@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,129 @@
1
+ import BigNumber from "bignumber.js";
2
+ import type { Operation as LegacyOperation } from "@ledgerhq/types-live";
3
+ import { getLastBlockHeight, getOperations, getTokenOperations } from "../../network";
4
+ import { listOperations } from "./listOperations";
5
+
6
+ jest.mock("../../network", () => ({
7
+ getLastBlockHeight: jest.fn(),
8
+ getOperations: jest.fn(),
9
+ getTokenOperations: jest.fn(),
10
+ }));
11
+
12
+ const ADDRESS = "0x0fe6688548f0C303932bB197B0A96034f1d74dba";
13
+
14
+ function makeLegacyOp(overrides: Partial<LegacyOperation> = {}): LegacyOperation {
15
+ return {
16
+ id: "op1",
17
+ hash: "0xtx1",
18
+ type: "OUT",
19
+ value: new BigNumber("1000000000000000000"),
20
+ fee: new BigNumber("21000000000000000"),
21
+ senders: [ADDRESS],
22
+ recipients: ["0xrecipient"],
23
+ blockHeight: 100,
24
+ blockHash: "0xblock",
25
+ accountId: ADDRESS,
26
+ date: new Date("2024-01-01T00:00:00Z"),
27
+ extra: {},
28
+ ...overrides,
29
+ } as unknown as LegacyOperation;
30
+ }
31
+
32
+ describe("listOperations", () => {
33
+ afterEach(() => {
34
+ jest.clearAllMocks();
35
+ });
36
+
37
+ it("merges VET and VTHO operations, both asset paths present", async () => {
38
+ jest.mocked(getLastBlockHeight).mockResolvedValueOnce(200);
39
+ jest.mocked(getOperations).mockResolvedValueOnce([makeLegacyOp({ id: "vet-op", type: "IN" })]);
40
+ jest
41
+ .mocked(getTokenOperations)
42
+ .mockResolvedValueOnce([makeLegacyOp({ id: "vtho-op", type: "IN" })]);
43
+
44
+ const page = await listOperations(ADDRESS, { minHeight: 0 });
45
+
46
+ expect(page.items).toHaveLength(2);
47
+ expect(page.items.find(op => op.id === "vet-op")?.asset).toEqual({
48
+ type: "native",
49
+ name: "VET",
50
+ });
51
+ expect(page.items.find(op => op.id === "vtho-op")?.asset.type).not.toBe("native");
52
+ expect(page.items.find(op => op.id === "vet-op")?.details?.ledgerOpType).toBe("IN");
53
+ expect(page.items.find(op => op.id === "vtho-op")?.details?.ledgerOpType).toBe("IN");
54
+ });
55
+
56
+ it("merges VET + VTHO without duplicate operations and resumes one block past the head", async () => {
57
+ jest.mocked(getLastBlockHeight).mockResolvedValueOnce(300);
58
+ jest
59
+ .mocked(getOperations)
60
+ .mockResolvedValueOnce([
61
+ makeLegacyOp({ id: "vet-1", date: new Date("2024-01-03T00:00:00Z") }),
62
+ makeLegacyOp({ id: "vet-2", date: new Date("2024-01-01T00:00:00Z") }),
63
+ ]);
64
+ jest
65
+ .mocked(getTokenOperations)
66
+ .mockResolvedValueOnce([
67
+ makeLegacyOp({ id: "vtho-1", date: new Date("2024-01-02T00:00:00Z") }),
68
+ ]);
69
+
70
+ const page = await listOperations(ADDRESS, { minHeight: 0 });
71
+
72
+ const ids = page.items.map(op => op.id);
73
+ expect(ids).toHaveLength(3);
74
+ expect(new Set(ids).size).toBe(ids.length); // no duplicates across the merged VET + VTHO streams
75
+ expect(ids).toEqual(["vet-1", "vtho-1", "vet-2"]); // merged, sorted by date desc
76
+ // Cursor resumes at head + 1 so the boundary block is never re-scanned into a duplicate next page.
77
+ expect(page.next).toBe("301");
78
+ });
79
+
80
+ it("attaches no fee to a native VET operation (VeChain gas is VTHO, avoids inflating the debit)", async () => {
81
+ jest.mocked(getLastBlockHeight).mockResolvedValueOnce(200);
82
+ const outOp = makeLegacyOp({
83
+ type: "OUT",
84
+ value: new BigNumber("1000000000000000000"),
85
+ fee: new BigNumber("21000000000000000"),
86
+ });
87
+ jest.mocked(getOperations).mockResolvedValueOnce([outOp]);
88
+ jest.mocked(getTokenOperations).mockResolvedValueOnce([]);
89
+
90
+ const page = await listOperations(ADDRESS, { minHeight: 0 });
91
+
92
+ expect(page.items[0].value).toBe(BigInt("1000000000000000000"));
93
+ expect(page.items[0].tx.fees).toBe(0n);
94
+ });
95
+
96
+ it("keeps the VTHO gas as the fee on a VTHO (token) operation", async () => {
97
+ jest.mocked(getLastBlockHeight).mockResolvedValueOnce(200);
98
+ const vthoOut = makeLegacyOp({
99
+ type: "OUT",
100
+ value: new BigNumber("5000000000000000000"),
101
+ fee: new BigNumber("21000000000000000"),
102
+ });
103
+ jest.mocked(getOperations).mockResolvedValueOnce([]);
104
+ jest.mocked(getTokenOperations).mockResolvedValueOnce([vthoOut]);
105
+
106
+ const page = await listOperations(ADDRESS, { minHeight: 0 });
107
+
108
+ expect(page.items[0].value).toBe(BigInt("5000000000000000000"));
109
+ expect(page.items[0].tx.fees).toBe(BigInt("21000000000000000"));
110
+ });
111
+
112
+ it("returns an empty page (with a resumable cursor) when the range is already exhausted", async () => {
113
+ jest.mocked(getLastBlockHeight).mockResolvedValueOnce(50);
114
+
115
+ const page = await listOperations(ADDRESS, { minHeight: 0, cursor: "100" });
116
+
117
+ expect(page.items).toEqual([]);
118
+ expect(getOperations).not.toHaveBeenCalled();
119
+ expect(page.next).toBe("100");
120
+ });
121
+
122
+ it("propagates an error from the underlying network call", async () => {
123
+ jest.mocked(getLastBlockHeight).mockResolvedValueOnce(200);
124
+ jest.mocked(getOperations).mockRejectedValueOnce(new Error("network down"));
125
+ jest.mocked(getTokenOperations).mockResolvedValueOnce([]);
126
+
127
+ await expect(listOperations(ADDRESS, { minHeight: 0 })).rejects.toThrow("network down");
128
+ });
129
+ });
@@ -0,0 +1,11 @@
1
+ export * from "./account/getBalance";
2
+ export * from "./history/getBlock";
3
+ export * from "./history/getBlockInfo";
4
+ export * from "./history/lastBlock";
5
+ export * from "./history/listOperations";
6
+ export * from "./transaction/broadcast";
7
+ export * from "./transaction/combine";
8
+ export * from "./transaction/craftTransaction";
9
+ export * from "./transaction/estimateFees";
10
+ export * from "./validateAddress";
11
+ export * from "./validateIntent";
@@ -0,0 +1,23 @@
1
+ import { http, HttpResponse } from "msw";
2
+ import { server, TEST_VECHAIN_ENDPOINT } from "../../test/msw.mock";
3
+ import { broadcast } from "./broadcast";
4
+
5
+ const SUBMIT_URL = `${TEST_VECHAIN_ENDPOINT}/transactions`;
6
+
7
+ beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
8
+ afterEach(() => server.resetHandlers());
9
+ afterAll(() => server.close());
10
+
11
+ describe("broadcast via MSW", () => {
12
+ it("submits the signed raw transaction and returns the transaction id", async () => {
13
+ server.use(http.post(SUBMIT_URL, () => HttpResponse.json({ id: "0xtxid" })));
14
+
15
+ expect(await broadcast("0xaabbcc")).toBe("0xtxid");
16
+ });
17
+
18
+ it("throws on an HTTP 200 whose body carries no transaction id (never swallows an error body)", async () => {
19
+ server.use(http.post(SUBMIT_URL, () => HttpResponse.json({})));
20
+
21
+ await expect(broadcast("0xaabbcc")).rejects.toThrow("Expected an ID to be returned");
22
+ });
23
+ });
@@ -0,0 +1,10 @@
1
+ import type { BroadcastConfig } from "@ledgerhq/coin-module-framework/api/types";
2
+ import { submit } from "../../network";
3
+ import type { VechainSDKTransaction } from "../../types";
4
+
5
+ // Submit a signed tx (hex from combine) via Thor POST /transactions.
6
+ export async function broadcast(tx: string, _broadcastConfig?: BroadcastConfig): Promise<string> {
7
+ const encoded = Uint8Array.from(Buffer.from(tx.replace(/^0x/, ""), "hex"));
8
+
9
+ return submit({ encoded } as VechainSDKTransaction);
10
+ }
@@ -0,0 +1,26 @@
1
+ import { submit } from "../../network";
2
+ import { broadcast } from "./broadcast";
3
+
4
+ jest.mock("../../network", () => ({ submit: jest.fn() }));
5
+
6
+ describe("broadcast", () => {
7
+ afterEach(() => {
8
+ jest.clearAllMocks();
9
+ });
10
+
11
+ it("decodes the hex raw transaction and delegates to submit", async () => {
12
+ jest.mocked(submit).mockResolvedValueOnce("0xtxid");
13
+
14
+ const id = await broadcast("0xaabbcc");
15
+
16
+ expect(id).toBe("0xtxid");
17
+ const [passed] = jest.mocked(submit).mock.calls[0];
18
+ expect(Array.from(passed.encoded as Uint8Array)).toEqual([0xaa, 0xbb, 0xcc]);
19
+ });
20
+
21
+ it("propagates an error from submit", async () => {
22
+ jest.mocked(submit).mockRejectedValueOnce(new Error("vechain: broadcast rejected"));
23
+
24
+ await expect(broadcast("0xaabbcc")).rejects.toThrow("vechain: broadcast rejected");
25
+ });
26
+ });
@@ -0,0 +1,10 @@
1
+ import type { VechainSDKTransactionBody } from "../../types";
2
+ import { VechainSDKTransaction } from "../../types";
3
+
4
+ // Attach the signature to the crafted body and return the signed, hex-encoded tx for broadcast.
5
+ export function combine(tx: string, signature: string): string {
6
+ const body = JSON.parse(tx) as VechainSDKTransactionBody;
7
+ const signed = VechainSDKTransaction.of(body, Buffer.from(signature, "hex"));
8
+
9
+ return `0x${Buffer.from(signed.encoded).toString("hex")}`;
10
+ }
@@ -0,0 +1,37 @@
1
+ import { combine } from "./combine";
2
+ import type { VechainSDKTransactionBody } from "../../types";
3
+
4
+ const BODY: VechainSDKTransactionBody = {
5
+ chainTag: 74,
6
+ blockRef: "0x00000000aabbccdd",
7
+ expiration: 18,
8
+ clauses: [{ to: "0x02961B92B8D20A4ea12f1f1CeFA74Dd7B4355A86", value: "0x1", data: "0x" }],
9
+ gas: 21000,
10
+ maxFeePerGas: 1000,
11
+ maxPriorityFeePerGas: 100,
12
+ dependsOn: null,
13
+ nonce: "0x1a2b3c4d5e6f7890",
14
+ };
15
+
16
+ describe("combine", () => {
17
+ it("returns a hex-encoded signed transaction", () => {
18
+ const signature = "aa".repeat(65);
19
+
20
+ const signed = combine(JSON.stringify(BODY), signature);
21
+
22
+ expect(signed).toMatch(/^0x[0-9a-f]+$/);
23
+ });
24
+
25
+ it("produces a different (longer) payload than the unsigned body JSON, reflecting the signature", () => {
26
+ const signature = "bb".repeat(65);
27
+
28
+ const signed = combine(JSON.stringify(BODY), signature);
29
+
30
+ expect(signed.length).toBeGreaterThan(0);
31
+ expect(signed).not.toBe(JSON.stringify(BODY));
32
+ });
33
+
34
+ it("throws on a malformed transaction payload", () => {
35
+ expect(() => combine("not-json", "aa".repeat(65))).toThrow();
36
+ });
37
+ });
@@ -0,0 +1,137 @@
1
+ import { BigNumber } from "bignumber.js";
2
+ import type {
3
+ CraftedTransaction,
4
+ FeeEstimation,
5
+ TransactionIntent,
6
+ } from "@ledgerhq/coin-module-framework/api/index";
7
+ import {
8
+ calculateClausesVet,
9
+ calculateClausesVtho,
10
+ generateNonce,
11
+ parseAddress,
12
+ } from "../../common-logic";
13
+ import { estimateGas } from "../../common-logic/estimateGas";
14
+ import { getThorClient } from "../../common-logic/getThorClient";
15
+ import { getAccount, getBlockRef } from "../../network";
16
+ import { MAINNET_CHAIN_TAG } from "../../types";
17
+ import type { VechainSDKTransactionBody, VechainSDKTransactionClause } from "../../types";
18
+
19
+ const EXPIRATION = 18;
20
+
21
+ type GasFeeParams = {
22
+ gas: number;
23
+ maxFeePerGas: number;
24
+ maxPriorityFeePerGas: number;
25
+ };
26
+
27
+ function isTokenAsset(intent: TransactionIntent): boolean {
28
+ return intent.asset.type !== "native";
29
+ }
30
+
31
+ async function resolveAmount(intent: TransactionIntent, isToken: boolean): Promise<BigNumber> {
32
+ if (!intent.useAllAmount) {
33
+ return new BigNumber(intent.amount.toString());
34
+ }
35
+
36
+ const { balance, energy } = await getAccount(intent.sender);
37
+ return new BigNumber((isToken ? energy : balance) || "0");
38
+ }
39
+
40
+ async function resolveGasFees(
41
+ clauses: VechainSDKTransactionClause[],
42
+ sender: string,
43
+ customFees?: FeeEstimation,
44
+ ): Promise<GasFeeParams> {
45
+ const custom = customFees?.parameters as Partial<GasFeeParams> | undefined;
46
+
47
+ // Fast path: a full override covers every field, so skip the network round trip entirely.
48
+ if (
49
+ custom?.gas !== undefined &&
50
+ custom.maxFeePerGas !== undefined &&
51
+ custom.maxPriorityFeePerGas !== undefined
52
+ ) {
53
+ return {
54
+ gas: Number(custom.gas),
55
+ maxFeePerGas: Number(custom.maxFeePerGas),
56
+ maxPriorityFeePerGas: Number(custom.maxPriorityFeePerGas),
57
+ };
58
+ }
59
+
60
+ const gasResult = await estimateGas(clauses, sender);
61
+ const thorClient = getThorClient();
62
+ const body = await thorClient.transactions.buildTransactionBody(clauses, gasResult.totalGas, {});
63
+
64
+ // Partial override: apply each provided field individually over the estimate, so e.g. a
65
+ // gas-only override is honoured instead of being discarded by an all-or-nothing check. Coerce
66
+ // every field to a JS number: the generic coin-framework hands fee parameters back as bigint,
67
+ // and a bigint here reaches `JSON.stringify(body)` below, which cannot serialize it.
68
+ return {
69
+ gas: Number(custom?.gas ?? gasResult.totalGas),
70
+ maxFeePerGas: Number(custom?.maxFeePerGas ?? body.maxFeePerGas ?? "0"),
71
+ maxPriorityFeePerGas: Number(custom?.maxPriorityFeePerGas ?? body.maxPriorityFeePerGas ?? "0"),
72
+ };
73
+ }
74
+
75
+ // Craft an unsigned single-clause Thor tx (VET transfer, or VIP-180 transfer for VTHO). The returned
76
+ // transaction is the JSON VechainSDKTransactionBody that combine() parses back — keep them in sync.
77
+ export async function craftTransaction(
78
+ intent: TransactionIntent,
79
+ customFees?: FeeEstimation,
80
+ ): Promise<CraftedTransaction> {
81
+ if (!intent.recipient) {
82
+ throw new Error("vechain: recipient is required");
83
+ }
84
+ // Validate sender up front: it is used for I/O (getAccount, estimateGas) below, so a malformed
85
+ // value should fail clearly here instead of after a wasted network round trip.
86
+ if (!parseAddress(intent.sender)) {
87
+ throw new Error("vechain: invalid sender address");
88
+ }
89
+
90
+ const isToken = isTokenAsset(intent);
91
+ let amount = await resolveAmount(intent, isToken);
92
+ if (amount.lte(0)) {
93
+ throw new Error("vechain: transaction amount must be positive");
94
+ }
95
+
96
+ let clauses = isToken
97
+ ? await calculateClausesVtho(intent.recipient, amount)
98
+ : await calculateClausesVet(intent.recipient, amount);
99
+
100
+ const [{ gas, maxFeePerGas, maxPriorityFeePerGas }, blockRef] = await Promise.all([
101
+ resolveGasFees(clauses, intent.sender, customFees),
102
+ getBlockRef(),
103
+ ]);
104
+
105
+ const fee = new BigNumber(maxFeePerGas).times(gas);
106
+
107
+ if (intent.useAllAmount && isToken) {
108
+ amount = amount.minus(fee);
109
+ if (amount.lte(0)) {
110
+ throw new Error("vechain: VTHO balance too low to cover the transaction gas fee");
111
+ }
112
+ clauses = await calculateClausesVtho(intent.recipient, amount);
113
+ }
114
+
115
+ const body: VechainSDKTransactionBody = {
116
+ chainTag: MAINNET_CHAIN_TAG,
117
+ blockRef,
118
+ expiration: EXPIRATION,
119
+ clauses,
120
+ gas,
121
+ maxFeePerGas,
122
+ maxPriorityFeePerGas,
123
+ dependsOn: null,
124
+ nonce: generateNonce(),
125
+ };
126
+
127
+ return {
128
+ transaction: JSON.stringify(body),
129
+ details: {
130
+ fee: fee.toFixed(0),
131
+ gas,
132
+ maxFeePerGas,
133
+ maxPriorityFeePerGas,
134
+ isTokenAccount: isToken,
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,217 @@
1
+ import { VTHO_ADDRESS } from "@vechain/sdk-core";
2
+ import type { TransactionIntent } from "@ledgerhq/coin-module-framework/api/index";
3
+ import { calculateClausesVet, calculateClausesVtho, parseAddress } from "../../common-logic";
4
+ import { estimateGas } from "../../common-logic/estimateGas";
5
+ import { getThorClient } from "../../common-logic/getThorClient";
6
+ import { setCoinConfig } from "../../config";
7
+ import { getAccount, getBlockRef } from "../../network";
8
+ import { craftTransaction } from "./craftTransaction";
9
+
10
+ jest.mock("../../common-logic", () => ({
11
+ calculateClausesVet: jest.fn(),
12
+ calculateClausesVtho: jest.fn(),
13
+ generateNonce: jest.fn(() => "0xnonce"),
14
+ parseAddress: jest.fn(() => true),
15
+ }));
16
+ jest.mock("../../common-logic/estimateGas", () => ({ estimateGas: jest.fn() }));
17
+ jest.mock("../../common-logic/getThorClient", () => ({ getThorClient: jest.fn() }));
18
+ jest.mock("../../network", () => ({ getAccount: jest.fn(), getBlockRef: jest.fn() }));
19
+
20
+ const SENDER = "0x0fe6688548f0C303932bB197B0A96034f1d74dba";
21
+ const RECIPIENT = "0x02961B92B8D20A4ea12f1f1CeFA74Dd7B4355A86";
22
+
23
+ const NATIVE_INTENT: TransactionIntent = {
24
+ intentType: "transaction",
25
+ type: "send",
26
+ sender: SENDER,
27
+ recipient: RECIPIENT,
28
+ amount: 1_000_000_000_000_000_000n,
29
+ asset: { type: "native" },
30
+ };
31
+
32
+ const TOKEN_INTENT: TransactionIntent = {
33
+ ...NATIVE_INTENT,
34
+ asset: { type: "token", assetReference: VTHO_ADDRESS },
35
+ };
36
+
37
+ function mockBuildTransactionBody() {
38
+ return {
39
+ transactions: {
40
+ buildTransactionBody: jest
41
+ .fn()
42
+ .mockResolvedValue({ maxFeePerGas: "1000", maxPriorityFeePerGas: "100" }),
43
+ },
44
+ };
45
+ }
46
+
47
+ describe("craftTransaction", () => {
48
+ beforeEach(() => {
49
+ jest
50
+ .mocked(calculateClausesVet)
51
+ .mockResolvedValue([{ to: RECIPIENT, value: "0x1", data: "0x" }]);
52
+ jest
53
+ .mocked(calculateClausesVtho)
54
+ .mockResolvedValue([{ to: VTHO_ADDRESS, value: 0, data: "0xdata" }]);
55
+ jest.mocked(estimateGas).mockResolvedValue({ totalGas: 21000 } as never);
56
+ jest.mocked(getThorClient).mockReturnValue(mockBuildTransactionBody() as never);
57
+ jest.mocked(getBlockRef).mockResolvedValue("0xblockref12345678");
58
+ setCoinConfig(() => ({ status: { type: "active" } }));
59
+ });
60
+
61
+ afterEach(() => {
62
+ jest.clearAllMocks();
63
+ });
64
+
65
+ it("crafts a native VET transaction using calculateClausesVet", async () => {
66
+ const crafted = await craftTransaction(NATIVE_INTENT);
67
+
68
+ expect(calculateClausesVet).toHaveBeenCalled();
69
+ expect(calculateClausesVtho).not.toHaveBeenCalled();
70
+ const body = JSON.parse(crafted.transaction);
71
+ expect(body.clauses).toEqual([{ to: RECIPIENT, value: "0x1", data: "0x" }]);
72
+ expect(body.gas).toBe(21000);
73
+ expect(body.nonce).toBe("0xnonce");
74
+ expect(crafted.details?.isTokenAccount).toBe(false);
75
+ });
76
+
77
+ it("crafts with the mainnet chainTag (74)", async () => {
78
+ const crafted = await craftTransaction(NATIVE_INTENT);
79
+
80
+ const body = JSON.parse(crafted.transaction);
81
+ expect(body.chainTag).toBe(74);
82
+ });
83
+
84
+ it("crafts a VTHO transaction using calculateClausesVtho", async () => {
85
+ const crafted = await craftTransaction(TOKEN_INTENT);
86
+
87
+ expect(calculateClausesVtho).toHaveBeenCalled();
88
+ expect(calculateClausesVet).not.toHaveBeenCalled();
89
+ expect(crafted.details?.isTokenAccount).toBe(true);
90
+ });
91
+
92
+ it("computes the fee as maxFeePerGas * gas", async () => {
93
+ const crafted = await craftTransaction(NATIVE_INTENT);
94
+
95
+ expect(crafted.details?.fee).toBe((1000 * 21000).toString());
96
+ });
97
+
98
+ it("uses the account balance for the sent asset when useAllAmount is set", async () => {
99
+ jest.mocked(getAccount).mockResolvedValueOnce({
100
+ balance: "0x1000",
101
+ energy: "0x2000",
102
+ hasCode: false,
103
+ });
104
+
105
+ await craftTransaction({ ...NATIVE_INTENT, useAllAmount: true, amount: 0n });
106
+
107
+ expect(getAccount).toHaveBeenCalledWith(SENDER);
108
+ expect(calculateClausesVet).toHaveBeenCalledWith(RECIPIENT, expect.any(Object));
109
+ });
110
+
111
+ it("uses custom fee parameters instead of re-estimating gas when provided", async () => {
112
+ await craftTransaction(NATIVE_INTENT, {
113
+ value: 5000n,
114
+ parameters: { gas: 30000, maxFeePerGas: 50, maxPriorityFeePerGas: 5 },
115
+ });
116
+
117
+ expect(estimateGas).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it("applies a partial fee override (gas only) over the estimate instead of discarding it", async () => {
121
+ const crafted = await craftTransaction(NATIVE_INTENT, {
122
+ value: 0n,
123
+ parameters: { gas: 30000 },
124
+ });
125
+
126
+ expect(estimateGas).toHaveBeenCalled();
127
+ const body = JSON.parse(crafted.transaction);
128
+ expect(body.gas).toBe(30000);
129
+ expect(body.maxFeePerGas).toBe(1000);
130
+ expect(body.maxPriorityFeePerGas).toBe(100);
131
+ });
132
+
133
+ it("coerces bigint fee parameters to numbers so the body stays JSON-serializable", async () => {
134
+ // The generic coin-framework hands fee parameters back as bigint; JSON.stringify cannot
135
+ // serialize a bigint, so craftTransaction must coerce them to numbers before building the body.
136
+ const crafted = await craftTransaction(NATIVE_INTENT, {
137
+ value: 5000n,
138
+ parameters: { gas: 30000n, maxFeePerGas: 50n, maxPriorityFeePerGas: 5n },
139
+ } as never);
140
+
141
+ const body = JSON.parse(crafted.transaction);
142
+ expect(body.gas).toBe(30000);
143
+ expect(body.maxFeePerGas).toBe(50);
144
+ expect(body.maxPriorityFeePerGas).toBe(5);
145
+ expect(typeof body.gas).toBe("number");
146
+ });
147
+
148
+ it("throws when the recipient is missing", async () => {
149
+ await expect(craftTransaction({ ...NATIVE_INTENT, recipient: "" })).rejects.toThrow(
150
+ "vechain: recipient is required",
151
+ );
152
+ });
153
+
154
+ it("throws when the sender is not a valid address", async () => {
155
+ jest.mocked(parseAddress).mockReturnValueOnce(false);
156
+
157
+ await expect(craftTransaction({ ...NATIVE_INTENT, sender: "0xbad" })).rejects.toThrow(
158
+ "vechain: invalid sender address",
159
+ );
160
+ });
161
+
162
+ it("throws when the resolved amount is not positive", async () => {
163
+ await expect(craftTransaction({ ...NATIVE_INTENT, amount: 0n })).rejects.toThrow(
164
+ "vechain: transaction amount must be positive",
165
+ );
166
+ });
167
+
168
+ it("reserves the VTHO gas fee for a max VTHO send (amount = energy - fee)", async () => {
169
+ jest
170
+ .mocked(getAccount)
171
+ .mockResolvedValueOnce({ balance: "0x0", energy: "0x5F5E100", hasCode: false });
172
+
173
+ await craftTransaction({ ...TOKEN_INTENT, useAllAmount: true, amount: 0n });
174
+
175
+ const calls = jest.mocked(calculateClausesVtho).mock.calls;
176
+ expect(calls).toHaveLength(2);
177
+ expect(calls[calls.length - 1][1].toString()).toBe("79000000");
178
+ });
179
+
180
+ it("throws when the VTHO balance cannot cover the max-send gas fee", async () => {
181
+ jest
182
+ .mocked(getAccount)
183
+ .mockResolvedValueOnce({ balance: "0x0", energy: "0x3E8", hasCode: false });
184
+
185
+ await expect(
186
+ craftTransaction({ ...TOKEN_INTENT, useAllAmount: true, amount: 0n }),
187
+ ).rejects.toThrow("vechain: VTHO balance too low to cover the transaction gas fee");
188
+ });
189
+
190
+ it("crafts a dust native amount (1 wei)", async () => {
191
+ const crafted = await craftTransaction({ ...NATIVE_INTENT, amount: 1n });
192
+
193
+ expect(calculateClausesVet).toHaveBeenCalledWith(RECIPIENT, expect.any(Object));
194
+ expect(JSON.parse(crafted.transaction).gas).toBe(21000);
195
+ });
196
+
197
+ it("reserves the fee at the max-VTHO boundary, leaving exactly 1 wei", async () => {
198
+ jest
199
+ .mocked(getAccount)
200
+ .mockResolvedValueOnce({ balance: "0x0", energy: "21000001", hasCode: false });
201
+
202
+ await craftTransaction({ ...TOKEN_INTENT, useAllAmount: true, amount: 0n });
203
+
204
+ const calls = jest.mocked(calculateClausesVtho).mock.calls;
205
+ expect(calls[calls.length - 1][1].toString()).toBe("1");
206
+ });
207
+
208
+ it("throws at the max-VTHO boundary where energy exactly equals the fee", async () => {
209
+ jest
210
+ .mocked(getAccount)
211
+ .mockResolvedValueOnce({ balance: "0x0", energy: "21000000", hasCode: false });
212
+
213
+ await expect(
214
+ craftTransaction({ ...TOKEN_INTENT, useAllAmount: true, amount: 0n }),
215
+ ).rejects.toThrow("vechain: VTHO balance too low to cover the transaction gas fee");
216
+ });
217
+ });
@@ -0,0 +1,32 @@
1
+ import type { FeeEstimation, TransactionIntent } from "@ledgerhq/coin-module-framework/api/index";
2
+ import { parseAddress } from "../../common-logic";
3
+ import { craftTransaction } from "./craftTransaction";
4
+
5
+ // Estimate VTHO gas by crafting the unsigned tx and reading back its settled fee/gas parameters.
6
+ export async function estimateFees(
7
+ intent: TransactionIntent,
8
+ customFeesParameters?: FeeEstimation["parameters"],
9
+ ): Promise<FeeEstimation> {
10
+ if (!intent.recipient || !parseAddress(intent.recipient)) {
11
+ return { value: 0n, parameters: {} };
12
+ }
13
+
14
+ const estimationIntent =
15
+ !intent.useAllAmount && intent.amount <= 0n ? { ...intent, amount: 1n } : intent;
16
+
17
+ const { details } = await craftTransaction(
18
+ estimationIntent,
19
+ customFeesParameters ? { value: 0n, parameters: customFeesParameters } : undefined,
20
+ );
21
+
22
+ const fee = typeof details?.fee === "string" ? details.fee : "0";
23
+
24
+ return {
25
+ value: BigInt(fee),
26
+ parameters: {
27
+ gas: details?.gas,
28
+ maxFeePerGas: details?.maxFeePerGas,
29
+ maxPriorityFeePerGas: details?.maxPriorityFeePerGas,
30
+ },
31
+ };
32
+ }