@ledgerhq/coin-vechain 2.27.0 → 2.28.0

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 (204) hide show
  1. package/.unimportedrc.json +3 -21
  2. package/CHANGELOG.md +34 -0
  3. package/jest.config.js +39 -13
  4. package/lib/api/index.d.ts +4 -0
  5. package/lib/api/index.d.ts.map +1 -0
  6. package/lib/api/index.js +45 -0
  7. package/lib/api/index.js.map +1 -0
  8. package/lib/bridge/getTransactionStatus.js +1 -1
  9. package/lib/bridge/getTransactionStatus.js.map +1 -1
  10. package/lib/logic/account/getBalance.d.ts +5 -0
  11. package/lib/logic/account/getBalance.d.ts.map +1 -0
  12. package/lib/logic/account/getBalance.js +25 -0
  13. package/lib/logic/account/getBalance.js.map +1 -0
  14. package/lib/logic/history/getBlock.d.ts +3 -0
  15. package/lib/logic/history/getBlock.d.ts.map +1 -0
  16. package/lib/logic/history/getBlock.js +35 -0
  17. package/lib/logic/history/getBlock.js.map +1 -0
  18. package/lib/logic/history/getBlockInfo.d.ts +6 -0
  19. package/lib/logic/history/getBlockInfo.d.ts.map +1 -0
  20. package/lib/logic/history/getBlockInfo.js +23 -0
  21. package/lib/logic/history/getBlockInfo.js.map +1 -0
  22. package/lib/logic/history/lastBlock.d.ts +3 -0
  23. package/lib/logic/history/lastBlock.d.ts.map +1 -0
  24. package/lib/logic/history/lastBlock.js +11 -0
  25. package/lib/logic/history/lastBlock.js.map +1 -0
  26. package/lib/logic/history/listOperations.d.ts +3 -0
  27. package/lib/logic/history/listOperations.d.ts.map +1 -0
  28. package/lib/logic/history/listOperations.js +50 -0
  29. package/lib/logic/history/listOperations.js.map +1 -0
  30. package/lib/logic/index.d.ts +12 -0
  31. package/lib/logic/index.d.ts.map +1 -0
  32. package/lib/logic/index.js +28 -0
  33. package/lib/logic/index.js.map +1 -0
  34. package/lib/logic/transaction/broadcast.d.ts +3 -0
  35. package/lib/logic/transaction/broadcast.d.ts.map +1 -0
  36. package/lib/logic/transaction/broadcast.js +10 -0
  37. package/lib/logic/transaction/broadcast.js.map +1 -0
  38. package/lib/logic/transaction/combine.d.ts +2 -0
  39. package/lib/logic/transaction/combine.d.ts.map +1 -0
  40. package/lib/logic/transaction/combine.js +11 -0
  41. package/lib/logic/transaction/combine.js.map +1 -0
  42. package/lib/logic/transaction/craftTransaction.d.ts +3 -0
  43. package/lib/logic/transaction/craftTransaction.d.ts.map +1 -0
  44. package/lib/logic/transaction/craftTransaction.js +99 -0
  45. package/lib/logic/transaction/craftTransaction.js.map +1 -0
  46. package/lib/logic/transaction/estimateFees.d.ts +3 -0
  47. package/lib/logic/transaction/estimateFees.d.ts.map +1 -0
  48. package/lib/logic/transaction/estimateFees.js +23 -0
  49. package/lib/logic/transaction/estimateFees.js.map +1 -0
  50. package/lib/logic/validateAddress.d.ts +4 -0
  51. package/lib/logic/validateAddress.d.ts.map +1 -0
  52. package/lib/logic/validateAddress.js +14 -0
  53. package/lib/logic/validateAddress.js.map +1 -0
  54. package/lib/logic/validateIntent.d.ts +3 -0
  55. package/lib/logic/validateIntent.d.ts.map +1 -0
  56. package/lib/logic/validateIntent.js +65 -0
  57. package/lib/logic/validateIntent.js.map +1 -0
  58. package/lib/network/getBlock.d.ts +3 -0
  59. package/lib/network/getBlock.d.ts.map +1 -0
  60. package/lib/network/getBlock.js +23 -0
  61. package/lib/network/getBlock.js.map +1 -0
  62. package/lib/network/getFees.d.ts.map +1 -1
  63. package/lib/network/getFees.js +2 -3
  64. package/lib/network/getFees.js.map +1 -1
  65. package/lib/network/index.d.ts +1 -0
  66. package/lib/network/index.d.ts.map +1 -1
  67. package/lib/network/index.js +1 -0
  68. package/lib/network/index.js.map +1 -1
  69. package/lib/network/sdk.d.ts.map +1 -1
  70. package/lib/network/sdk.js +14 -8
  71. package/lib/network/sdk.js.map +1 -1
  72. package/lib/test/bridgeDatasetTest.js +1 -1
  73. package/lib/test/bridgeDatasetTest.js.map +1 -1
  74. package/lib/test/msw-setup.d.ts +2 -0
  75. package/lib/test/msw-setup.d.ts.map +1 -0
  76. package/lib/test/msw-setup.js +9 -0
  77. package/lib/test/msw-setup.js.map +1 -0
  78. package/lib/test/msw.mock.d.ts +3 -0
  79. package/lib/test/msw.mock.d.ts.map +1 -0
  80. package/lib/test/msw.mock.js +9 -0
  81. package/lib/test/msw.mock.js.map +1 -0
  82. package/lib/types/network.d.ts +30 -0
  83. package/lib/types/network.d.ts.map +1 -1
  84. package/lib-es/api/index.d.ts +4 -0
  85. package/lib-es/api/index.d.ts.map +1 -0
  86. package/lib-es/api/index.js +42 -0
  87. package/lib-es/api/index.js.map +1 -0
  88. package/lib-es/bridge/getTransactionStatus.js +1 -1
  89. package/lib-es/bridge/getTransactionStatus.js.map +1 -1
  90. package/lib-es/logic/account/getBalance.d.ts +5 -0
  91. package/lib-es/logic/account/getBalance.d.ts.map +1 -0
  92. package/lib-es/logic/account/getBalance.js +20 -0
  93. package/lib-es/logic/account/getBalance.js.map +1 -0
  94. package/lib-es/logic/history/getBlock.d.ts +3 -0
  95. package/lib-es/logic/history/getBlock.d.ts.map +1 -0
  96. package/lib-es/logic/history/getBlock.js +32 -0
  97. package/lib-es/logic/history/getBlock.js.map +1 -0
  98. package/lib-es/logic/history/getBlockInfo.d.ts +6 -0
  99. package/lib-es/logic/history/getBlockInfo.d.ts.map +1 -0
  100. package/lib-es/logic/history/getBlockInfo.js +19 -0
  101. package/lib-es/logic/history/getBlockInfo.js.map +1 -0
  102. package/lib-es/logic/history/lastBlock.d.ts +3 -0
  103. package/lib-es/logic/history/lastBlock.d.ts.map +1 -0
  104. package/lib-es/logic/history/lastBlock.js +8 -0
  105. package/lib-es/logic/history/lastBlock.js.map +1 -0
  106. package/lib-es/logic/history/listOperations.d.ts +3 -0
  107. package/lib-es/logic/history/listOperations.d.ts.map +1 -0
  108. package/lib-es/logic/history/listOperations.js +47 -0
  109. package/lib-es/logic/history/listOperations.js.map +1 -0
  110. package/lib-es/logic/index.d.ts +12 -0
  111. package/lib-es/logic/index.d.ts.map +1 -0
  112. package/lib-es/logic/index.js +12 -0
  113. package/lib-es/logic/index.js.map +1 -0
  114. package/lib-es/logic/transaction/broadcast.d.ts +3 -0
  115. package/lib-es/logic/transaction/broadcast.d.ts.map +1 -0
  116. package/lib-es/logic/transaction/broadcast.js +7 -0
  117. package/lib-es/logic/transaction/broadcast.js.map +1 -0
  118. package/lib-es/logic/transaction/combine.d.ts +2 -0
  119. package/lib-es/logic/transaction/combine.d.ts.map +1 -0
  120. package/lib-es/logic/transaction/combine.js +8 -0
  121. package/lib-es/logic/transaction/combine.js.map +1 -0
  122. package/lib-es/logic/transaction/craftTransaction.d.ts +3 -0
  123. package/lib-es/logic/transaction/craftTransaction.d.ts.map +1 -0
  124. package/lib-es/logic/transaction/craftTransaction.js +96 -0
  125. package/lib-es/logic/transaction/craftTransaction.js.map +1 -0
  126. package/lib-es/logic/transaction/estimateFees.d.ts +3 -0
  127. package/lib-es/logic/transaction/estimateFees.d.ts.map +1 -0
  128. package/lib-es/logic/transaction/estimateFees.js +20 -0
  129. package/lib-es/logic/transaction/estimateFees.js.map +1 -0
  130. package/lib-es/logic/validateAddress.d.ts +4 -0
  131. package/lib-es/logic/validateAddress.d.ts.map +1 -0
  132. package/lib-es/logic/validateAddress.js +10 -0
  133. package/lib-es/logic/validateAddress.js.map +1 -0
  134. package/lib-es/logic/validateIntent.d.ts +3 -0
  135. package/lib-es/logic/validateIntent.d.ts.map +1 -0
  136. package/lib-es/logic/validateIntent.js +62 -0
  137. package/lib-es/logic/validateIntent.js.map +1 -0
  138. package/lib-es/network/getBlock.d.ts +3 -0
  139. package/lib-es/network/getBlock.d.ts.map +1 -0
  140. package/lib-es/network/getBlock.js +16 -0
  141. package/lib-es/network/getBlock.js.map +1 -0
  142. package/lib-es/network/getFees.d.ts.map +1 -1
  143. package/lib-es/network/getFees.js +1 -2
  144. package/lib-es/network/getFees.js.map +1 -1
  145. package/lib-es/network/index.d.ts +1 -0
  146. package/lib-es/network/index.d.ts.map +1 -1
  147. package/lib-es/network/index.js +1 -0
  148. package/lib-es/network/index.js.map +1 -1
  149. package/lib-es/network/sdk.d.ts.map +1 -1
  150. package/lib-es/network/sdk.js +8 -2
  151. package/lib-es/network/sdk.js.map +1 -1
  152. package/lib-es/test/bridgeDatasetTest.js +1 -1
  153. package/lib-es/test/bridgeDatasetTest.js.map +1 -1
  154. package/lib-es/test/msw-setup.d.ts +2 -0
  155. package/lib-es/test/msw-setup.d.ts.map +1 -0
  156. package/lib-es/test/msw-setup.js +7 -0
  157. package/lib-es/test/msw-setup.js.map +1 -0
  158. package/lib-es/test/msw.mock.d.ts +3 -0
  159. package/lib-es/test/msw.mock.d.ts.map +1 -0
  160. package/lib-es/test/msw.mock.js +6 -0
  161. package/lib-es/test/msw.mock.js.map +1 -0
  162. package/lib-es/types/network.d.ts +30 -0
  163. package/lib-es/types/network.d.ts.map +1 -1
  164. package/package.json +7 -8
  165. package/src/api/index.integ.test.ts +123 -0
  166. package/src/api/index.ts +89 -0
  167. package/src/api/index.unit.test.ts +234 -0
  168. package/src/bridge/getTransactionStatus.test.ts +1 -1
  169. package/src/bridge/getTransactionStatus.ts +1 -1
  170. package/src/bridge/synchronisation.test.ts +7 -3
  171. package/src/logic/account/getBalance.ts +24 -0
  172. package/src/logic/account/getBalance.unit.test.ts +50 -0
  173. package/src/logic/history/getBlock.ts +47 -0
  174. package/src/logic/history/getBlock.unit.test.ts +106 -0
  175. package/src/logic/history/getBlockInfo.ts +24 -0
  176. package/src/logic/history/getBlockInfo.unit.test.ts +30 -0
  177. package/src/logic/history/lastBlock.ts +9 -0
  178. package/src/logic/history/lastBlock.unit.test.ts +30 -0
  179. package/src/logic/history/listOperations.ts +64 -0
  180. package/src/logic/history/listOperations.unit.test.ts +129 -0
  181. package/src/logic/index.ts +11 -0
  182. package/src/logic/transaction/broadcast.msw.test.ts +23 -0
  183. package/src/logic/transaction/broadcast.ts +10 -0
  184. package/src/logic/transaction/broadcast.unit.test.ts +26 -0
  185. package/src/logic/transaction/combine.ts +10 -0
  186. package/src/logic/transaction/combine.unit.test.ts +37 -0
  187. package/src/logic/transaction/craftTransaction.ts +137 -0
  188. package/src/logic/transaction/craftTransaction.unit.test.ts +217 -0
  189. package/src/logic/transaction/estimateFees.ts +32 -0
  190. package/src/logic/transaction/estimateFees.unit.test.ts +102 -0
  191. package/src/logic/validateAddress.ts +15 -0
  192. package/src/logic/validateAddress.unit.test.ts +15 -0
  193. package/src/logic/validateIntent.ts +96 -0
  194. package/src/logic/validateIntent.unit.test.ts +156 -0
  195. package/src/network/getBlock.ts +23 -0
  196. package/src/network/getBlock.unit.test.ts +58 -0
  197. package/src/network/getFees.ts +1 -3
  198. package/src/network/index.ts +1 -0
  199. package/src/network/sdk.test.ts +32 -1
  200. package/src/network/sdk.ts +9 -2
  201. package/src/test/bridgeDatasetTest.ts +1 -1
  202. package/src/test/msw-setup.ts +7 -0
  203. package/src/test/msw.mock.ts +6 -0
  204. package/src/types/network.ts +34 -0
@@ -0,0 +1,102 @@
1
+ import type { TransactionIntent } from "@ledgerhq/coin-module-framework/api/index";
2
+ import { craftTransaction } from "./craftTransaction";
3
+ import { estimateFees } from "./estimateFees";
4
+
5
+ jest.mock("./craftTransaction", () => ({ craftTransaction: jest.fn() }));
6
+
7
+ const INTENT: TransactionIntent = {
8
+ intentType: "transaction",
9
+ type: "send",
10
+ sender: "0x0fe6688548f0C303932bB197B0A96034f1d74dba",
11
+ recipient: "0x02961B92B8D20A4ea12f1f1CeFA74Dd7B4355A86",
12
+ amount: 1_000n,
13
+ asset: { type: "native" },
14
+ };
15
+
16
+ describe("estimateFees", () => {
17
+ afterEach(() => {
18
+ jest.clearAllMocks();
19
+ });
20
+
21
+ it("crafts the transaction and reads back the settled fee/gas parameters", async () => {
22
+ jest.mocked(craftTransaction).mockResolvedValueOnce({
23
+ transaction: "{}",
24
+ details: { fee: "21000000", gas: 21000, maxFeePerGas: 1000, maxPriorityFeePerGas: 100 },
25
+ });
26
+
27
+ const fees = await estimateFees(INTENT);
28
+
29
+ expect(craftTransaction).toHaveBeenCalledWith(INTENT, undefined);
30
+ expect(fees).toEqual({
31
+ value: 21000000n,
32
+ parameters: { gas: 21000, maxFeePerGas: 1000, maxPriorityFeePerGas: 100 },
33
+ });
34
+ });
35
+
36
+ it("forwards customFeesParameters to craftTransaction", async () => {
37
+ jest.mocked(craftTransaction).mockResolvedValueOnce({
38
+ transaction: "{}",
39
+ details: { fee: "1", gas: 1, maxFeePerGas: 1, maxPriorityFeePerGas: 1 },
40
+ });
41
+
42
+ await estimateFees(INTENT, { gas: 30000 });
43
+
44
+ expect(craftTransaction).toHaveBeenCalledWith(INTENT, {
45
+ value: 0n,
46
+ parameters: { gas: 30000 },
47
+ });
48
+ });
49
+
50
+ it("defaults the fee to 0 when craftTransaction returns no details", async () => {
51
+ jest.mocked(craftTransaction).mockResolvedValueOnce({ transaction: "{}" });
52
+
53
+ const fees = await estimateFees(INTENT);
54
+
55
+ expect(fees.value).toBe(0n);
56
+ });
57
+
58
+ it("propagates an error from craftTransaction", async () => {
59
+ jest
60
+ .mocked(craftTransaction)
61
+ .mockRejectedValueOnce(new Error("vechain: recipient is required"));
62
+
63
+ await expect(estimateFees(INTENT)).rejects.toThrow("vechain: recipient is required");
64
+ });
65
+
66
+ it("substitutes a 1-wei placeholder amount for a zero-amount pre-estimation", async () => {
67
+ jest.mocked(craftTransaction).mockResolvedValueOnce({
68
+ transaction: "{}",
69
+ details: { fee: "21000000", gas: 21000, maxFeePerGas: 1000, maxPriorityFeePerGas: 100 },
70
+ });
71
+
72
+ await estimateFees({ ...INTENT, amount: 0n });
73
+
74
+ expect(craftTransaction).toHaveBeenCalledWith({ ...INTENT, amount: 1n }, undefined);
75
+ });
76
+
77
+ it("does not substitute a placeholder when useAllAmount is set (craftTransaction resolves the balance)", async () => {
78
+ jest.mocked(craftTransaction).mockResolvedValueOnce({
79
+ transaction: "{}",
80
+ details: { fee: "1", gas: 1, maxFeePerGas: 1, maxPriorityFeePerGas: 1 },
81
+ });
82
+
83
+ const maxIntent = { ...INTENT, amount: 0n, useAllAmount: true };
84
+ await estimateFees(maxIntent);
85
+
86
+ expect(craftTransaction).toHaveBeenCalledWith(maxIntent, undefined);
87
+ });
88
+
89
+ it("returns a zero estimate without crafting when the recipient is missing", async () => {
90
+ const fees = await estimateFees({ ...INTENT, recipient: "" });
91
+
92
+ expect(fees).toEqual({ value: 0n, parameters: {} });
93
+ expect(craftTransaction).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("returns a zero estimate without crafting when the recipient is not a valid address", async () => {
97
+ const fees = await estimateFees({ ...INTENT, recipient: "0xnot-an-address" });
98
+
99
+ expect(fees).toEqual({ value: 0n, parameters: {} });
100
+ expect(craftTransaction).not.toHaveBeenCalled();
101
+ });
102
+ });
@@ -0,0 +1,15 @@
1
+ import type { AddressValidationCurrencyParameters } from "@ledgerhq/coin-module-framework/api/index";
2
+ import { parseAddress } from "../common-logic";
3
+
4
+ // Validate a VeChain address. Kept in logic/ so api/ depends on the logic layer for validation
5
+ // like every other method, rather than reaching into common-logic directly.
6
+ export function isValidAddress(address: string): boolean {
7
+ return parseAddress(address);
8
+ }
9
+
10
+ export async function validateAddress(
11
+ address: string,
12
+ _parameters: Partial<AddressValidationCurrencyParameters>,
13
+ ): Promise<boolean> {
14
+ return isValidAddress(address);
15
+ }
@@ -0,0 +1,15 @@
1
+ import { isValidAddress, validateAddress } from "./validateAddress";
2
+
3
+ const VALID = "0x0fe6688548f0C303932bB197B0A96034f1d74dba";
4
+
5
+ describe("validateAddress", () => {
6
+ it("returns true for a valid VeChain address", async () => {
7
+ expect(isValidAddress(VALID)).toBe(true);
8
+ await expect(validateAddress(VALID, {})).resolves.toBe(true);
9
+ });
10
+
11
+ it("returns false for a malformed address", async () => {
12
+ expect(isValidAddress("0xnot-an-address")).toBe(false);
13
+ await expect(validateAddress("0xnot-an-address", {})).resolves.toBe(false);
14
+ });
15
+ });
@@ -0,0 +1,96 @@
1
+ import type {
2
+ Balance,
3
+ FeeEstimation,
4
+ MemoNotSupported,
5
+ TransactionIntent,
6
+ TransactionValidation,
7
+ } from "@ledgerhq/coin-module-framework/api/index";
8
+ import {
9
+ AmountRequired,
10
+ InvalidAddress,
11
+ InvalidAddressBecauseDestinationIsAlsoSource,
12
+ NotEnoughBalance,
13
+ RecipientRequired,
14
+ } from "@ledgerhq/ledger-wallet-framework/errors";
15
+ import { parseAddress } from "../common-logic";
16
+ import { NotEnoughVTHO } from "../errors";
17
+
18
+ function isTokenAsset(intent: TransactionIntent): boolean {
19
+ return intent.asset.type !== "native";
20
+ }
21
+
22
+ function spendable(balances: Balance[], isToken: boolean): bigint {
23
+ const asset = balances.find(b =>
24
+ isToken ? b.asset.type !== "native" : b.asset.type === "native",
25
+ );
26
+ return (asset?.value ?? 0n) - (asset?.locked ?? 0n);
27
+ }
28
+
29
+ function amountError(
30
+ intent: TransactionIntent<MemoNotSupported>,
31
+ isToken: boolean,
32
+ amount: bigint,
33
+ maxAmount: bigint,
34
+ estimatedFees: bigint,
35
+ availableAsset: bigint,
36
+ ): Error | undefined {
37
+ if (intent.useAllAmount && maxAmount === 0n) {
38
+ return new NotEnoughBalance();
39
+ }
40
+ if (!intent.useAllAmount && amount <= 0n) {
41
+ return new AmountRequired();
42
+ }
43
+ if (isToken && amount + estimatedFees > availableAsset) {
44
+ return new NotEnoughBalance();
45
+ }
46
+ if (!isToken && amount > availableAsset) {
47
+ return new NotEnoughBalance();
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ // Validate recipient, a positive amount, and enough balance incl. the VTHO gas fee (always VTHO).
53
+ export async function validateIntent(
54
+ intent: TransactionIntent<MemoNotSupported>,
55
+ balances: Balance[],
56
+ customFees?: FeeEstimation,
57
+ ): Promise<TransactionValidation> {
58
+ const errors: Record<string, Error> = {};
59
+ const warnings: Record<string, Error> = {};
60
+
61
+ const isToken = isTokenAsset(intent);
62
+ const estimatedFees = customFees?.value ?? 0n;
63
+ const availableAsset = spendable(balances, isToken);
64
+ const availableVtho = spendable(balances, true);
65
+
66
+ if (!intent.recipient) {
67
+ errors.recipient = new RecipientRequired();
68
+ } else if (intent.sender.toLowerCase() === intent.recipient.toLowerCase()) {
69
+ warnings.recipient = new InvalidAddressBecauseDestinationIsAlsoSource();
70
+ } else if (!parseAddress(intent.recipient)) {
71
+ errors.recipient = new InvalidAddress("", { currencyName: "VeChain" });
72
+ }
73
+
74
+ const spendableForAmount = isToken ? availableAsset - estimatedFees : availableAsset;
75
+ const maxAmount = spendableForAmount > 0n ? spendableForAmount : 0n;
76
+ const amount = intent.useAllAmount ? maxAmount : intent.amount;
77
+
78
+ const amountErr = amountError(intent, isToken, amount, maxAmount, estimatedFees, availableAsset);
79
+ if (amountErr) {
80
+ errors.amount = amountErr;
81
+ }
82
+
83
+ if (!errors.amount && estimatedFees > availableVtho) {
84
+ errors.amount = new NotEnoughVTHO();
85
+ }
86
+
87
+ const totalSpent = isToken ? amount + estimatedFees : amount;
88
+
89
+ return {
90
+ errors,
91
+ warnings,
92
+ estimatedFees,
93
+ amount,
94
+ totalSpent,
95
+ };
96
+ }
@@ -0,0 +1,156 @@
1
+ import type { Balance, TransactionIntent } from "@ledgerhq/coin-module-framework/api/index";
2
+ import { validateIntent } from "./validateIntent";
3
+
4
+ const SENDER = "0x0fe6688548f0C303932bB197B0A96034f1d74dba";
5
+ const RECIPIENT = "0x02961B92B8D20A4ea12f1f1CeFA74Dd7B4355A86";
6
+
7
+ const NATIVE_INTENT: TransactionIntent = {
8
+ intentType: "transaction",
9
+ type: "send",
10
+ sender: SENDER,
11
+ recipient: RECIPIENT,
12
+ amount: 1_000n,
13
+ asset: { type: "native" },
14
+ };
15
+
16
+ const BALANCES: Balance[] = [
17
+ { value: 10_000n, asset: { type: "native" } },
18
+ { value: 5_000n, asset: { type: "token", assetReference: "0xvtho" } },
19
+ ];
20
+
21
+ describe("validateIntent", () => {
22
+ it("passes for a well-formed native send with sufficient balance and fees", async () => {
23
+ const result = await validateIntent(NATIVE_INTENT, BALANCES, { value: 100n });
24
+
25
+ expect(result.errors).toEqual({});
26
+ expect(result.amount).toBe(1_000n);
27
+ expect(result.totalSpent).toBe(1_000n);
28
+ });
29
+
30
+ it("flags a missing recipient", async () => {
31
+ const result = await validateIntent({ ...NATIVE_INTENT, recipient: "" }, BALANCES);
32
+
33
+ expect(result.errors.recipient).toBeInstanceOf(Error);
34
+ });
35
+
36
+ it("flags an ill-formed recipient address", async () => {
37
+ const result = await validateIntent(
38
+ { ...NATIVE_INTENT, recipient: "not-an-address" },
39
+ BALANCES,
40
+ );
41
+
42
+ expect(result.errors.recipient).toBeInstanceOf(Error);
43
+ });
44
+
45
+ it("warns when the recipient equals the sender", async () => {
46
+ const result = await validateIntent({ ...NATIVE_INTENT, recipient: SENDER }, BALANCES);
47
+
48
+ expect(result.warnings.recipient).toBeInstanceOf(Error);
49
+ });
50
+
51
+ it("flags a zero amount when not sending the full balance", async () => {
52
+ const result = await validateIntent({ ...NATIVE_INTENT, amount: 0n }, BALANCES);
53
+
54
+ expect(result.errors.amount).toBeInstanceOf(Error);
55
+ });
56
+
57
+ it("flags insufficient native balance for the requested amount", async () => {
58
+ const result = await validateIntent({ ...NATIVE_INTENT, amount: 999_999n }, BALANCES);
59
+
60
+ expect(result.errors.amount).toBeInstanceOf(Error);
61
+ });
62
+
63
+ it("flags NotEnoughVTHO when fees exceed the VTHO balance, even with enough VET", async () => {
64
+ const result = await validateIntent(NATIVE_INTENT, BALANCES, { value: 999_999n });
65
+
66
+ expect(result.errors.amount?.name).toBe("NotEnoughVTHO");
67
+ });
68
+
69
+ it("subtracts the estimated fee from the spendable amount for a max VTHO send", async () => {
70
+ const tokenIntent: TransactionIntent = {
71
+ ...NATIVE_INTENT,
72
+ asset: { type: "token", assetReference: "0xvtho" },
73
+ useAllAmount: true,
74
+ };
75
+
76
+ const result = await validateIntent(tokenIntent, BALANCES, { value: 100n });
77
+
78
+ expect(result.amount).toBe(4_900n); // 5000 VTHO balance - 100 fee
79
+ expect(result.totalSpent).toBe(5_000n);
80
+ });
81
+
82
+ it("flags NotEnoughBalance for a max VTHO send when the fee consumes the whole balance", async () => {
83
+ const tokenIntent: TransactionIntent = {
84
+ ...NATIVE_INTENT,
85
+ asset: { type: "token", assetReference: "0xvtho" },
86
+ useAllAmount: true,
87
+ };
88
+
89
+ const result = await validateIntent(tokenIntent, BALANCES, { value: 5_000n }); // fee == full VTHO balance
90
+
91
+ expect(result.amount).toBe(0n);
92
+ expect(result.errors.amount?.name).toBe("NotEnoughBalance");
93
+ });
94
+
95
+ it("does not subtract fees from a max native VET send (fees are paid in VTHO)", async () => {
96
+ const nativeMax: TransactionIntent = { ...NATIVE_INTENT, useAllAmount: true };
97
+
98
+ const result = await validateIntent(nativeMax, BALANCES, { value: 100n });
99
+
100
+ expect(result.amount).toBe(10_000n);
101
+ });
102
+
103
+ it("passes a native send at the exact balance boundary (amount == available)", async () => {
104
+ const result = await validateIntent({ ...NATIVE_INTENT, amount: 10_000n }, BALANCES, {
105
+ value: 100n,
106
+ });
107
+ expect(result.errors).toEqual({});
108
+ expect(result.amount).toBe(10_000n);
109
+ });
110
+
111
+ it("flags a native send one wei over the balance", async () => {
112
+ const result = await validateIntent({ ...NATIVE_INTENT, amount: 10_001n }, BALANCES, {
113
+ value: 100n,
114
+ });
115
+ expect(result.errors.amount?.name).toBe("NotEnoughBalance");
116
+ });
117
+
118
+ it("passes a dust native amount (1 wei)", async () => {
119
+ const result = await validateIntent({ ...NATIVE_INTENT, amount: 1n }, BALANCES, {
120
+ value: 100n,
121
+ });
122
+ expect(result.errors).toEqual({});
123
+ expect(result.amount).toBe(1n);
124
+ });
125
+
126
+ it("does not flag NotEnoughVTHO when the fee equals the VTHO balance exactly", async () => {
127
+ const result = await validateIntent(NATIVE_INTENT, BALANCES, { value: 5_000n });
128
+ expect(result.errors.amount).toBeUndefined();
129
+ });
130
+
131
+ it("flags NotEnoughVTHO when the fee is one wei over the VTHO balance", async () => {
132
+ const result = await validateIntent(NATIVE_INTENT, BALANCES, { value: 5_001n });
133
+ expect(result.errors.amount?.name).toBe("NotEnoughVTHO");
134
+ });
135
+
136
+ const tokenIntent = (amount: bigint): TransactionIntent => ({
137
+ ...NATIVE_INTENT,
138
+ asset: { type: "token", assetReference: "0xvtho" },
139
+ amount,
140
+ });
141
+
142
+ it("passes a VTHO send at the exact spendable boundary (amount + fee == balance)", async () => {
143
+ const result = await validateIntent(tokenIntent(4_900n), BALANCES, { value: 100n });
144
+ expect(result.errors).toEqual({});
145
+ });
146
+
147
+ it("flags a VTHO send one wei over the spendable boundary (amount + fee > balance)", async () => {
148
+ const result = await validateIntent(tokenIntent(4_901n), BALANCES, { value: 100n });
149
+ expect(result.errors.amount?.name).toBe("NotEnoughBalance");
150
+ });
151
+
152
+ it("passes a dust VTHO amount (1 wei)", async () => {
153
+ const result = await validateIntent(tokenIntent(1n), BALANCES, { value: 100n });
154
+ expect(result.errors).toEqual({});
155
+ });
156
+ });
@@ -0,0 +1,23 @@
1
+ import network from "@ledgerhq/live-network";
2
+ import { VECHAIN_NODE_URL as BASE_URL } from "../constants";
3
+ import type { ApiResponseBlock } from "../types";
4
+
5
+ // Thor GET /blocks/{revision}. `expanded` returns full clause/output detail (getBlock) vs. tx
6
+ // hashes only (getBlockInfo/lastBlock).
7
+ export const getBlock = async (
8
+ revision: number,
9
+ expanded = false,
10
+ ): Promise<ApiResponseBlock | null> => {
11
+ if (!Number.isInteger(revision) || revision < 0) {
12
+ throw new Error(`vechain: getBlock: invalid revision ${revision}`);
13
+ }
14
+
15
+ const query = new URLSearchParams({ expanded: String(expanded) });
16
+
17
+ const { data } = await network<ApiResponseBlock | null>({
18
+ method: "GET",
19
+ url: `${BASE_URL}/blocks/${revision}?${query}`,
20
+ });
21
+
22
+ return data;
23
+ };
@@ -0,0 +1,58 @@
1
+ import network from "@ledgerhq/live-network";
2
+ import { getBlock } from "./getBlock";
3
+
4
+ jest.mock("@ledgerhq/live-network", () => jest.fn());
5
+
6
+ describe("getBlock", () => {
7
+ afterEach(() => {
8
+ jest.clearAllMocks();
9
+ });
10
+
11
+ it("returns the parsed block on a successful response", async () => {
12
+ const block = { id: "0xabc", number: 42, timestamp: 1_700_000_000, transactions: [] };
13
+ jest.mocked(network).mockResolvedValueOnce({ data: block, status: 200 });
14
+
15
+ const result = await getBlock(42);
16
+
17
+ expect(result).toEqual(block);
18
+ });
19
+
20
+ it("requests expanded=false by default", async () => {
21
+ jest.mocked(network).mockResolvedValueOnce({ data: null, status: 200 });
22
+
23
+ await getBlock(42);
24
+
25
+ expect(network).toHaveBeenCalledWith(
26
+ expect.objectContaining({
27
+ method: "GET",
28
+ url: expect.stringContaining("/blocks/42?expanded=false"),
29
+ }),
30
+ );
31
+ });
32
+
33
+ it("requests expanded=true when asked", async () => {
34
+ jest.mocked(network).mockResolvedValueOnce({ data: null, status: 200 });
35
+
36
+ await getBlock(42, true);
37
+
38
+ expect(network).toHaveBeenCalledWith(
39
+ expect.objectContaining({ url: expect.stringContaining("expanded=true") }),
40
+ );
41
+ });
42
+
43
+ it("rejects a negative revision without making a network call", async () => {
44
+ await expect(getBlock(-1)).rejects.toThrow("vechain: getBlock: invalid revision -1");
45
+ expect(network).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it("rejects a non-integer revision without making a network call", async () => {
49
+ await expect(getBlock(1.5)).rejects.toThrow("vechain: getBlock: invalid revision 1.5");
50
+ expect(network).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it("propagates a network error", async () => {
54
+ jest.mocked(network).mockRejectedValueOnce(new Error("status 500"));
55
+
56
+ await expect(getBlock(42)).rejects.toThrow("status 500");
57
+ });
58
+ });
@@ -1,8 +1,6 @@
1
1
  import BigNumber from "bignumber.js";
2
2
  import network from "@ledgerhq/live-network";
3
- import { getEnv } from "@ledgerhq/live-env";
4
-
5
- const BASE_URL = getEnv("API_VECHAIN_THOREST");
3
+ import { VECHAIN_NODE_URL as BASE_URL } from "../constants";
6
4
 
7
5
  /**
8
6
  * Get fees paid for the transaction
@@ -1,2 +1,3 @@
1
1
  export { getAccount, getOperations } from "./sdk";
2
2
  export * from "./sdk";
3
+ export * from "./getBlock";
@@ -1,6 +1,6 @@
1
1
  import BigNumber from "bignumber.js";
2
2
  import type { Operation } from "@ledgerhq/types-live";
3
- import { LedgerAPI4xx } from "@ledgerhq/errors";
3
+ import { LedgerAPI4xx } from "@ledgerhq/live-network/errors";
4
4
  import {
5
5
  getAccount,
6
6
  getBlockRef,
@@ -203,6 +203,21 @@ describe("sdk", () => {
203
203
  });
204
204
  });
205
205
 
206
+ describe("when the range is inverted (no new block since the last known operation)", () => {
207
+ test("returns an empty array without querying the network", async () => {
208
+ mockGetTransferLogs.mockClear();
209
+ const operations = await getOperations(
210
+ "my-account-id",
211
+ "0xmy-address",
212
+ LAST_BLOCK_COUNT + 1,
213
+ LAST_BLOCK_COUNT,
214
+ );
215
+
216
+ expect(operations).toEqual([]);
217
+ expect(mockGetTransferLogs).not.toHaveBeenCalled();
218
+ });
219
+ });
220
+
206
221
  describe("when logged operations exceed limit in the given range", () => {
207
222
  beforeEach(() => {
208
223
  mockGetTransferLogs.mockImplementationOnce(throws403ExceedsLimit);
@@ -280,6 +295,22 @@ describe("sdk", () => {
280
295
  });
281
296
  });
282
297
 
298
+ describe("when the range is inverted (no new block since the last known operation)", () => {
299
+ test("returns an empty array without querying the network", async () => {
300
+ mockGetEventLogs.mockClear();
301
+ const operations = await getTokenOperations(
302
+ "my-account-id",
303
+ "0xmy-address",
304
+ "0xmy-token-address",
305
+ LAST_BLOCK_COUNT + 1,
306
+ LAST_BLOCK_COUNT,
307
+ );
308
+
309
+ expect(operations).toEqual([]);
310
+ expect(mockGetEventLogs).not.toHaveBeenCalled();
311
+ });
312
+ });
313
+
283
314
  describe("when logged operations exceed limit in the given range", () => {
284
315
  beforeEach(() => {
285
316
  mockGetEventLogs.mockImplementationOnce(throws403ExceedsLimit);
@@ -13,9 +13,8 @@ import {
13
13
  mapTokenTransfersToOperations,
14
14
  padAddress,
15
15
  } from "../common-logic";
16
- import { getEnv } from "@ledgerhq/live-env";
16
+ import { VECHAIN_NODE_URL as BASE_URL } from "../constants";
17
17
 
18
- const BASE_URL = getEnv("API_VECHAIN_THOREST");
19
18
  const NET_ERROR_LOG_TRANSFERS_LIMIT = {
20
19
  status: 403,
21
20
  msgPattern: /exceeds the maximum allowed/,
@@ -149,6 +148,14 @@ const getLogsWithPagination = async (
149
148
  stopAt: number,
150
149
  fetchRangeOfLogs: (from: number, to: number) => Promise<Operation[]>,
151
150
  ): Promise<Operation[]> => {
151
+ // No new block since the last known operation (startAt = lastOp.blockHeight + 1 can exceed the
152
+ // current best height when nothing has happened since the previous sync) — Thor's `/logs/*`
153
+ // endpoints reject an inverted range with a 400, so treat it as "nothing new" instead of
154
+ // querying at all.
155
+ if (stopAt < startAt) {
156
+ return [];
157
+ }
158
+
152
159
  let completed = false;
153
160
  const ops: Operation[] = [];
154
161
 
@@ -11,7 +11,7 @@ import { MAINNET_CHAIN_TAG } from "../types";
11
11
  import { vechain1, vechain3 } from "../datasets";
12
12
  import { generateNonce } from "../common-logic";
13
13
  import vechainScanAccounts1 from "../datasets/vechain.scanAccounts.1";
14
- import { AmountRequired, NotEnoughBalance } from "@ledgerhq/errors";
14
+ import { AmountRequired, NotEnoughBalance } from "@ledgerhq/ledger-wallet-framework/errors";
15
15
  import { NotEnoughVTHO } from "../errors";
16
16
  import { ABIContract, VIP180_ABI } from "@vechain/sdk-core";
17
17
 
@@ -0,0 +1,7 @@
1
+ import { setEnv } from "@ledgerhq/live-env";
2
+ import { TEST_VECHAIN_ENDPOINT } from "./msw.mock";
3
+
4
+ // Runs as a jest `setupFiles` entry (before the test modules load), so `constants/env.ts` — which
5
+ // reads `API_VECHAIN_THOREST` into a module-level const at import time — resolves to the test host
6
+ // that the MSW handlers intercept.
7
+ setEnv("API_VECHAIN_THOREST", TEST_VECHAIN_ENDPOINT);
@@ -0,0 +1,6 @@
1
+ import { setupServer } from "msw/node";
2
+
3
+ // Test host that `msw-setup.ts` points `API_VECHAIN_THOREST` at, so MSW handlers intercept the exact
4
+ // URL the network layer builds. Shared, empty MSW server; each suite registers handlers via `server.use`.
5
+ export const TEST_VECHAIN_ENDPOINT = "https://vechain-test.ledger.com";
6
+ export const server = setupServer();
@@ -79,3 +79,37 @@ export interface QueryResponse {
79
79
  reverted: boolean;
80
80
  vmError: string;
81
81
  }
82
+
83
+ /** Thor `GET /blocks/{revision}` transfer (VET or VIP180 event, when `expanded=true`). */
84
+ export interface ApiResponseBlockTransfer {
85
+ sender: string;
86
+ recipient: string;
87
+ amount: string;
88
+ }
89
+
90
+ /** A single clause output, part of an expanded block transaction. */
91
+ export interface ApiResponseBlockOutput {
92
+ contractAddress: string | null;
93
+ events: EventLog[];
94
+ transfers: ApiResponseBlockTransfer[];
95
+ }
96
+
97
+ /** An expanded transaction, as returned by Thor `GET /blocks/{revision}?expanded=true`. */
98
+ export interface ApiResponseBlockTransaction {
99
+ id: string;
100
+ origin: string;
101
+ gasPayer?: string;
102
+ gasUsed: number;
103
+ paid: string;
104
+ reverted: boolean;
105
+ outputs: ApiResponseBlockOutput[];
106
+ }
107
+
108
+ /** Thor `GET /blocks/{revision}` response. */
109
+ export interface ApiResponseBlock {
110
+ id: string;
111
+ number: number;
112
+ timestamp: number;
113
+ /** Transaction ids (`string[]`) when `expanded=false`, full transactions when `expanded=true`. */
114
+ transactions: unknown[];
115
+ }