@ledgerhq/coin-xrp 7.23.4 → 7.24.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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/lib/api/getBlock.d.ts.map +1 -1
  3. package/lib/api/getBlock.js +17 -5
  4. package/lib/api/getBlock.js.map +1 -1
  5. package/lib/api/listOperations.d.ts.map +1 -1
  6. package/lib/api/listOperations.js +120 -44
  7. package/lib/api/listOperations.js.map +1 -1
  8. package/lib/api/validateIntent.d.ts.map +1 -1
  9. package/lib/api/validateIntent.js +12 -7
  10. package/lib/api/validateIntent.js.map +1 -1
  11. package/lib/network/index.d.ts.map +1 -1
  12. package/lib/network/index.js +2 -0
  13. package/lib/network/index.js.map +1 -1
  14. package/lib/types/model.d.ts +1 -0
  15. package/lib/types/model.d.ts.map +1 -1
  16. package/lib/utils/errors.d.ts +3 -0
  17. package/lib/utils/errors.d.ts.map +1 -1
  18. package/lib/utils/errors.js +2 -1
  19. package/lib/utils/errors.js.map +1 -1
  20. package/lib-es/api/getBlock.d.ts.map +1 -1
  21. package/lib-es/api/getBlock.js +17 -5
  22. package/lib-es/api/getBlock.js.map +1 -1
  23. package/lib-es/api/listOperations.d.ts.map +1 -1
  24. package/lib-es/api/listOperations.js +120 -44
  25. package/lib-es/api/listOperations.js.map +1 -1
  26. package/lib-es/api/validateIntent.d.ts.map +1 -1
  27. package/lib-es/api/validateIntent.js +14 -9
  28. package/lib-es/api/validateIntent.js.map +1 -1
  29. package/lib-es/network/index.d.ts.map +1 -1
  30. package/lib-es/network/index.js +2 -0
  31. package/lib-es/network/index.js.map +1 -1
  32. package/lib-es/types/model.d.ts +1 -0
  33. package/lib-es/types/model.d.ts.map +1 -1
  34. package/lib-es/utils/errors.d.ts +3 -0
  35. package/lib-es/utils/errors.d.ts.map +1 -1
  36. package/lib-es/utils/errors.js +1 -0
  37. package/lib-es/utils/errors.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/api/getBlock.test.ts +102 -1
  40. package/src/api/getBlock.ts +23 -5
  41. package/src/api/index.integ.test.ts +6 -6
  42. package/src/api/index.test.ts +21 -5
  43. package/src/api/listOperations.test.ts +211 -3
  44. package/src/api/listOperations.ts +153 -49
  45. package/src/api/validateIntent.test.ts +105 -11
  46. package/src/api/validateIntent.ts +14 -9
  47. package/src/network/index.test.ts +32 -0
  48. package/src/network/index.ts +2 -0
  49. package/src/types/model.ts +1 -0
  50. package/src/utils/errors.ts +2 -0
@@ -135,13 +135,14 @@ describe('listOperations', () => {
135
135
  fee: bigint,
136
136
  sender: string,
137
137
  transactionType: NonPaymentTransactionType = 'OfferCreate',
138
- destination?: string
138
+ destination?: string,
139
+ affectedNodes?: unknown[]
139
140
  ): unknown {
140
141
  return {
141
142
  ledger_hash: 'HASH_VALUE_BLOCK',
142
143
  hash: 'NON_PAYMENT_HASH',
143
144
  close_time_iso: '2000-01-01T00:00:01Z',
144
- meta: { TransactionResult: 'tesSUCCESS' },
145
+ meta: { TransactionResult: 'tesSUCCESS', AffectedNodes: affectedNodes },
145
146
  tx_json: {
146
147
  TransactionType: transactionType,
147
148
  Fee: fee.toString(),
@@ -198,12 +199,27 @@ describe('listOperations', () => {
198
199
  expect(results).toHaveLength(0)
199
200
  })
200
201
 
201
- it('should return non-Payment tx when queried address is recipient', async () => {
202
+ it('should return non-Payment tx when queried address receives XRP via AffectedNodes', async () => {
202
203
  const fee = BigInt(10)
203
204
  const address = 'recipient_address'
204
205
  const sender = 'sender_address'
206
+ // AccountDelete sends the remaining balance to the Destination.
207
+ // The recipient's AccountRoot is modified in AffectedNodes.
208
+ const affectedNodes = [
209
+ {
210
+ ModifiedNode: {
211
+ LedgerEntryType: 'AccountRoot',
212
+ LedgerIndex: 'ABC123',
213
+ FinalFields: { Account: address, Balance: '50000000' },
214
+ PreviousFields: { Balance: '10000000' },
215
+ },
216
+ },
217
+ ]
205
218
  mockGetTransactions.mockResolvedValue(
206
- mockNetworkTxs([givenNonPaymentTx(fee, sender, 'AccountDelete', address)], undefined)
219
+ mockNetworkTxs(
220
+ [givenNonPaymentTx(fee, sender, 'AccountDelete', address, affectedNodes)],
221
+ undefined
222
+ )
207
223
  )
208
224
 
209
225
  const { items: results } = await api.listOperations(address, { minHeight: 0, order: 'asc' })
@@ -212,7 +228,7 @@ describe('listOperations', () => {
212
228
  expect(results[0]).toMatchObject<Partial<Operation>>({
213
229
  id: 'NON_PAYMENT_HASH',
214
230
  type: 'IN',
215
- value: BigInt(0),
231
+ value: BigInt(40000000),
216
232
  senders: [sender],
217
233
  recipients: [address],
218
234
  tx: expect.objectContaining({
@@ -227,17 +227,225 @@ describe('listOperations', () => {
227
227
  })
228
228
  })
229
229
 
230
- it('should exclude non-Payment txs sent by other accounts from FEES operations', async () => {
231
- // Given — offerCreateTx has Account: "sender", but we query "other_address"
230
+ it('should exclude non-Payment txs that do not affect the queried address', async () => {
231
+ // Given — offerCreateTx has Account: "sender", no AffectedNodes for "other_address"
232
232
  mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([offerCreateTx]))
233
233
 
234
234
  // When
235
235
  const [results] = await listOperations('other_address', { minHeight: 0, order: 'asc' })
236
236
 
237
- // Then — no Payment and no matching Account, so nothing returned
237
+ // Then — not fee payer, no AffectedNodes balance change nothing returned
238
238
  expect(results).toHaveLength(0)
239
239
  })
240
240
 
241
+ it('should emit an IN operation when a DEX trade fills an offer and the queried address receives XRP', async () => {
242
+ // Given — Account A creates an OfferCreate that fills an offer by "receiver".
243
+ // "receiver" gets 5000000 drops of XRP through the DEX match.
244
+ const dexFillTx = {
245
+ ledger_hash: 'HASH_VALUE_BLOCK',
246
+ hash: 'DEX_FILL_HASH',
247
+ validated: true,
248
+ close_time_iso: '2000-01-01T00:00:01Z',
249
+ meta: {
250
+ TransactionResult: 'tesSUCCESS',
251
+ AffectedNodes: [
252
+ {
253
+ ModifiedNode: {
254
+ LedgerEntryType: 'AccountRoot',
255
+ LedgerIndex: 'ABC123',
256
+ FinalFields: { Account: 'receiver', Balance: '15000000' },
257
+ PreviousFields: { Balance: '10000000' },
258
+ },
259
+ },
260
+ {
261
+ ModifiedNode: {
262
+ LedgerEntryType: 'AccountRoot',
263
+ LedgerIndex: 'DEF456',
264
+ FinalFields: { Account: 'trader', Balance: '4999988' },
265
+ PreviousFields: { Balance: '10000000' },
266
+ },
267
+ },
268
+ ],
269
+ },
270
+ tx_json: {
271
+ TransactionType: 'OfferCreate',
272
+ Fee: '12',
273
+ ledger_index: 2,
274
+ date: 1000,
275
+ Account: 'trader',
276
+ Sequence: 3,
277
+ SigningPubKey: 'DEADBEEF',
278
+ },
279
+ }
280
+ mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([dexFillTx]))
281
+
282
+ // When — query as "receiver" (the address whose offer got filled)
283
+ const [results] = await listOperations('receiver', { minHeight: 0, order: 'asc' })
284
+
285
+ // Then — receiver gets an IN operation for 5000000 drops
286
+ expect(results).toHaveLength(1)
287
+ expect(results[0]).toMatchObject({
288
+ id: 'DEX_FILL_HASH',
289
+ type: 'IN',
290
+ value: BigInt(5000000),
291
+ asset: { type: 'native' },
292
+ tx: expect.objectContaining({
293
+ hash: 'DEX_FILL_HASH',
294
+ fees: BigInt(12),
295
+ feesPayer: 'trader',
296
+ failed: false,
297
+ }),
298
+ })
299
+ })
300
+
301
+ it('should emit an OUT operation when sender spends XRP through a DEX trade beyond fees', async () => {
302
+ // Given — sender's OfferCreate spends 5000000 drops XRP + 12 drops fee through the DEX
303
+ const dexSpendTx = {
304
+ ledger_hash: 'HASH_VALUE_BLOCK',
305
+ hash: 'DEX_SPEND_HASH',
306
+ validated: true,
307
+ close_time_iso: '2000-01-01T00:00:01Z',
308
+ meta: {
309
+ TransactionResult: 'tesSUCCESS',
310
+ AffectedNodes: [
311
+ {
312
+ ModifiedNode: {
313
+ LedgerEntryType: 'AccountRoot',
314
+ LedgerIndex: 'ABC123',
315
+ FinalFields: { Account: 'sender', Balance: '4999988' },
316
+ PreviousFields: { Balance: '10000000' },
317
+ },
318
+ },
319
+ ],
320
+ },
321
+ tx_json: {
322
+ TransactionType: 'OfferCreate',
323
+ Fee: '12',
324
+ ledger_index: 2,
325
+ date: 1000,
326
+ Account: 'sender',
327
+ Sequence: 4,
328
+ SigningPubKey: 'DEADBEEF',
329
+ },
330
+ }
331
+ mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([dexSpendTx]))
332
+
333
+ // When
334
+ const [results] = await listOperations('sender', { minHeight: 0, order: 'asc' })
335
+
336
+ // Then — sender gets an OUT operation for 5000000 drops (fee excluded)
337
+ expect(results).toHaveLength(1)
338
+ expect(results[0]).toMatchObject({
339
+ id: 'DEX_SPEND_HASH',
340
+ type: 'OUT',
341
+ // diff = 4999988 - 10000000 = -5000012, adjustedDiff = -5000012 + 12 = -5000000
342
+ value: BigInt(5000000),
343
+ tx: expect.objectContaining({
344
+ fees: BigInt(12),
345
+ feesPayer: 'sender',
346
+ }),
347
+ })
348
+ })
349
+
350
+ it('should emit an IN operation when a non-Payment tx creates a new account (CreatedNode)', async () => {
351
+ // Given — a non-Payment tx (e.g. EscrowFinish) creates a new account with an initial balance
352
+ const escrowFinishTx = {
353
+ ledger_hash: 'HASH_VALUE_BLOCK',
354
+ hash: 'ESCROW_HASH',
355
+ validated: true,
356
+ close_time_iso: '2000-01-01T00:00:01Z',
357
+ meta: {
358
+ TransactionResult: 'tesSUCCESS',
359
+ AffectedNodes: [
360
+ {
361
+ CreatedNode: {
362
+ LedgerEntryType: 'AccountRoot',
363
+ LedgerIndex: 'ABC123',
364
+ NewFields: { Account: 'new_account', Balance: '50000000' },
365
+ },
366
+ },
367
+ ],
368
+ },
369
+ tx_json: {
370
+ TransactionType: 'EscrowFinish',
371
+ Fee: '12',
372
+ ledger_index: 2,
373
+ date: 1000,
374
+ Account: 'escrow_sender',
375
+ Destination: 'new_account',
376
+ Sequence: 5,
377
+ SigningPubKey: 'DEADBEEF',
378
+ },
379
+ }
380
+ mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([escrowFinishTx]))
381
+
382
+ // When — query as the newly created account
383
+ const [results] = await listOperations('new_account', { minHeight: 0, order: 'asc' })
384
+
385
+ // Then — new account gets an IN operation for its initial balance
386
+ expect(results).toHaveLength(1)
387
+ expect(results[0]).toMatchObject({
388
+ id: 'ESCROW_HASH',
389
+ type: 'IN',
390
+ value: BigInt(50000000),
391
+ senders: ['escrow_sender'],
392
+ recipients: ['new_account'],
393
+ })
394
+ })
395
+
396
+ it('should emit an OUT operation when AccountDelete sender account is deleted (DeletedNode)', async () => {
397
+ // Given — sender deletes their account, remaining balance goes to destination.
398
+ // The sender's AccountRoot appears as a DeletedNode with FinalFields.Balance.
399
+ const accountDeleteTx = {
400
+ ledger_hash: 'HASH_VALUE_BLOCK',
401
+ hash: 'ACCT_DELETE_HASH',
402
+ validated: true,
403
+ close_time_iso: '2000-01-01T00:00:01Z',
404
+ meta: {
405
+ TransactionResult: 'tesSUCCESS',
406
+ AffectedNodes: [
407
+ {
408
+ DeletedNode: {
409
+ LedgerEntryType: 'AccountRoot',
410
+ LedgerIndex: 'ABC123',
411
+ FinalFields: { Account: 'deleted_sender', Balance: '30000000' },
412
+ },
413
+ },
414
+ ],
415
+ },
416
+ tx_json: {
417
+ TransactionType: 'AccountDelete',
418
+ Fee: '2000000',
419
+ ledger_index: 2,
420
+ date: 1000,
421
+ Account: 'deleted_sender',
422
+ Destination: 'receiver',
423
+ Sequence: 10,
424
+ SigningPubKey: 'DEADBEEF',
425
+ },
426
+ }
427
+ mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([accountDeleteTx]))
428
+
429
+ // When — query as the deleted sender
430
+ const [results] = await listOperations('deleted_sender', { minHeight: 0, order: 'asc' })
431
+
432
+ // Then — sender gets an OUT operation for the remaining balance sent to destination.
433
+ // FinalFields.Balance = 30000000 (post-fee). diff = -(30000000 + 2000000) = -32000000.
434
+ // adjustedDiff = -32000000 + 2000000 = -30000000 → OUT with value 30000000.
435
+ expect(results).toHaveLength(1)
436
+ expect(results[0]).toMatchObject({
437
+ id: 'ACCT_DELETE_HASH',
438
+ type: 'OUT',
439
+ value: BigInt(30000000),
440
+ senders: ['deleted_sender'],
441
+ recipients: ['receiver'],
442
+ tx: expect.objectContaining({
443
+ fees: BigInt(2000000),
444
+ feesPayer: 'deleted_sender',
445
+ }),
446
+ })
447
+ })
448
+
241
449
  it('should return both Payment ops and FEES ops when txs contain both types', async () => {
242
450
  // Given — paymentTx (Payment from "sender") + offerCreateTx (OfferCreate from "sender")
243
451
  mockNetworkGetTransactions.mockResolvedValue(mockNetworkTxs([paymentTx, offerCreateTx]))
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: © 2024 LEDGER SAS
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
- import type { XrplOperation } from '../network/types'
4
+ import type { AffectedNode, XrplOperation } from '../network/types'
5
5
  import { Operation } from '@ledgerhq/coin-module-framework/api/types'
6
6
  import { getServerInfos, getTransactions, GetTransactionsOptions } from '../network'
7
7
  import { ListOperationsOptions, XrpMemo } from '../types'
@@ -66,27 +66,21 @@ export async function listOperations(
66
66
  /**
67
67
  * Fetches one page of account_tx and splits results into:
68
68
  * - Payment transactions (OUT/IN)
69
- * - Non-Payment transactions sent by this account (fee-only, no XRP transferred)
69
+ * - Non-Payment transactions (balance changes extracted from AffectedNodes metadata)
70
70
  *
71
71
  * tx_type node RPC filter is unreliable (see LIVE-16705), so we filter client-side.
72
72
  */
73
73
  async function getAccountTransactions(
74
74
  address: string,
75
75
  opts: GetTransactionsOptions
76
- ): Promise<[boolean, GetTransactionsOptions, XrplOperation[], XrplOperation[], XrplOperation[]]> {
76
+ ): Promise<[boolean, GetTransactionsOptions, XrplOperation[], XrplOperation[]]> {
77
77
  const response = await getTransactions(address, opts)
78
78
  const txs = response.transactions
79
79
  const responseMarker = response.marker
80
80
 
81
81
  const paymentTxs = txs.filter((tx) => tx.tx_json.TransactionType === 'Payment')
82
- // Non-Payment transactions sent by this account: the account paid fees but transferred no XRP.
83
- const feeOnlyTxs = txs.filter(
84
- (tx) => tx.tx_json.TransactionType !== 'Payment' && tx.tx_json.Account === address
85
- )
86
- // Non-Payment transactions received by this account: the account received XRP but did not pay fees.
87
- const receivedTxs = txs.filter(
88
- (tx) => tx.tx_json.TransactionType !== 'Payment' && tx.tx_json.Destination === address
89
- )
82
+ // Non-Payment transactions filtered for balance impact later by convertNonPaymentToCoreOperation
83
+ const nonPaymentTxs = txs.filter((tx) => tx.tx_json.TransactionType !== 'Payment')
90
84
 
91
85
  const shortage = (opts.limit && txs.length < opts.limit) || false
92
86
  const { marker: _marker, ...restOpts } = opts
@@ -96,36 +90,37 @@ export async function listOperations(
96
90
  nextOpts.marker = responseMarker
97
91
  if (nextOpts.limit) nextOpts.limit -= paymentTxs.length
98
92
  }
99
- return [shortage, nextOpts, paymentTxs, feeOnlyTxs, receivedTxs]
93
+ return [shortage, nextOpts, paymentTxs, nonPaymentTxs]
100
94
  }
101
95
 
102
- let [txShortage, nextOptions, transactions, feeOnlyTxs, receivedTxs] =
103
- await getAccountTransactions(address, options)
96
+ let [txShortage, nextOptions, transactions, nonPaymentTxs] = await getAccountTransactions(
97
+ address,
98
+ options
99
+ )
104
100
  const isEnough = () => txShortage || (limit && transactions.length >= limit)
105
101
  // We need to call the node RPC multiple times to get the desired number of transactions by the limiter.
106
102
  while (nextOptions.limit && !isEnough()) {
107
- const [newTxShortage, newNextOptions, newTransactions, newFeeOnlyTxs, newReceivedTxs] =
103
+ const [newTxShortage, newNextOptions, newTransactions, newNonPaymentTxs] =
108
104
  await getAccountTransactions(address, nextOptions)
109
105
  txShortage = newTxShortage
110
106
  nextOptions = newNextOptions
111
107
  transactions = transactions.concat(newTransactions)
112
- feeOnlyTxs = feeOnlyTxs.concat(newFeeOnlyTxs)
113
- receivedTxs = receivedTxs.concat(newReceivedTxs)
108
+ nonPaymentTxs = nonPaymentTxs.concat(newNonPaymentTxs)
114
109
  }
115
110
 
116
111
  // the order is reversed so that the results are always sorted by newest tx first element of the list
117
112
  if (order === 'asc') {
118
113
  transactions.reverse()
119
- feeOnlyTxs.reverse()
120
- receivedTxs.reverse()
114
+ nonPaymentTxs.reverse()
121
115
  }
122
116
 
123
117
  // the next index to start the pagination from
124
118
  const next = nextOptions.marker ? JSON.stringify(nextOptions.marker) : ''
125
119
  const paymentOps = transactions.map(convertToCoreOperation(address))
126
- const feeOps = feeOnlyTxs.map(convertFeeToCoreOperation)
127
- const receivedOps = receivedTxs.map(convertToCoreOperation(address))
128
- return [[...paymentOps, ...feeOps, ...receivedOps], next]
120
+ const nonPaymentOps = nonPaymentTxs
121
+ .map(convertNonPaymentToCoreOperation(address))
122
+ .filter((op): op is Operation => op !== null)
123
+ return [[...paymentOps, ...nonPaymentOps], next]
129
124
  }
130
125
 
131
126
  const convertToCoreOperation =
@@ -230,27 +225,106 @@ const convertToCoreOperation =
230
225
  }
231
226
 
232
227
  /**
233
- * Converts a non-Payment transaction sent by this account into a FEES-type Operation.
234
- * These transactions (OfferCreate, OfferCancel, TrustSet, AccountSet, etc.) do not
235
- * transfer XRP to a recipient — they only consume the network fee paid by the sender.
228
+ * Finds the XRP (native) balance change for a specific address in a transaction's AffectedNodes.
229
+ * Returns the balance diff in drops, or null if the address's AccountRoot was not affected.
230
+ *
231
+ * For ModifiedNode: diff = FinalFields.Balance - PreviousFields.Balance (includes fee deduction).
232
+ * For CreatedNode: diff = NewFields.Balance (initial funding amount).
233
+ * For DeletedNode: diff = -(FinalFields.Balance + fee). FinalFields.Balance is the post-fee balance
234
+ * remaining when the account was deleted. We add the fee back so the caller's fee adjustment
235
+ * (adjustedDiff = diff + fee) yields the correct transfer amount.
236
236
  */
237
- const convertFeeToCoreOperation = (operation: XrplOperation): Operation => {
238
- const {
239
- ledger_hash,
240
- hash,
241
- close_time_iso,
242
- tx_json: { TransactionType, Fee, date, Account, ledger_index, Sequence, SigningPubKey },
243
- } = operation
244
-
245
- const toEpochDate = (RIPPLE_EPOCH + date) * 1000
246
- const failed = operation.meta.TransactionResult !== 'tesSUCCESS'
247
-
248
- return {
249
- id: hash,
250
- asset: { type: 'native' },
251
- tx: {
237
+ function findXrpBalanceDiff(
238
+ address: string,
239
+ affectedNodes: AffectedNode[],
240
+ fee: bigint
241
+ ): bigint | null {
242
+ for (const node of affectedNodes) {
243
+ if ('ModifiedNode' in node) {
244
+ const data = node.ModifiedNode
245
+ if (
246
+ data.LedgerEntryType === 'AccountRoot' &&
247
+ data.FinalFields?.Account === address &&
248
+ typeof data.FinalFields?.Balance === 'string' &&
249
+ typeof data.PreviousFields?.Balance === 'string'
250
+ ) {
251
+ return (
252
+ BigInt(data.FinalFields.Balance as string) - BigInt(data.PreviousFields.Balance as string)
253
+ )
254
+ }
255
+ } else if ('CreatedNode' in node) {
256
+ const data = node.CreatedNode
257
+ if (
258
+ data.LedgerEntryType === 'AccountRoot' &&
259
+ data.NewFields?.Account === address &&
260
+ typeof data.NewFields?.Balance === 'string'
261
+ ) {
262
+ return BigInt(data.NewFields.Balance as string)
263
+ }
264
+ } else if ('DeletedNode' in node) {
265
+ const data = node.DeletedNode
266
+ // AccountDelete: the sender's AccountRoot is deleted. FinalFields.Balance is the post-fee balance
267
+ // (the fee was already deducted before deletion). We return -(Balance + fee) so that the caller's
268
+ // fee adjustment (diff + fee) yields the correct transfer amount (= FinalFields.Balance).
269
+ if (
270
+ data.LedgerEntryType === 'AccountRoot' &&
271
+ data.FinalFields?.Account === address &&
272
+ typeof data.FinalFields?.Balance === 'string'
273
+ ) {
274
+ const balance = BigInt(data.FinalFields.Balance as string)
275
+ // Include the fee in the diff so the caller's adjustedDiff = (-balance - fee) + fee = -balance
276
+ return -(balance + fee)
277
+ }
278
+ }
279
+ }
280
+ return null
281
+ }
282
+
283
+ /**
284
+ * Converts a non-Payment transaction into an Operation by inspecting AffectedNodes metadata
285
+ * to determine the actual XRP balance change for the queried address.
286
+ *
287
+ * This handles DEX trades (OfferCreate), TrustSet, AccountSet, etc. where the queried address
288
+ * may gain or lose XRP through offer matching — not just pay fees.
289
+ *
290
+ * Returns null if the transaction does not affect the address's XRP balance and the address
291
+ * is not the fee payer.
292
+ */
293
+ const convertNonPaymentToCoreOperation =
294
+ (address: string) =>
295
+ (operation: XrplOperation): Operation | null => {
296
+ const {
297
+ ledger_hash,
252
298
  hash,
253
- fees: BigInt(Fee),
299
+ close_time_iso,
300
+ meta: { AffectedNodes, TransactionResult },
301
+ tx_json: {
302
+ TransactionType,
303
+ Fee,
304
+ date,
305
+ Account,
306
+ Destination,
307
+ ledger_index,
308
+ Sequence,
309
+ SigningPubKey,
310
+ },
311
+ } = operation
312
+
313
+ const fee = BigInt(Fee)
314
+ const toEpochDate = (RIPPLE_EPOCH + date) * 1000
315
+ const failed = TransactionResult !== 'tesSUCCESS'
316
+ const isFeePayer = Account === address
317
+
318
+ // Extract actual XRP balance change from ledger metadata
319
+ const diff = findXrpBalanceDiff(address, AffectedNodes ?? [], fee)
320
+
321
+ // The ledger diff already includes the fee deduction, so add the fee back to isolate the transfer amount.
322
+ // Example: sender trades 5 XRP + 12 drops fee → diff = -5000012, adjustedDiff = -5000012 + 12 = -5000000
323
+ const adjustedDiff = diff !== null && isFeePayer ? diff + fee : diff
324
+
325
+ const baseTx = {
326
+ hash,
327
+ fees: fee,
254
328
  feesPayer: Account,
255
329
  date: new Date(toEpochDate),
256
330
  block: {
@@ -259,15 +333,45 @@ const convertFeeToCoreOperation = (operation: XrplOperation): Operation => {
259
333
  height: ledger_index,
260
334
  },
261
335
  failed,
262
- },
263
- type: 'FEES',
264
- value: BigInt(0),
265
- senders: [Account],
266
- recipients: [],
267
- details: {
336
+ }
337
+
338
+ const details = {
268
339
  xrpTxType: TransactionType,
269
340
  sequence: Sequence,
270
341
  signingPubKey: SigningPubKey,
271
- },
342
+ }
343
+
344
+ // Non-zero transfer amount: emit IN or OUT operation
345
+ if (adjustedDiff !== null && adjustedDiff !== BigInt(0)) {
346
+ const type = adjustedDiff > BigInt(0) ? 'IN' : 'OUT'
347
+ const value = adjustedDiff > BigInt(0) ? adjustedDiff : -adjustedDiff
348
+
349
+ return {
350
+ id: hash,
351
+ asset: { type: 'native' },
352
+ tx: baseTx,
353
+ type,
354
+ value,
355
+ senders: [Account],
356
+ recipients: Destination ? [Destination] : [],
357
+ details,
358
+ }
359
+ }
360
+
361
+ // No transfer beyond fees — only emit FEES for the fee payer
362
+ if (isFeePayer) {
363
+ return {
364
+ id: hash,
365
+ asset: { type: 'native' },
366
+ tx: baseTx,
367
+ type: 'FEES',
368
+ value: BigInt(0),
369
+ senders: [Account],
370
+ recipients: [],
371
+ details,
372
+ }
373
+ }
374
+
375
+ // Not fee payer, no XRP balance change — skip
376
+ return null
272
377
  }
273
- }