@ledgerhq/coin-xrp 7.3.0 → 7.3.1-nightly.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.
@@ -1,9 +1,10 @@
1
1
  import { decode } from "ripple-binary-codec";
2
2
  import { createApi } from ".";
3
+ import { Operation } from "@ledgerhq/coin-framework/api/types";
3
4
  //import { decode, encodeForSigning } from "ripple-binary-codec";
4
5
  //import { sign } from "ripple-keypairs";
5
6
 
6
- describe("Xrp Api", () => {
7
+ describe("Xrp Api (testnet)", () => {
7
8
  const SENDER = "rh1HPuRVsYYvThxG2Bs1MfjmrVC73S16Fb";
8
9
  const api = createApi({ node: "https://s.altnet.rippletest.net:51234" });
9
10
 
@@ -33,7 +34,7 @@ describe("Xrp Api", () => {
33
34
  describe("listOperations", () => {
34
35
  it.skip("returns a list regarding address parameter", async () => {
35
36
  // When
36
- const [tx, _] = await api.listOperations(SENDER, { minHeight: 200 });
37
+ const [tx, _] = await api.listOperations(SENDER, { minHeight: 200, order: "asc" });
37
38
 
38
39
  // https://blockexplorer.one/xrp/testnet/address/rh1HPuRVsYYvThxG2Bs1MfjmrVC73S16Fb
39
40
  // as of 2025-03-18, the address has 287 transactions
@@ -51,7 +52,10 @@ describe("Xrp Api", () => {
51
52
  const SENDER_WITH_TRANSACTIONS = "rUxSkt6hQpWxXQwTNRUCYYRQ7BC2yRA3F8";
52
53
 
53
54
  // When
54
- const [ops, _] = await api.listOperations(SENDER_WITH_TRANSACTIONS, { minHeight: 0 });
55
+ const [ops, _] = await api.listOperations(SENDER_WITH_TRANSACTIONS, {
56
+ minHeight: 0,
57
+ order: "asc",
58
+ });
55
59
  // Then
56
60
  const checkSet = new Set(ops.map(elt => elt.tx.hash));
57
61
  expect(checkSet.size).toEqual(ops.length);
@@ -64,6 +68,21 @@ describe("Xrp Api", () => {
64
68
  // so here we are checking that this limit is bypassed
65
69
  expect(ops.length).toBeGreaterThan(200);
66
70
  });
71
+
72
+ it("returns operations from latest, but in asc order", async () => {
73
+ // When
74
+ const SENDER_WITH_TRANSACTIONS = "rUxSkt6hQpWxXQwTNRUCYYRQ7BC2yRA3F8";
75
+ const [txDesc] = await api.listOperations(SENDER_WITH_TRANSACTIONS, {
76
+ minHeight: 200,
77
+ order: "desc",
78
+ });
79
+ expect(txDesc.length).toBeGreaterThanOrEqual(200);
80
+ // Then
81
+ // Check if the result is sorted in ascending order
82
+ expect(txDesc[0].tx.block.height).toBeGreaterThanOrEqual(
83
+ txDesc[txDesc.length - 1].tx.block.height,
84
+ );
85
+ });
67
86
  });
68
87
 
69
88
  describe("lastBlock", () => {
@@ -82,13 +101,13 @@ describe("Xrp Api", () => {
82
101
  // Account with no transaction (at the time of this writing)
83
102
  const SENDER_WITH_NO_TRANSACTION = "rKtXXTVno77jhu6tto1MAXjepyuaKaLcqB";
84
103
 
85
- it("returns an amount above 0 when address has transactions", async () => {
104
+ it("returns a balance", async () => {
86
105
  // When
87
106
  const result = await api.getBalance(SENDER);
88
107
 
89
108
  // Then
90
109
  expect(result[0].asset).toEqual({ type: "native" });
91
- expect(result[0].value).toBeGreaterThan(BigInt(0));
110
+ expect(result[0].value).toBeGreaterThanOrEqual(BigInt(0));
92
111
  });
93
112
 
94
113
  it("returns 0 when address has no transaction", async () => {
@@ -162,6 +181,169 @@ describe("Xrp Api", () => {
162
181
  });
163
182
  });
164
183
 
184
+ describe("Xrp Api (mainnet)", () => {
185
+ const SENDER = "rn5BQvhksnPfbo277LtFks4iyYStPKGrnJ";
186
+ const api = createApi({ node: "https://xrp.coin.ledger.com" });
187
+
188
+ describe("estimateFees", () => {
189
+ it("returns a default value", async () => {
190
+ // Given
191
+ const amount = BigInt(100);
192
+
193
+ // When
194
+ const result = await api.estimateFees({
195
+ asset: { type: "native" },
196
+ type: "send",
197
+ sender: SENDER,
198
+ amount,
199
+ recipient: "r9m6MwViR4GnUNqoGXGa8eroBrZ9FAPHFS",
200
+ memo: {
201
+ type: "map",
202
+ memos: new Map(),
203
+ },
204
+ });
205
+
206
+ // Then
207
+ expect(result.value).toEqual(BigInt(10));
208
+ });
209
+ });
210
+
211
+ describe("listOperations", () => {
212
+ let ops: Operation[];
213
+
214
+ beforeAll(async () => {
215
+ const resp = await api.listOperations(SENDER, { minHeight: 0 });
216
+ ops = resp[0];
217
+ });
218
+
219
+ it("returns operations", async () => {
220
+ // https://xrpscan.com/account/rn5BQvhksnPfbo277LtFks4iyYStPKGrnJ
221
+ expect(ops.length).toBeGreaterThanOrEqual(200);
222
+ const checkSet = new Set(ops.map(elt => elt.tx.hash));
223
+ expect(checkSet.size).toEqual(ops.length);
224
+ ops.forEach(operation => {
225
+ const isSenderOrReceipt =
226
+ operation.senders.includes(SENDER) || operation.recipients.includes(SENDER);
227
+ expect(isSenderOrReceipt).toBeTruthy();
228
+ });
229
+ });
230
+
231
+ it("returns IN operation", async () => {
232
+ // https://xrpscan.com/tx/805E371FDA0223E8910F831802EE93DBA1A4CA40AC8C1337F26F566CD67788F5
233
+ const inTx = {
234
+ hash: "9E5141DCAE8158E51ED612333FF3EC2D60A3D2DCD2F6DD5E4F92E4A6704C3CE9",
235
+ amount: 7.5,
236
+ recipient: SENDER,
237
+ sender: "rnXQmrXk9HSKdNwkut1k9dpAMVYuBsJV49",
238
+ type: "IN",
239
+ fees: 0.00001,
240
+ };
241
+ const op = ops.find(o => o.tx.hash === inTx.hash) as Operation;
242
+ expect(op.tx.hash).toEqual(inTx.hash);
243
+ expect(op.value).toEqual(BigInt(inTx.amount * 1e6));
244
+ expect(op.recipients).toContain(inTx.recipient);
245
+ expect(op.senders).toContain(inTx.sender);
246
+ expect(op.type).toEqual(inTx.type);
247
+ expect(op.tx.fees).toEqual(BigInt(inTx.fees * 1e6));
248
+ });
249
+
250
+ it("returns OUT operation", async () => {
251
+ // https://xrpscan.com/tx/8D13FD7EE0D28B615905903D033A3DC3839FBAA2F545417E3DE51A1A745C1688
252
+ const outTx = {
253
+ hash: "8D13FD7EE0D28B615905903D033A3DC3839FBAA2F545417E3DE51A1A745C1688",
254
+ amount: 0.1,
255
+ recipient: "r3XzsqzQCC6r4ZzifWnwa32sFR2H9exkew",
256
+ sender: SENDER,
257
+ type: "OUT",
258
+ fees: 0.00001,
259
+ };
260
+ const op = ops.find(o => o.tx.hash === outTx.hash) as Operation;
261
+ expect(op.tx.hash).toEqual(outTx.hash);
262
+ expect(op.value).toEqual(BigInt(outTx.amount * 1e6));
263
+ expect(op.recipients).toContain(outTx.recipient);
264
+ expect(op.senders).toContain(outTx.sender);
265
+ expect(op.type).toEqual(outTx.type);
266
+ expect(op.tx.fees).toEqual(BigInt(outTx.fees * 1e6));
267
+ });
268
+ });
269
+
270
+ describe("lastBlock", () => {
271
+ it("returns last block info", async () => {
272
+ const result = await api.lastBlock();
273
+ expect(result.hash).toBeDefined();
274
+ expect(result.height).toBeDefined();
275
+ expect(result.time).toBeInstanceOf(Date);
276
+ });
277
+ });
278
+
279
+ describe("getBalance", () => {
280
+ it("returns an amount", async () => {
281
+ const result = await api.getBalance(SENDER);
282
+ expect(result[0].asset).toEqual({ type: "native" });
283
+ expect(result[0].value).toBeGreaterThanOrEqual(BigInt(0));
284
+ });
285
+ });
286
+
287
+ describe("craftTransaction", () => {
288
+ const RECIPIENT = "r9m6MwViR4GnUNqoGXGa8eroBrZ9FAPHFS";
289
+
290
+ it("returns a raw transaction", async () => {
291
+ const result = await api.craftTransaction({
292
+ asset: { type: "native" },
293
+ type: "send",
294
+ sender: SENDER,
295
+ recipient: RECIPIENT,
296
+ amount: BigInt(10),
297
+ memo: {
298
+ type: "map",
299
+ memos: new Map([["memos", ["testdata"]]]),
300
+ },
301
+ });
302
+ expect(result.length).toEqual(178);
303
+ });
304
+
305
+ it("should use default fees when user does not provide them for crafting a transaction", async () => {
306
+ const result = await api.craftTransaction({
307
+ asset: { type: "native" },
308
+ type: "send",
309
+ sender: SENDER,
310
+ recipient: RECIPIENT,
311
+ amount: BigInt(10),
312
+ memo: {
313
+ type: "map",
314
+ memos: new Map(),
315
+ },
316
+ });
317
+
318
+ expect(decode(result)).toMatchObject({
319
+ Fee: "10",
320
+ });
321
+ });
322
+
323
+ it("should use custom user fees when user provides it for crafting a transaction", async () => {
324
+ const customFees = 99n;
325
+ const result = await api.craftTransaction(
326
+ {
327
+ asset: { type: "native" },
328
+ type: "send",
329
+ sender: SENDER,
330
+ recipient: RECIPIENT,
331
+ amount: BigInt(10),
332
+ memo: {
333
+ type: "map",
334
+ memos: new Map(),
335
+ },
336
+ },
337
+ { value: customFees },
338
+ );
339
+
340
+ expect(decode(result)).toMatchObject({
341
+ Fee: customFees.toString(),
342
+ });
343
+ });
344
+ });
345
+ });
346
+
165
347
  // To enable this test, you need to fill an `.env` file at the root of this package. Example can be found in `.env.integ.test.example`.
166
348
  // The value hardcoded here depends on the value filled in the `.env` file.
167
349
  /*describe.skip("combine", () => {
@@ -106,14 +106,13 @@ describe("listOperations", () => {
106
106
  const txs = givenTxs(BigInt(10), BigInt(10), "src", "dest");
107
107
  // each time it's called it returns a marker, so in theory it would loop forever
108
108
  mockGetTransactions.mockResolvedValue(mockNetworkTxs(txs, defaultMarker));
109
- const [results, _] = await api.listOperations("src", { minHeight: 0 });
109
+ const [results, _] = await api.listOperations("src", { minHeight: 0, order: "asc" });
110
110
 
111
- // called 10 times because there is a hard limit of 10 iterations in case something goes wrong
112
- // with interpretation of the token (bug / explorer api changed ...)
113
- expect(mockGetServerInfos).toHaveBeenCalledTimes(10);
114
- expect(mockGetTransactions).toHaveBeenCalledTimes(10);
111
+ // called 1 times because the client is expected to do the pagination itself
112
+ expect(mockGetServerInfos).toHaveBeenCalledTimes(1);
113
+ expect(mockGetTransactions).toHaveBeenCalledTimes(1);
115
114
 
116
- expect(results.length).toBe(txs.length * 10);
115
+ expect(results.length).toBe(txs.length);
117
116
  });
118
117
 
119
118
  it("should pass the token returned by previous calls", async () => {
@@ -122,11 +121,10 @@ describe("listOperations", () => {
122
121
  .mockReturnValueOnce(mockNetworkTxs(txs, defaultMarker))
123
122
  .mockReturnValueOnce(mockNetworkTxs(txs, undefined));
124
123
 
125
- const [results, _] = await api.listOperations("src", { minHeight: 0 });
124
+ const [results, token] = await api.listOperations("src", { minHeight: 0, order: "asc" });
126
125
 
127
- // called 2 times because the second time there is no marker
128
- expect(mockGetServerInfos).toHaveBeenCalledTimes(2);
129
- expect(mockGetTransactions).toHaveBeenCalledTimes(2);
126
+ expect(mockGetServerInfos).toHaveBeenCalledTimes(1);
127
+ expect(mockGetTransactions).toHaveBeenCalledTimes(1);
130
128
 
131
129
  // check tokens are passed
132
130
  const baseOptions = {
@@ -135,13 +133,14 @@ describe("listOperations", () => {
135
133
  forward: true,
136
134
  };
137
135
  expect(mockGetTransactions).toHaveBeenNthCalledWith(1, "src", baseOptions);
136
+ await api.listOperations("src", { minHeight: 0, order: "asc", lastPagingToken: token });
138
137
  const optionsWithToken = {
139
138
  ...baseOptions,
140
139
  marker: defaultMarker,
141
140
  };
142
141
  expect(mockGetTransactions).toHaveBeenNthCalledWith(2, "src", optionsWithToken);
143
142
 
144
- expect(results.length).toBe(txs.length * 2);
143
+ expect(results.length).toBe(txs.length);
145
144
  });
146
145
 
147
146
  it.each([
@@ -171,15 +170,12 @@ describe("listOperations", () => {
171
170
  mockGetTransactions.mockResolvedValue(mockNetworkTxs([], undefined));
172
171
 
173
172
  // When
174
- const [results, _] = await api.listOperations(address, { minHeight: 0 });
173
+ const [results, _] = await api.listOperations(address, { minHeight: 0, order: "asc" });
175
174
 
176
175
  // Then
177
- // called twice because the marker is set the first time
178
- expect(mockGetServerInfos).toHaveBeenCalledTimes(2);
179
- expect(mockGetTransactions).toHaveBeenCalledTimes(2);
176
+ expect(mockGetServerInfos).toHaveBeenCalledTimes(1);
177
+ expect(mockGetTransactions).toHaveBeenCalledTimes(1);
180
178
 
181
- // if expectedType is "OUT", compute value with fees (i.e. delivered_amount + Fee)
182
- const expectedValue = expectedType === "IN" ? deliveredAmount : deliveredAmount + fee;
183
179
  // the order is reversed so that the result is always sorted by newest tx first element of the list
184
180
  expect(results).toEqual([
185
181
  {
@@ -196,7 +192,7 @@ describe("listOperations", () => {
196
192
  },
197
193
  },
198
194
  type: expectedType,
199
- value: expectedValue,
195
+ value: deliveredAmount,
200
196
  senders: [opSender],
201
197
  recipients: [opDestination],
202
198
  details: {
@@ -224,7 +220,7 @@ describe("listOperations", () => {
224
220
  },
225
221
  },
226
222
  type: expectedType,
227
- value: expectedValue,
223
+ value: deliveredAmount,
228
224
  senders: [opSender],
229
225
  recipients: [opDestination],
230
226
  details: {
@@ -247,7 +243,7 @@ describe("listOperations", () => {
247
243
  },
248
244
  },
249
245
  type: expectedType,
250
- value: expectedValue,
246
+ value: deliveredAmount,
251
247
  senders: [opSender],
252
248
  recipients: [opDestination],
253
249
  details: {
package/src/api/index.ts CHANGED
@@ -102,75 +102,20 @@ async function estimate(): Promise<FeeEstimation> {
102
102
  return { value: estimation.fees };
103
103
  }
104
104
 
105
- type PaginationState = {
106
- readonly pageSize: number; // must be large enough to avoid unnecessary calls to the underlying explorer
107
- readonly maxIterations: number; // a security to avoid infinite loop
108
- currentIteration: number;
109
- readonly minHeight: number;
110
- continueIterations: boolean;
111
- apiNextCursor?: string;
112
- accumulator: Operation[];
113
- };
114
-
115
- async function operationsFromHeight(
116
- address: string,
117
- minHeight: number,
118
- ): Promise<[Operation[], string]> {
119
- async function fetchNextPage(state: PaginationState): Promise<PaginationState> {
120
- const options: ListOperationsOptions = {
121
- limit: state.pageSize,
122
- minHeight: state.minHeight,
123
- order: "asc",
124
- };
125
- if (state.apiNextCursor) {
126
- options.token = state.apiNextCursor;
127
- }
128
- const [operations, apiNextCursor] = await listOperations(address, options);
129
- const newCurrentIteration = state.currentIteration + 1;
130
- let continueIteration = true;
131
- if (apiNextCursor === "") {
132
- continueIteration = false;
133
- } else if (newCurrentIteration >= state.maxIterations) {
134
- log("coin:xrp", "(api/operations): max iterations reached", state.maxIterations);
135
- continueIteration = false;
136
- }
137
- const accumulated = state.accumulator.concat(operations);
138
- return {
139
- ...state,
140
- currentIteration: newCurrentIteration,
141
- continueIterations: continueIteration,
142
- apiNextCursor: apiNextCursor,
143
- accumulator: accumulated,
144
- };
145
- }
146
-
147
- const firstState: PaginationState = {
148
- pageSize: 200,
149
- maxIterations: 10,
150
- currentIteration: 0,
151
- minHeight: minHeight,
152
- continueIterations: true,
153
- accumulator: [],
154
- };
155
-
156
- let state = await fetchNextPage(firstState);
157
- while (state.continueIterations) {
158
- state = await fetchNextPage(state);
159
- }
160
- return [state.accumulator, state.apiNextCursor ?? ""];
161
- }
162
-
163
105
  // NOTE: double check
164
106
  async function operations(address: string, pagination: Pagination): Promise<[Operation[], string]> {
165
- const { minHeight, lastPagingToken } = pagination;
166
- if (minHeight) {
167
- return await operationsFromHeight(address, minHeight);
168
- }
169
- const isInitSync = lastPagingToken === "";
170
-
171
- const newPagination = {
172
- minHeight: isInitSync ? 0 : parseInt(lastPagingToken || "0", 10),
107
+ const { minHeight, lastPagingToken, order } = pagination;
108
+ const options: ListOperationsOptions = {
109
+ limit: 200,
110
+ minHeight: minHeight,
111
+ order: order ?? "asc",
173
112
  };
174
- // TODO token must be implemented properly (waiting ack from the design document)
175
- return await operationsFromHeight(address, newPagination.minHeight);
113
+ if (lastPagingToken) {
114
+ const token = lastPagingToken.split("-");
115
+ options.token = JSON.stringify({ ledger: Number(token[0]), seq: Number(token[1]) });
116
+ log(options.token);
117
+ }
118
+ const [operations, apiNextCursor] = await listOperations(address, options);
119
+ const next = apiNextCursor ? JSON.parse(apiNextCursor) : null;
120
+ return [operations, next ? next.ledger + "-" + next.seq : ""];
176
121
  }
@@ -41,7 +41,7 @@ describe("listOperations", () => {
41
41
  // Given
42
42
  mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([]));
43
43
  // When
44
- const [results, token] = await listOperations("any address", { minHeight: 0 });
44
+ const [results, token] = await listOperations("any address", { minHeight: 0, order: "asc" });
45
45
  // Then
46
46
  expect(mockGetServerInfos).toHaveBeenCalledTimes(1);
47
47
  expect(mockNetworkGetTransactions).toHaveBeenCalledTimes(1);
@@ -215,9 +215,7 @@ describe("listOperations", () => {
215
215
  // Then
216
216
  expect(mockGetServerInfos).toHaveBeenCalledTimes(1);
217
217
  expect(mockNetworkGetTransactions).toHaveBeenCalledTimes(1);
218
- // if expectedType is "OUT", compute value with fees (i.e. delivered_amount + Fee)
219
- const expectedValue =
220
- expectedType === "IN" ? BigInt(deliveredAmount) : BigInt(deliveredAmount + fees);
218
+ const expectedValue = BigInt(deliveredAmount);
221
219
  expect(results).toEqual([
222
220
  {
223
221
  id: "HASH_VALUE",
@@ -120,17 +120,12 @@ const convertToCoreOperation =
120
120
  } = operation;
121
121
 
122
122
  const type = Account === address ? "OUT" : "IN";
123
- let value =
123
+ const value =
124
124
  delivered_amount && typeof delivered_amount === "string"
125
125
  ? BigInt(delivered_amount)
126
126
  : BigInt(0);
127
127
 
128
128
  const fees = BigInt(Fee);
129
- if (type === "OUT") {
130
- if (!Number.isNaN(fees)) {
131
- value = value + fees;
132
- }
133
- }
134
129
 
135
130
  const toEpochDate = (RIPPLE_EPOCH + date) * 1000;
136
131
 
@@ -21,7 +21,7 @@ export type XrpMemoValueMap = {
21
21
  };
22
22
  export type XrpMapMemo = TypedMapMemo<XrpMemoValueMap>;
23
23
 
24
- type Order = "asc" | "desc";
24
+ export type Order = "asc" | "desc";
25
25
 
26
26
  export type ListOperationsOptions = {
27
27
  // pagination: