@ledgerhq/coin-xrp 7.5.1 → 7.6.0-nightly.1

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 (78) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/lib/api/index.d.ts.map +1 -1
  4. package/lib/api/index.integ.test.js +8 -0
  5. package/lib/api/index.integ.test.js.map +1 -1
  6. package/lib/api/index.js +5 -0
  7. package/lib/api/index.js.map +1 -1
  8. package/lib/api/index.test.js +10 -2
  9. package/lib/api/index.test.js.map +1 -1
  10. package/lib/logic/combine.d.ts.map +1 -1
  11. package/lib/logic/combine.js +16 -4
  12. package/lib/logic/combine.js.map +1 -1
  13. package/lib/logic/craftRawTransaction.d.ts +3 -0
  14. package/lib/logic/craftRawTransaction.d.ts.map +1 -0
  15. package/lib/logic/craftRawTransaction.js +70 -0
  16. package/lib/logic/craftRawTransaction.js.map +1 -0
  17. package/lib/logic/craftRawTransaction.test.d.ts +2 -0
  18. package/lib/logic/craftRawTransaction.test.d.ts.map +1 -0
  19. package/lib/logic/craftRawTransaction.test.js +304 -0
  20. package/lib/logic/craftRawTransaction.test.js.map +1 -0
  21. package/lib/logic/index.d.ts +1 -0
  22. package/lib/logic/index.d.ts.map +1 -1
  23. package/lib/logic/index.js +3 -1
  24. package/lib/logic/index.js.map +1 -1
  25. package/lib/logic/utils.d.ts +6 -0
  26. package/lib/logic/utils.d.ts.map +1 -1
  27. package/lib/logic/utils.js +18 -0
  28. package/lib/logic/utils.js.map +1 -1
  29. package/lib/logic/utils.test.js +62 -0
  30. package/lib/logic/utils.test.js.map +1 -1
  31. package/lib/logic/validateIntent.test.js +8 -0
  32. package/lib/logic/validateIntent.test.js.map +1 -1
  33. package/lib/types/model.d.ts +7 -0
  34. package/lib/types/model.d.ts.map +1 -1
  35. package/lib-es/api/index.d.ts.map +1 -1
  36. package/lib-es/api/index.integ.test.js +8 -0
  37. package/lib-es/api/index.integ.test.js.map +1 -1
  38. package/lib-es/api/index.js +6 -1
  39. package/lib-es/api/index.js.map +1 -1
  40. package/lib-es/api/index.test.js +10 -2
  41. package/lib-es/api/index.test.js.map +1 -1
  42. package/lib-es/logic/combine.d.ts.map +1 -1
  43. package/lib-es/logic/combine.js +16 -4
  44. package/lib-es/logic/combine.js.map +1 -1
  45. package/lib-es/logic/craftRawTransaction.d.ts +3 -0
  46. package/lib-es/logic/craftRawTransaction.d.ts.map +1 -0
  47. package/lib-es/logic/craftRawTransaction.js +67 -0
  48. package/lib-es/logic/craftRawTransaction.js.map +1 -0
  49. package/lib-es/logic/craftRawTransaction.test.d.ts +2 -0
  50. package/lib-es/logic/craftRawTransaction.test.d.ts.map +1 -0
  51. package/lib-es/logic/craftRawTransaction.test.js +302 -0
  52. package/lib-es/logic/craftRawTransaction.test.js.map +1 -0
  53. package/lib-es/logic/index.d.ts +1 -0
  54. package/lib-es/logic/index.d.ts.map +1 -1
  55. package/lib-es/logic/index.js +1 -0
  56. package/lib-es/logic/index.js.map +1 -1
  57. package/lib-es/logic/utils.d.ts +6 -0
  58. package/lib-es/logic/utils.d.ts.map +1 -1
  59. package/lib-es/logic/utils.js +18 -1
  60. package/lib-es/logic/utils.js.map +1 -1
  61. package/lib-es/logic/utils.test.js +63 -1
  62. package/lib-es/logic/utils.test.js.map +1 -1
  63. package/lib-es/logic/validateIntent.test.js +8 -0
  64. package/lib-es/logic/validateIntent.test.js.map +1 -1
  65. package/lib-es/types/model.d.ts +7 -0
  66. package/lib-es/types/model.d.ts.map +1 -1
  67. package/package.json +7 -7
  68. package/src/api/index.integ.test.ts +8 -0
  69. package/src/api/index.test.ts +21 -10
  70. package/src/api/index.ts +6 -0
  71. package/src/logic/combine.ts +26 -6
  72. package/src/logic/craftRawTransaction.test.ts +362 -0
  73. package/src/logic/craftRawTransaction.ts +91 -0
  74. package/src/logic/index.ts +1 -0
  75. package/src/logic/utils.test.ts +71 -1
  76. package/src/logic/utils.ts +20 -1
  77. package/src/logic/validateIntent.test.ts +8 -0
  78. package/src/types/model.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ledgerhq/coin-xrp",
3
- "version": "7.5.1",
3
+ "version": "7.6.0-nightly.1",
4
4
  "description": "Ledger XRP Coin integration",
5
5
  "keywords": [
6
6
  "Ledger",
@@ -103,12 +103,12 @@
103
103
  "invariant": "^2.2.4",
104
104
  "ripple-address-codec": "^5.0.0",
105
105
  "ripple-binary-codec": "^1.3.0",
106
- "@ledgerhq/coin-framework": "^6.6.0",
107
- "@ledgerhq/cryptoassets": "^13.30.0",
108
- "@ledgerhq/devices": "8.6.1",
106
+ "@ledgerhq/coin-framework": "^6.7.0-nightly.1",
107
+ "@ledgerhq/cryptoassets": "^13.31.0-nightly.1",
109
108
  "@ledgerhq/errors": "^6.26.0",
110
- "@ledgerhq/live-network": "^2.0.19",
111
- "@ledgerhq/types-live": "^6.86.0",
109
+ "@ledgerhq/devices": "8.6.1",
110
+ "@ledgerhq/live-network": "^2.0.20-nightly.0",
111
+ "@ledgerhq/types-live": "^6.87.0-nightly.1",
112
112
  "@ledgerhq/logs": "^6.13.0"
113
113
  },
114
114
  "devDependencies": {
@@ -122,7 +122,7 @@
122
122
  "ts-jest": "^29.1.1",
123
123
  "typescript": "^5.4.5",
124
124
  "@ledgerhq/disable-network-setup": "^0.0.0",
125
- "@ledgerhq/types-cryptoassets": "^7.28.0"
125
+ "@ledgerhq/types-cryptoassets": "^7.29.0-nightly.0"
126
126
  },
127
127
  "scripts": {
128
128
  "clean": "rimraf lib lib-es",
@@ -15,6 +15,7 @@ describe("Xrp Api (testnet)", () => {
15
15
 
16
16
  // When
17
17
  const result = await api.estimateFees({
18
+ intentType: "transaction",
18
19
  asset: { type: "native" },
19
20
  type: "send",
20
21
  sender: SENDER,
@@ -125,6 +126,7 @@ describe("Xrp Api (testnet)", () => {
125
126
  it("returns a raw transaction", async () => {
126
127
  // When
127
128
  const { transaction: result } = await api.craftTransaction({
129
+ intentType: "transaction",
128
130
  asset: { type: "native" },
129
131
  type: "send",
130
132
  sender: SENDER,
@@ -141,6 +143,7 @@ describe("Xrp Api (testnet)", () => {
141
143
 
142
144
  it("should use default fees when user does not provide them for crafting a transaction", async () => {
143
145
  const { transaction: result } = await api.craftTransaction({
146
+ intentType: "transaction",
144
147
  asset: { type: "native" },
145
148
  type: "send",
146
149
  sender: SENDER,
@@ -161,6 +164,7 @@ describe("Xrp Api (testnet)", () => {
161
164
  const customFees = 99n;
162
165
  const { transaction: result } = await api.craftTransaction(
163
166
  {
167
+ intentType: "transaction",
164
168
  asset: { type: "native" },
165
169
  type: "send",
166
170
  sender: SENDER,
@@ -192,6 +196,7 @@ describe("Xrp Api (mainnet)", () => {
192
196
 
193
197
  // When
194
198
  const result = await api.estimateFees({
199
+ intentType: "transaction",
195
200
  asset: { type: "native" },
196
201
  type: "send",
197
202
  sender: SENDER,
@@ -289,6 +294,7 @@ describe("Xrp Api (mainnet)", () => {
289
294
 
290
295
  it("returns a raw transaction", async () => {
291
296
  const { transaction: result } = await api.craftTransaction({
297
+ intentType: "transaction",
292
298
  asset: { type: "native" },
293
299
  type: "send",
294
300
  sender: SENDER,
@@ -304,6 +310,7 @@ describe("Xrp Api (mainnet)", () => {
304
310
 
305
311
  it("should use default fees when user does not provide them for crafting a transaction", async () => {
306
312
  const { transaction: result } = await api.craftTransaction({
313
+ intentType: "transaction",
307
314
  asset: { type: "native" },
308
315
  type: "send",
309
316
  sender: SENDER,
@@ -324,6 +331,7 @@ describe("Xrp Api (mainnet)", () => {
324
331
  const customFees = 99n;
325
332
  const { transaction: result } = await api.craftTransaction(
326
333
  {
334
+ intentType: "transaction",
327
335
  asset: { type: "native" },
328
336
  type: "send",
329
337
  sender: SENDER,
@@ -1,4 +1,4 @@
1
- import { Operation, TransactionIntent } from "@ledgerhq/coin-framework/api/types";
1
+ import { Operation, SendTransactionIntent } from "@ledgerhq/coin-framework/api/types";
2
2
  import * as LogicFunctions from "../logic";
3
3
  import { GetTransactionsOptions } from "../network";
4
4
  import { NetworkInfo, XrpMapMemo } from "../types";
@@ -280,9 +280,12 @@ describe("Testing craftTransaction function", () => {
280
280
 
281
281
  it("should use custom user fees when user provides it for crafting a transaction", async () => {
282
282
  const customFees = 99n;
283
- await api.craftTransaction({ sender: "foo" } as TransactionIntent<XrpMapMemo>, {
284
- value: customFees,
285
- });
283
+ await api.craftTransaction(
284
+ { intentType: "transaction", sender: "foo" } as SendTransactionIntent<XrpMapMemo>,
285
+ {
286
+ value: customFees,
287
+ },
288
+ );
286
289
 
287
290
  expect(logicCraftTransactionSpy).toHaveBeenCalledWith(
288
291
  expect.any(Object),
@@ -294,7 +297,10 @@ describe("Testing craftTransaction function", () => {
294
297
  });
295
298
 
296
299
  it("should use default fees when user does not provide them for crafting a transaction", async () => {
297
- await api.craftTransaction({ sender: "foo" } as TransactionIntent<XrpMapMemo>);
300
+ await api.craftTransaction({
301
+ intentType: "transaction",
302
+ sender: "foo",
303
+ } as SendTransactionIntent<XrpMapMemo>);
298
304
 
299
305
  expect(logicCraftTransactionSpy).toHaveBeenCalledWith(
300
306
  expect.any(Object),
@@ -307,9 +313,10 @@ describe("Testing craftTransaction function", () => {
307
313
 
308
314
  it("should pass signing pub key when user provides it for crafting a transaction", async () => {
309
315
  await api.craftTransaction({
316
+ intentType: "transaction",
310
317
  sender: "foo",
311
318
  senderPublicKey: "bar",
312
- } as TransactionIntent<XrpMapMemo>);
319
+ } as SendTransactionIntent<XrpMapMemo>);
313
320
 
314
321
  expect(logicCraftTransactionSpy).toHaveBeenCalledWith(
315
322
  expect.any(Object),
@@ -320,12 +327,13 @@ describe("Testing craftTransaction function", () => {
320
327
 
321
328
  it("should pass memos when user provides it for crafting a transaction", async () => {
322
329
  await api.craftTransaction({
330
+ intentType: "transaction",
323
331
  sender: "foo",
324
332
  memo: {
325
333
  type: "map",
326
334
  memos: new Map([["memos", ["testdata"]]]),
327
335
  },
328
- } as TransactionIntent<XrpMapMemo>);
336
+ } as SendTransactionIntent<XrpMapMemo>);
329
337
 
330
338
  expect(logicCraftTransactionSpy).toHaveBeenCalledWith(
331
339
  expect.any(Object),
@@ -340,8 +348,9 @@ describe("Testing craftTransaction function", () => {
340
348
 
341
349
  it("should not pass memos when user does not provide it for crafting a transaction", async () => {
342
350
  await api.craftTransaction({
351
+ intentType: "transaction",
343
352
  sender: "foo",
344
- } as TransactionIntent<XrpMapMemo>);
353
+ } as SendTransactionIntent<XrpMapMemo>);
345
354
 
346
355
  expect(logicCraftTransactionSpy).toHaveBeenCalledWith(
347
356
  expect.any(Object),
@@ -354,12 +363,13 @@ describe("Testing craftTransaction function", () => {
354
363
 
355
364
  it("should not pass memos when user provides an empty memo list it for crafting a transaction", async () => {
356
365
  await api.craftTransaction({
366
+ intentType: "transaction",
357
367
  sender: "foo",
358
368
  memo: {
359
369
  type: "map",
360
370
  memos: new Map(),
361
371
  },
362
- } as TransactionIntent<XrpMapMemo>);
372
+ } as SendTransactionIntent<XrpMapMemo>);
363
373
 
364
374
  expect(logicCraftTransactionSpy).toHaveBeenCalledWith(
365
375
  expect.any(Object),
@@ -372,12 +382,13 @@ describe("Testing craftTransaction function", () => {
372
382
 
373
383
  it("should pass destination tag when user provides it for crafting a transaction", async () => {
374
384
  await api.craftTransaction({
385
+ intentType: "transaction",
375
386
  sender: "foo",
376
387
  memo: {
377
388
  type: "map",
378
389
  memos: new Map([["destinationTag", "1337"]]),
379
390
  },
380
- } as TransactionIntent<XrpMapMemo>);
391
+ } as SendTransactionIntent<XrpMapMemo>);
381
392
 
382
393
  expect(logicCraftTransactionSpy).toHaveBeenCalledWith(
383
394
  expect.any(Object),
package/src/api/index.ts CHANGED
@@ -12,12 +12,14 @@ import {
12
12
  TransactionIntent,
13
13
  CraftedTransaction,
14
14
  } from "@ledgerhq/coin-framework/api/index";
15
+ import { isSendTransactionIntent } from "@ledgerhq/coin-framework/utils";
15
16
  import { log } from "@ledgerhq/logs";
16
17
  import coinConfig, { type XrpConfig } from "../config";
17
18
  import {
18
19
  broadcast,
19
20
  combine,
20
21
  craftTransaction,
22
+ craftRawTransaction,
21
23
  estimateFees,
22
24
  getBalance,
23
25
  getAccountInfo,
@@ -36,6 +38,7 @@ export function createApi(config: XrpConfig): Api<XrpMapMemo> {
36
38
  broadcast,
37
39
  combine,
38
40
  craftTransaction: craft,
41
+ craftRawTransaction,
39
42
  estimateFees: estimate,
40
43
  getBalance,
41
44
  lastBlock,
@@ -64,6 +67,9 @@ async function craft(
64
67
  transactionIntent: TransactionIntent<XrpMapMemo>,
65
68
  customFees?: FeeEstimation,
66
69
  ): Promise<CraftedTransaction> {
70
+ if (isSendTransactionIntent(transactionIntent) === false) {
71
+ throw new Error("Only transaction intentType is supported");
72
+ }
67
73
  const nextSequenceNumber = await getNextValidSequence(transactionIntent.sender);
68
74
  const estimatedFees = customFees?.value ?? (await estimateFees()).fees;
69
75
 
@@ -1,18 +1,38 @@
1
1
  import { decode, encode } from "ripple-binary-codec";
2
2
  import { JsonObject } from "ripple-binary-codec/dist/types/serialized-type";
3
+ import { SignerEntry } from "../types";
3
4
 
4
5
  type XRPTransaction = JsonObject & {
5
- TxnSignature: string;
6
+ Signers?: Array<SignerEntry>;
7
+ TxnSignature?: string;
6
8
  SigningPubKey?: string;
7
9
  };
8
10
 
9
11
  export function combine(transaction: string, signature: string, publicKey?: string): string {
10
- const xrplTransaction: JsonObject = decode(transaction);
11
- let transactionWithSignature: XRPTransaction = { ...xrplTransaction, TxnSignature: signature };
12
+ const xrplTransaction: XRPTransaction = decode(transaction);
12
13
 
13
- if (publicKey) {
14
- transactionWithSignature = { ...transactionWithSignature, SigningPubKey: publicKey };
14
+ // Multi sign transactions have an empty SigningPubKey
15
+ // https://xrpl.org/docs/concepts/accounts/multi-signing#sending-multi-signed-transactions
16
+ // https://xrpl.org/docs/tutorials/how-tos/manage-account-settings/send-a-multi-signed-transaction
17
+ if (xrplTransaction.SigningPubKey === "") {
18
+ // Find the signer that still needs a signature. If a publicKey is provided, match it;
19
+ // otherwise take the first signer without a TxnSignature.
20
+ const signerEntry = xrplTransaction.Signers?.find(
21
+ s => s.Signer.TxnSignature === "" && (!publicKey || s.Signer.SigningPubKey === publicKey),
22
+ );
23
+
24
+ if (signerEntry) {
25
+ signerEntry.Signer.TxnSignature = signature;
26
+ }
27
+
28
+ return encode(xrplTransaction);
29
+ }
30
+
31
+ xrplTransaction.TxnSignature = signature;
32
+
33
+ if (publicKey && !xrplTransaction.SigningPubKey) {
34
+ xrplTransaction.SigningPubKey = publicKey;
15
35
  }
16
36
 
17
- return encode(transactionWithSignature);
37
+ return encode(xrplTransaction);
18
38
  }
@@ -0,0 +1,362 @@
1
+ import { craftRawTransaction } from "./craftRawTransaction";
2
+ import { encode, decode } from "ripple-binary-codec";
3
+ import { SignerEntry } from "../types";
4
+
5
+ // --- Mocks ---
6
+ const mockEstimateFees = jest.fn();
7
+ const mockGetLedgerIndex = jest.fn();
8
+
9
+ jest.mock("./estimateFees", () => ({
10
+ estimateFees: () => mockEstimateFees(),
11
+ }));
12
+
13
+ jest.mock("../network", () => ({
14
+ getLedgerIndex: () => mockGetLedgerIndex(),
15
+ }));
16
+
17
+ // Mock ripple-address-codec globally so ordering logic in sortSignersByNumericAddress uses deterministic data.
18
+ const decodeAccountIDMock = jest.fn((account: string) => {
19
+ const bytes = new Uint8Array(20).fill(0);
20
+ const ordering: Record<string, number> = { alpha: 1, beta: 2, gamma: 3, delta: 4 };
21
+ if (ordering[account]) bytes[19] = ordering[account];
22
+ return bytes;
23
+ });
24
+ jest.mock("ripple-address-codec", () => ({
25
+ decodeAccountID: (addr: string) => decodeAccountIDMock(addr),
26
+ isValidClassicAddress: () => true,
27
+ }));
28
+
29
+ describe("craftRawTransaction", () => {
30
+ const sender = "rPDf6SQStnNmw1knCu1ei7h6BcDAEUUqn5";
31
+ const destination = "rJe1St1G6BWMFmdrrcT7NdD3XT1NxTMEWN";
32
+ // XRPL Ed25519 public keys must be 33 bytes: 0xED prefix + 32 bytes payload (66 hex chars total)
33
+ // Using a deterministic dummy key so ripple-binary-codec encode/decode roundtrips without alteration.
34
+ const publicKey = "ED0123456789ABCDEFFEDCBA98765432100123456789ABCDEFFEDCBA9876543211"; // 66 hex chars
35
+ // Pre-set key used in the test that ensures existing SigningPubKey is preserved.
36
+ const alreadySetPublicKey = "EDFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"; // 66 hex chars
37
+
38
+ beforeEach(() => {
39
+ mockEstimateFees.mockReset();
40
+ mockGetLedgerIndex.mockReset();
41
+ });
42
+
43
+ // Helper to safely extract Signers array without relying on type assertions elsewhere
44
+ function getSigners(obj: unknown): SignerEntry[] | undefined {
45
+ if (!obj || typeof obj !== "object") return undefined;
46
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
47
+ const potential = (obj as { Signers?: unknown }).Signers;
48
+ if (Array.isArray(potential)) {
49
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
50
+ return potential as SignerEntry[];
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ it("fills missing Fee, Sequence, LastLedgerSequence & SigningPubKey for a standard payment", async () => {
56
+ // Given: base transaction missing common autofill fields
57
+ const baseTx = {
58
+ TransactionType: "Payment",
59
+ Account: sender,
60
+ Amount: "1000",
61
+ Destination: destination,
62
+ } as const;
63
+ const serialized = encode(baseTx);
64
+
65
+ mockEstimateFees.mockResolvedValue({ fees: BigInt(123) });
66
+ mockGetLedgerIndex.mockResolvedValue(1000);
67
+
68
+ // When
69
+ const result = await craftRawTransaction(serialized, sender, publicKey, 10);
70
+
71
+ // Then
72
+ const decoded = decode(result.transaction);
73
+ expect(decoded).toMatchObject({
74
+ TransactionType: "Payment",
75
+ Account: sender,
76
+ Amount: "1000",
77
+ Destination: destination,
78
+ Fee: "123",
79
+ Sequence: 10,
80
+ LastLedgerSequence: 1020, // 1000 + 20 offset
81
+ SigningPubKey: publicKey,
82
+ });
83
+ expect(mockEstimateFees).toHaveBeenCalledTimes(1);
84
+ expect(mockGetLedgerIndex).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ it("autofills only missing Fee & LastLedgerSequence when Sequence provided", async () => {
88
+ // Given: Sequence present; Fee, LastLedgerSequence & SigningPubKey missing
89
+ const baseTx = {
90
+ TransactionType: "Payment",
91
+ Account: sender,
92
+ Amount: "1234",
93
+ Destination: destination,
94
+ Sequence: 77,
95
+ } as const;
96
+ const serialized = encode(baseTx);
97
+
98
+ mockEstimateFees.mockResolvedValue({ fees: BigInt(456) });
99
+ mockGetLedgerIndex.mockResolvedValue(2000);
100
+
101
+ // When: pass a different sequence param to ensure existing Sequence is not overwritten
102
+ const result = await craftRawTransaction(serialized, sender, publicKey, 999);
103
+
104
+ // Then
105
+ const decoded = decode(result.transaction);
106
+ expect(decoded.Sequence).toBe(77); // preserved
107
+ expect(decoded.Fee).toBe("456"); // autofilled
108
+ expect(decoded.LastLedgerSequence).toBe(2020); // 2000 + 20
109
+ expect(decoded.SigningPubKey).toBe(publicKey); // autofilled
110
+ expect(mockEstimateFees).toHaveBeenCalledTimes(1);
111
+ expect(mockGetLedgerIndex).toHaveBeenCalledTimes(1);
112
+ });
113
+
114
+ it("does not overwrite provided Fee / Sequence / LastLedgerSequence / SigningPubKey", async () => {
115
+ // Given
116
+ const baseTx = {
117
+ TransactionType: "Payment",
118
+ Account: sender,
119
+ Amount: "2500",
120
+ Destination: destination,
121
+ Fee: "999",
122
+ Sequence: 42,
123
+ LastLedgerSequence: 5000,
124
+ SigningPubKey: alreadySetPublicKey,
125
+ } as const;
126
+ const serialized = encode(baseTx);
127
+
128
+ // Mocks would be used if fields missing; ensure they are ignored
129
+ mockEstimateFees.mockResolvedValue({ fees: BigInt(1) });
130
+ mockGetLedgerIndex.mockResolvedValue(1);
131
+
132
+ // When
133
+ const result = await craftRawTransaction(serialized, sender, publicKey, 999);
134
+
135
+ // Then
136
+ const decoded = decode(result.transaction);
137
+ expect(decoded.Fee).toBe("999");
138
+ expect(decoded.Sequence).toBe(42);
139
+ expect(decoded.LastLedgerSequence).toBe(5000);
140
+ expect(decoded.SigningPubKey).toBe(alreadySetPublicKey);
141
+ expect(mockEstimateFees).not.toHaveBeenCalled();
142
+ expect(mockGetLedgerIndex).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it("sets Sequence to 0 when TicketSequence is provided and Sequence missing", async () => {
146
+ // Given
147
+ const baseTx = {
148
+ TransactionType: "Payment",
149
+ Account: sender,
150
+ Amount: "1",
151
+ Destination: destination,
152
+ TicketSequence: 555,
153
+ Fee: "10",
154
+ // Sequence intentionally omitted
155
+ } as const;
156
+ const serialized = encode(baseTx);
157
+
158
+ mockGetLedgerIndex.mockResolvedValue(10);
159
+
160
+ // When
161
+ const result = await craftRawTransaction(serialized, sender, publicKey, 77);
162
+
163
+ // Then
164
+ const decoded = decode(result.transaction);
165
+ expect(decoded.TicketSequence).toBe(555);
166
+ expect(decoded.Sequence).toBe(0);
167
+ expect(decoded.LastLedgerSequence).toBe(30); // 10 + 20
168
+ });
169
+
170
+ it("forces Sequence to 0 when TicketSequence present even if non-zero Sequence provided", async () => {
171
+ // Given: TicketSequence provided AND a non-zero Sequence already set (we enforce spec by overriding)
172
+ const baseTx = {
173
+ TransactionType: "Payment",
174
+ Account: sender,
175
+ Amount: "123",
176
+ Destination: destination,
177
+ TicketSequence: 777,
178
+ Sequence: 55, // non-zero pre-set
179
+ Fee: "10",
180
+ } as const;
181
+ const serialized = encode(baseTx);
182
+
183
+ mockGetLedgerIndex.mockResolvedValue(111);
184
+
185
+ // When
186
+ const result = await craftRawTransaction(serialized, sender, publicKey, 9999);
187
+
188
+ // Then
189
+ const decoded = decode(result.transaction);
190
+ expect(decoded.Sequence).toBe(0); // overridden to comply with spec
191
+ expect(decoded.TicketSequence).toBe(777);
192
+ expect(decoded.LastLedgerSequence).toBe(131); // 111 + 20
193
+ });
194
+
195
+ it("throws when sender does not match Account for standard transaction", async () => {
196
+ // Given
197
+ const baseTx = {
198
+ TransactionType: "Payment",
199
+ Account: sender,
200
+ Amount: "10",
201
+ Destination: destination,
202
+ } as const;
203
+ const serialized = encode(baseTx);
204
+
205
+ // When & Then
206
+ await expect(craftRawTransaction(serialized, "rOTHERADDRESS", publicKey, 1)).rejects.toThrow(
207
+ "Sender address does not match the transaction account",
208
+ );
209
+ });
210
+
211
+ describe("multi-sign", () => {
212
+ it("adds a Signers array when absent (single existing signer)", async () => {
213
+ // Given: multi-sign transaction (SigningPubKey empty) with required Fee & Sequence
214
+ const multi = {
215
+ TransactionType: "Payment",
216
+ Account: sender, // Account can differ from signer in multi-sign, we keep same for simplicity
217
+ Amount: "100",
218
+ Destination: destination,
219
+ SigningPubKey: "", // signals multi-sign
220
+ Fee: "500",
221
+ Sequence: 9,
222
+ } as const;
223
+ const serialized = encode(multi);
224
+
225
+ // When
226
+ const result = await craftRawTransaction(serialized, sender, publicKey, 9);
227
+
228
+ // Then
229
+ const decoded = decode(result.transaction);
230
+ const signers = getSigners(decoded);
231
+ expect(signers?.length).toBe(1);
232
+ if (signers && signers[0]) {
233
+ expect(signers[0]).toMatchObject({
234
+ Signer: { Account: sender, SigningPubKey: publicKey, TxnSignature: "" },
235
+ });
236
+ }
237
+ // No autofill of LastLedgerSequence in multi-sign path (not required for this assertion)
238
+ });
239
+
240
+ it("does not add LastLedgerSequence for multi-sign when absent", async () => {
241
+ // Given: multi-sign tx w/o LastLedgerSequence
242
+ const multi = {
243
+ TransactionType: "Payment",
244
+ Account: sender,
245
+ Amount: "50",
246
+ Destination: destination,
247
+ SigningPubKey: "", // multi-sign
248
+ Fee: "12",
249
+ Sequence: 2,
250
+ } as const;
251
+ const serialized = encode(multi);
252
+ mockGetLedgerIndex.mockResolvedValue(500); // should NOT be used
253
+
254
+ // When
255
+ const result = await craftRawTransaction(serialized, sender, publicKey, 2);
256
+
257
+ // Then
258
+ const decoded = decode(result.transaction);
259
+ expect(decoded.LastLedgerSequence).toBeUndefined();
260
+ });
261
+
262
+ it("appends signer and sorts existing Signers numerically", async () => {
263
+ // Given existing unsorted signers
264
+ const existing: SignerEntry[] = [
265
+ {
266
+ Signer: {
267
+ Account: "rPDf6SQStnNmw1knCu1ei7h6BcDAEUUqn5",
268
+ SigningPubKey: "K3",
269
+ TxnSignature: "",
270
+ },
271
+ },
272
+ {
273
+ Signer: {
274
+ Account: "rJe1St1G6BWMFmdrrcT7NdD3XT1NxTMEWN",
275
+ SigningPubKey: "K1",
276
+ TxnSignature: "",
277
+ },
278
+ },
279
+ {
280
+ Signer: {
281
+ Account: "rDKsbvy9uaNpPtvVFraJyNGfjvTw8xivgK",
282
+ SigningPubKey: "K4",
283
+ TxnSignature: "",
284
+ },
285
+ },
286
+ ];
287
+ const multi: {
288
+ TransactionType: string;
289
+ Account: string;
290
+ Amount: string;
291
+ Destination: string;
292
+ SigningPubKey: string;
293
+ Fee: string;
294
+ Sequence: number;
295
+ Signers: SignerEntry[];
296
+ } = {
297
+ TransactionType: "Payment",
298
+ Account: sender,
299
+ Amount: "200",
300
+ Destination: destination,
301
+ SigningPubKey: "",
302
+ Fee: "700",
303
+ Sequence: 3,
304
+ Signers: existing,
305
+ };
306
+ const serialized = encode(multi);
307
+
308
+ // When
309
+ const result = await craftRawTransaction(
310
+ serialized,
311
+ "r94uo44ukDHWVjYDJLZJYwDdZjo1F2QYgq",
312
+ publicKey,
313
+ 3,
314
+ );
315
+
316
+ // Then
317
+ const decoded = decode(result.transaction);
318
+ const signers = getSigners(decoded) ?? [];
319
+ const accounts = signers.map(s => s.Signer.Account);
320
+ expect(accounts).toEqual([
321
+ "rPDf6SQStnNmw1knCu1ei7h6BcDAEUUqn5",
322
+ "rJe1St1G6BWMFmdrrcT7NdD3XT1NxTMEWN",
323
+ "rDKsbvy9uaNpPtvVFraJyNGfjvTw8xivgK",
324
+ "r94uo44ukDHWVjYDJLZJYwDdZjo1F2QYgq",
325
+ ]);
326
+ const betaEntry = signers.find(
327
+ s => s.Signer.Account === "r94uo44ukDHWVjYDJLZJYwDdZjo1F2QYgq",
328
+ );
329
+ expect(betaEntry?.Signer.SigningPubKey).toBe(publicKey);
330
+ });
331
+
332
+ it("throws when Fee missing for multi-sign", async () => {
333
+ const multi = {
334
+ TransactionType: "Payment",
335
+ Account: sender,
336
+ Amount: "10",
337
+ Destination: destination,
338
+ SigningPubKey: "",
339
+ Sequence: 1,
340
+ } as const;
341
+ const serialized = encode(multi);
342
+ await expect(craftRawTransaction(serialized, sender, publicKey, 1)).rejects.toThrow(
343
+ "Fee is required for multi sign transactions",
344
+ );
345
+ });
346
+
347
+ it("throws when Sequence missing for multi-sign", async () => {
348
+ const multi = {
349
+ TransactionType: "Payment",
350
+ Account: sender,
351
+ Amount: "10",
352
+ Destination: destination,
353
+ SigningPubKey: "",
354
+ Fee: "1",
355
+ } as const;
356
+ const serialized = encode(multi);
357
+ await expect(craftRawTransaction(serialized, sender, publicKey, 1)).rejects.toThrow(
358
+ "Sequence is required for multi sign transactions",
359
+ );
360
+ });
361
+ });
362
+ });