Skip to content

Commit 556714a

Browse files
committed
fix(xrp listoperations): 🐛 add fees operation for transactions that are not payment
1 parent 36a9640 commit 556714a

File tree

5 files changed

+270
-17
lines changed

5 files changed

+270
-17
lines changed

.changeset/flat-eagles-applaud.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ledgerhq/coin-xrp": minor
3+
---
4+
5+
Fix: listOperations output is missing some token related transactions

libs/coin-modules/coin-xrp/src/api/index.integ.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ describe("Xrp Api (testnet)", () => {
218218

219219
describe("Xrp Api (mainnet)", () => {
220220
const SENDER = "rn5BQvhksnPfbo277LtFks4iyYStPKGrnJ";
221+
const SENDER_WITH_FEES = "r4aYLkMk2yzULLpLfXtJyznFPnHKZozKip";
221222
const api = createApi({ node: "https://xrp.coin.ledger.com" });
222223

223224
describe("estimateFees", () => {
@@ -307,6 +308,29 @@ describe("Xrp Api (mainnet)", () => {
307308
expect(op.type).toEqual(outTx.type);
308309
expect(op.tx.fees).toEqual(BigInt(outTx.fees * 1e6));
309310
});
311+
312+
it("returns FEES operation", async () => {
313+
const resp = await api.listOperations(SENDER_WITH_FEES, { minHeight: 0 });
314+
const feesOps = resp.items;
315+
316+
// https://xrpscan.com/tx/BEA8B9E4D2A8351417E862D41C8BE1F0013DEFBF6A8893771FE6991E6B20E19C
317+
const outTx = {
318+
hash: "BEA8B9E4D2A8351417E862D41C8BE1F0013DEFBF6A8893771FE6991E6B20E19C",
319+
amount: 0,
320+
recipient: [],
321+
sender: SENDER_WITH_FEES,
322+
type: "FEES",
323+
fees: 0.00001,
324+
};
325+
const op = feesOps.find(o => o.tx.hash === outTx.hash) as Operation;
326+
327+
expect(op.tx.hash).toEqual(outTx.hash);
328+
expect(op.value).toEqual(BigInt(outTx.amount * 1e6));
329+
expect(op.recipients).toEqual([]);
330+
expect(op.senders).toContain(outTx.sender);
331+
expect(op.type).toEqual(outTx.type);
332+
expect(op.tx.fees).toEqual(BigInt(outTx.fees * 1e6));
333+
});
310334
});
311335

312336
describe("lastBlock", () => {

libs/coin-modules/coin-xrp/src/api/index.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,71 @@ describe("listOperations", () => {
123123
];
124124
}
125125

126+
function givenNonPaymentTx(
127+
fee: bigint,
128+
sender: string,
129+
transactionType = "OfferCreate",
130+
): unknown {
131+
return {
132+
ledger_hash: "HASH_VALUE_BLOCK",
133+
hash: "NON_PAYMENT_HASH",
134+
close_time_iso: "2000-01-01T00:00:01Z",
135+
meta: { TransactionResult: "tesSUCCESS" },
136+
tx_json: {
137+
TransactionType: transactionType,
138+
Fee: fee.toString(),
139+
ledger_index: 1,
140+
date: 1000,
141+
Account: sender,
142+
Sequence: 42,
143+
SigningPubKey: "PUBKEY",
144+
},
145+
};
146+
}
147+
148+
it("should return a FEES operation for a non-Payment tx sent by the queried address", async () => {
149+
const fee = BigInt(10);
150+
const address = "sender_address";
151+
mockGetTransactions.mockResolvedValue(
152+
mockNetworkTxs([givenNonPaymentTx(fee, address)], undefined),
153+
);
154+
155+
const { items: results } = await api.listOperations(address, { minHeight: 0, order: "asc" });
156+
157+
expect(results).toHaveLength(1);
158+
expect(results[0]).toMatchObject<Partial<Operation>>({
159+
id: "NON_PAYMENT_HASH",
160+
type: "FEES",
161+
value: BigInt(0),
162+
senders: [address],
163+
recipients: [],
164+
tx: expect.objectContaining({
165+
hash: "NON_PAYMENT_HASH",
166+
fees: fee,
167+
failed: false,
168+
}),
169+
details: {
170+
xrpTxType: "OfferCreate",
171+
sequence: 42,
172+
signingPubKey: "PUBKEY",
173+
},
174+
});
175+
});
176+
177+
it("should not return FEES operation for non-Payment tx sent by another address", async () => {
178+
const fee = BigInt(10);
179+
mockGetTransactions.mockResolvedValue(
180+
mockNetworkTxs([givenNonPaymentTx(fee, "other_address")], undefined),
181+
);
182+
183+
const { items: results } = await api.listOperations("queried_address", {
184+
minHeight: 0,
185+
order: "asc",
186+
});
187+
188+
expect(results).toHaveLength(0);
189+
});
190+
126191
it("should kill the loop after 10 iterations", async () => {
127192
const txs = givenTxs(BigInt(10), BigInt(10), "src", "dest");
128193
// each time it's called it returns a marker, so in theory it would loop forever

libs/coin-modules/coin-xrp/src/logic/listOperations.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,27 @@ describe("listOperations", () => {
8181
SigningPubKey: "DEADBEEF",
8282
},
8383
};
84+
// Non-Payment tx whose Account does NOT match the queried address ("any address")
8485
const otherTx = { ...paymentTx, tx_json: { ...paymentTx.tx_json, TransactionType: "Other" } };
8586

87+
// Non-Payment tx sent by "sender" — used to verify FEES operation generation
88+
const offerCreateTx = {
89+
ledger_hash: "HASH_VALUE_BLOCK",
90+
hash: "OFFER_HASH",
91+
validated: true,
92+
close_time_iso: "2000-01-01T00:00:01Z",
93+
meta: { TransactionResult: "tesSUCCESS" },
94+
tx_json: {
95+
TransactionType: "OfferCreate",
96+
Fee: "5",
97+
ledger_index: 2,
98+
date: 1000,
99+
Account: "sender",
100+
Sequence: 2,
101+
SigningPubKey: "DEADBEEF",
102+
},
103+
};
104+
86105
it("should only list operations of type payment", async () => {
87106
// Given
88107
const lastTransaction = paymentTx;
@@ -171,6 +190,84 @@ describe("listOperations", () => {
171190
expect(JSON.parse(token)).toEqual(someMarker);
172191
});
173192

193+
it("should include a FEES operation for a non-Payment tx sent by the queried address", async () => {
194+
// Given
195+
mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([offerCreateTx]));
196+
197+
// When
198+
const [results] = await listOperations("sender", { minHeight: 0, order: "asc" });
199+
200+
// Then
201+
expect(results).toHaveLength(1);
202+
expect(results[0]).toMatchObject<Partial<Operation>>({
203+
id: "OFFER_HASH",
204+
type: "FEES",
205+
value: BigInt(0),
206+
senders: ["sender"],
207+
recipients: [],
208+
tx: expect.objectContaining({
209+
hash: "OFFER_HASH",
210+
fees: BigInt(5),
211+
failed: false,
212+
block: {
213+
hash: "HASH_VALUE_BLOCK",
214+
height: 2,
215+
time: new Date("2000-01-01T00:00:01Z"),
216+
},
217+
}),
218+
details: {
219+
xrpTxType: "OfferCreate",
220+
sequence: 2,
221+
signingPubKey: "DEADBEEF",
222+
},
223+
});
224+
});
225+
226+
it("should exclude non-Payment txs sent by other accounts from FEES operations", async () => {
227+
// Given — offerCreateTx has Account: "sender", but we query "other_address"
228+
mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([offerCreateTx]));
229+
230+
// When
231+
const [results] = await listOperations("other_address", { minHeight: 0, order: "asc" });
232+
233+
// Then — no Payment and no matching Account, so nothing returned
234+
expect(results).toHaveLength(0);
235+
});
236+
237+
it("should return both Payment ops and FEES ops when txs contain both types", async () => {
238+
// Given — paymentTx (Payment from "sender") + offerCreateTx (OfferCreate from "sender")
239+
mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([paymentTx, offerCreateTx]));
240+
241+
// When — query as "sender" to match both
242+
const [results] = await listOperations("sender", { minHeight: 0, order: "asc" });
243+
244+
// Then
245+
expect(results).toHaveLength(2);
246+
expect(results.some(op => op.type === "OUT")).toBe(true);
247+
expect(results.some(op => op.type === "FEES")).toBe(true);
248+
});
249+
250+
it("should mark a failed non-Payment tx as a failed FEES operation", async () => {
251+
// Given
252+
const failedOfferTx = {
253+
...offerCreateTx,
254+
hash: "FAILED_OFFER_HASH",
255+
meta: { TransactionResult: "tecINSUF_RESERVE_OFFER" },
256+
};
257+
mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([failedOfferTx]));
258+
259+
// When
260+
const [results] = await listOperations("sender", { minHeight: 0, order: "asc" });
261+
262+
// Then
263+
expect(results).toHaveLength(1);
264+
expect(results[0]).toMatchObject({
265+
id: "FAILED_OFFER_HASH",
266+
type: "FEES",
267+
tx: expect.objectContaining({ failed: true }),
268+
});
269+
});
270+
174271
it.each([
175272
{
176273
address: "WHATEVER_ADDRESS",

libs/coin-modules/coin-xrp/src/logic/listOperations.ts

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -60,44 +60,64 @@ export async function listOperations(
6060
};
6161
}
6262

63-
async function getPaymentTransactions(
63+
/**
64+
* Fetches one page of account_tx and splits results into:
65+
* - Payment transactions (OUT/IN)
66+
* - Non-Payment transactions sent by this account (fee-only, no XRP transferred)
67+
*
68+
* tx_type node RPC filter is unreliable (see LIVE-16705), so we filter client-side.
69+
*/
70+
async function getAccountTransactions(
6471
address: string,
65-
options: GetTransactionsOptions,
66-
): Promise<[boolean, GetTransactionsOptions, XrplOperation[]]> {
67-
const response = await getTransactions(address, options);
72+
opts: GetTransactionsOptions,
73+
): Promise<[boolean, GetTransactionsOptions, XrplOperation[], XrplOperation[]]> {
74+
const response = await getTransactions(address, opts);
6875
const txs = response.transactions;
6976
const responseMarker = response.marker;
70-
// Filter out the transactions that are not "Payment" type because the filter on "tx_type" of the node RPC is not working as expected.
77+
7178
const paymentTxs = txs.filter(tx => tx.tx_json.TransactionType === "Payment");
72-
const shortage = (options.limit && txs.length < options.limit) || false;
73-
const { marker, ...nextOptions } = options;
79+
// Non-Payment transactions sent by this account: the account paid fees but transferred no XRP.
80+
const feeOnlyTxs = txs.filter(
81+
tx => tx.tx_json.TransactionType !== "Payment" && tx.tx_json.Account === address,
82+
);
83+
84+
const shortage = (opts.limit && txs.length < opts.limit) || false;
85+
const { marker: _marker, ...restOpts } = opts;
86+
const nextOpts: GetTransactionsOptions = { ...restOpts };
7487

7588
if (responseMarker) {
76-
(nextOptions as GetTransactionsOptions).marker = responseMarker;
77-
if (nextOptions.limit) nextOptions.limit -= paymentTxs.length;
89+
nextOpts.marker = responseMarker;
90+
if (nextOpts.limit) nextOpts.limit -= paymentTxs.length;
7891
}
79-
return [shortage, nextOptions, paymentTxs];
92+
return [shortage, nextOpts, paymentTxs, feeOnlyTxs];
8093
}
8194

82-
let [txShortage, nextOptions, transactions] = await getPaymentTransactions(address, options);
95+
let [txShortage, nextOptions, transactions, feeOnlyTxs] = await getAccountTransactions(
96+
address,
97+
options,
98+
);
8399
const isEnough = () => txShortage || (limit && transactions.length >= limit);
84100
// We need to call the node RPC multiple times to get the desired number of transactions by the limiter.
85101
while (nextOptions.limit && !isEnough()) {
86-
const [newTxShortage, newNextOptions, newTransactions] = await getPaymentTransactions(
87-
address,
88-
nextOptions,
89-
);
102+
const [newTxShortage, newNextOptions, newTransactions, newFeeOnlyTxs] =
103+
await getAccountTransactions(address, nextOptions);
90104
txShortage = newTxShortage;
91105
nextOptions = newNextOptions;
92106
transactions = transactions.concat(newTransactions);
107+
feeOnlyTxs = feeOnlyTxs.concat(newFeeOnlyTxs);
93108
}
94109

95110
// the order is reversed so that the results are always sorted by newest tx first element of the list
96-
if (order === "asc") transactions.reverse();
111+
if (order === "asc") {
112+
transactions.reverse();
113+
feeOnlyTxs.reverse();
114+
}
97115

98116
// the next index to start the pagination from
99117
const next = nextOptions.marker ? JSON.stringify(nextOptions.marker) : "";
100-
return [transactions.map(convertToCoreOperation(address)), next];
118+
const paymentOps = transactions.map(convertToCoreOperation(address));
119+
const feeOps = feeOnlyTxs.map(convertFeeToCoreOperation);
120+
return [[...paymentOps, ...feeOps], next];
101121
}
102122

103123
const convertToCoreOperation =
@@ -198,3 +218,45 @@ const convertToCoreOperation =
198218

199219
return op;
200220
};
221+
222+
/**
223+
* Converts a non-Payment transaction sent by this account into a FEES-type Operation.
224+
* These transactions (OfferCreate, OfferCancel, TrustSet, AccountSet, etc.) do not
225+
* transfer XRP to a recipient — they only consume the network fee paid by the sender.
226+
*/
227+
const convertFeeToCoreOperation = (operation: XrplOperation): Operation => {
228+
const {
229+
ledger_hash,
230+
hash,
231+
close_time_iso,
232+
tx_json: { TransactionType, Fee, date, Account, ledger_index, Sequence, SigningPubKey },
233+
} = operation;
234+
235+
const toEpochDate = (RIPPLE_EPOCH + date) * 1000;
236+
const failed = operation.meta.TransactionResult !== "tesSUCCESS";
237+
238+
return {
239+
id: hash,
240+
asset: { type: "native" },
241+
tx: {
242+
hash,
243+
fees: BigInt(Fee),
244+
date: new Date(toEpochDate),
245+
block: {
246+
time: new Date(close_time_iso),
247+
hash: ledger_hash,
248+
height: ledger_index,
249+
},
250+
failed,
251+
},
252+
type: "FEES",
253+
value: BigInt(0),
254+
senders: [Account],
255+
recipients: [],
256+
details: {
257+
xrpTxType: TransactionType,
258+
sequence: Sequence,
259+
signingPubKey: SigningPubKey,
260+
},
261+
};
262+
};

0 commit comments

Comments
 (0)