From 05c7e45615897f4caffa1e0a736997eb12e28406 Mon Sep 17 00:00:00 2001 From: Prajwal U Date: Wed, 11 Nov 2026 13:16:06 +0530 Subject: [PATCH] fix(sdk-coin-xrp): include SendMax, Paths and DeliverMin in Payment explanation Problem/goal: The Payment explanation returned only Destination, Amount and Fee, so SendMax, Paths and DeliverMin were not visible to consumers. Implementation details: - Both explainers (xrp.ts and lib/transaction.ts) now return sendMax, hasPaths and deliverMin when present, and list them in displayOrder. - Payments without these fields produce the same explanation as before. - No signing or verification behaviour changes. Verification: Added unit tests for explainer output in both explainers and for plain payments carrying no extra fields. sdk-coin-xrp unit suite passes (162 tests); tsc and eslint clean. Ticket: CSHLD-2162 --- modules/sdk-coin-xrp/src/lib/iface.ts | 9 ++++ modules/sdk-coin-xrp/src/lib/transaction.ts | 3 ++ modules/sdk-coin-xrp/src/lib/utils.ts | 10 +++- modules/sdk-coin-xrp/src/xrp.ts | 3 ++ modules/sdk-coin-xrp/test/unit/xrp.ts | 51 ++++++++++++++++++++- 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/modules/sdk-coin-xrp/src/lib/iface.ts b/modules/sdk-coin-xrp/src/lib/iface.ts index 324ef9b3d54..2028fa51a8d 100644 --- a/modules/sdk-coin-xrp/src/lib/iface.ts +++ b/modules/sdk-coin-xrp/src/lib/iface.ts @@ -139,6 +139,15 @@ export interface PaymentTransactionExplanation extends BaseTransactionExplanatio * this flag is still set so consumers can distinguish partial from full delivery. */ partialPayment?: boolean; + sendMax?: Amount | MPTAmount; + hasPaths?: boolean; + deliverMin?: Amount | MPTAmount; +} + +export interface PaymentSpendLimits { + sendMax?: Amount | MPTAmount; + hasPaths?: boolean; + deliverMin?: Amount | MPTAmount; } export interface AccountSetTransactionExplanation extends BaseTransactionExplanation { diff --git a/modules/sdk-coin-xrp/src/lib/transaction.ts b/modules/sdk-coin-xrp/src/lib/transaction.ts index fa75f90534a..373a569677c 100644 --- a/modules/sdk-coin-xrp/src/lib/transaction.ts +++ b/modules/sdk-coin-xrp/src/lib/transaction.ts @@ -267,6 +267,7 @@ export class Transaction extends BaseTransaction { // *requested* amount, not what was actually delivered — surface that via the flag so // consumers don't treat outputAmount as the settled value. const partialPayment = utils.isPartialPayment(tx.Flags as number); + const spendLimits = utils.getPaymentSpendLimits(tx); return { displayOrder: [ @@ -277,6 +278,7 @@ export class Transaction extends BaseTransaction { 'changeOutputs', 'fee', ...(partialPayment ? ['partialPayment'] : []), + ...Object.keys(spendLimits), ], id: this._id as string, changeOutputs: [], @@ -293,6 +295,7 @@ export class Transaction extends BaseTransaction { feeRate: undefined, }, ...(partialPayment ? { partialPayment: true } : {}), + ...spendLimits, }; } diff --git a/modules/sdk-coin-xrp/src/lib/utils.ts b/modules/sdk-coin-xrp/src/lib/utils.ts index 87c5c65886d..7561561c38b 100644 --- a/modules/sdk-coin-xrp/src/lib/utils.ts +++ b/modules/sdk-coin-xrp/src/lib/utils.ts @@ -12,7 +12,7 @@ import * as url from 'url'; import * as xrpl from 'xrpl'; import { Amount, IssuedCurrencyAmount, isMPTAmount, MPTAmount } from 'xrpl'; import { TF_PARTIAL_PAYMENT, VALID_ACCOUNT_SET_FLAGS } from './constants'; -import { Address, SignerDetails } from './iface'; +import { Address, PaymentSpendLimits, SignerDetails } from './iface'; import { KeyPair as XrpKeyPair } from './keyPair'; import assert from 'assert'; @@ -242,6 +242,14 @@ class Utils implements BaseUtils { return (Number(flags) & TF_PARTIAL_PAYMENT) !== 0; } + public getPaymentSpendLimits(tx: xrpl.Payment): PaymentSpendLimits { + return { + ...(tx.SendMax !== undefined ? { sendMax: tx.SendMax } : {}), + ...(tx.Paths !== undefined ? { hasPaths: true } : {}), + ...(tx.DeliverMin !== undefined ? { deliverMin: tx.DeliverMin } : {}), + }; + } + /** * Extracts the numeric/string delivered amount value from XRP transaction metadata. * `meta.delivered_amount` is an `Amount | 'unavailable'`: diff --git a/modules/sdk-coin-xrp/src/xrp.ts b/modules/sdk-coin-xrp/src/xrp.ts index 44b439972d3..0eb21401b7a 100644 --- a/modules/sdk-coin-xrp/src/xrp.ts +++ b/modules/sdk-coin-xrp/src/xrp.ts @@ -353,6 +353,7 @@ export class Xrp extends BaseCoin { const partialPayment = utils.isPartialPayment(transaction.Flags as number); const deliveredValue = partialPayment ? utils.getDeliveredAmountValue(params.meta) : undefined; const outputAmount = deliveredValue !== undefined ? deliveredValue : transaction.Amount; + const spendLimits = utils.getPaymentSpendLimits(transaction); return { displayOrder: [ 'id', @@ -362,6 +363,7 @@ export class Xrp extends BaseCoin { 'changeOutputs', 'fee', ...(partialPayment ? ['partialPayment'] : []), + ...Object.keys(spendLimits), ], id: id, changeOutputs: [], @@ -379,6 +381,7 @@ export class Xrp extends BaseCoin { size: txHex.length / 2, }, ...(partialPayment ? { partialPayment: true } : {}), + ...spendLimits, }; } diff --git a/modules/sdk-coin-xrp/test/unit/xrp.ts b/modules/sdk-coin-xrp/test/unit/xrp.ts index a92626cc527..c74262aef6c 100644 --- a/modules/sdk-coin-xrp/test/unit/xrp.ts +++ b/modules/sdk-coin-xrp/test/unit/xrp.ts @@ -12,7 +12,8 @@ import * as nock from 'nock'; import * as rippleBinaryCodec from 'ripple-binary-codec'; import sinon from 'sinon'; import * as xrpl from 'xrpl'; -import { XrpToken } from '../../src'; +import { Transaction, XrpToken } from '../../src'; +import { coins } from '@bitgo/statics'; import * as testData from '../resources/xrp'; import { SIGNER_BACKUP, SIGNER_BITGO, SIGNER_USER } from '../resources/xrp'; import { getMptBuilderFactory } from './getBuilderFactory'; @@ -484,6 +485,54 @@ describe('XRP:', function () { explanation.outputAmount.should.equal('0.005'); }); + describe('Payment spend limits (SendMax, Paths, DeliverMin)', function () { + const crossCurrencyPayment = { + TransactionType: 'Payment', + Account: 'rBSpCz8PafXTJHppDcNnex7dYnbe3tSuFG', + Destination: 'rfjub8A4dpSD5nnszUFTsLprxu1W398jwc', + Amount: '1', + SendMax: { + currency: '524C555344000000000000000000000000000000', + issuer: 'rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV', + value: '100000', + }, + Paths: [[{ currency: 'USD', issuer: 'rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV', type: 48 }]], + DeliverMin: '1', + Flags: 2147483648, + Fee: '45', + Sequence: 7, + }; + + it('should surface SendMax, Paths and DeliverMin in the explanation', async function () { + const explanation = await basecoin.explainTransaction({ txHex: JSON.stringify(crossCurrencyPayment) }); + explanation.sendMax.should.deepEqual(crossCurrencyPayment.SendMax); + explanation.hasPaths.should.equal(true); + explanation.deliverMin.should.equal('1'); + explanation.displayOrder.should.containEql('sendMax'); + explanation.displayOrder.should.containEql('hasPaths'); + explanation.displayOrder.should.containEql('deliverMin'); + }); + + it('should surface the same fields from the lib-level transaction explainer', async function () { + // xrpl rejects DeliverMin without tfPartialPayment, so this payload omits it. + const paymentWithoutDeliverMin = _.omit(crossCurrencyPayment, 'DeliverMin'); + const txHex = xrpl.encode(paymentWithoutDeliverMin as xrpl.Payment); + const transaction = new Transaction(coins.get('txrp')); + transaction.fromRawTransaction(txHex); + const explanation = transaction.explainTransaction(); + explanation.sendMax.should.deepEqual(crossCurrencyPayment.SendMax); + explanation.hasPaths.should.equal(true); + }); + + it('should not add spend-limit fields to a plain payment', async function () { + const plainPayment = _.omit(crossCurrencyPayment, ['SendMax', 'Paths', 'DeliverMin']); + const explanation = await basecoin.explainTransaction({ txHex: JSON.stringify(plainPayment) }); + explanation.should.not.have.property('sendMax'); + explanation.should.not.have.property('hasPaths'); + explanation.should.not.have.property('deliverMin'); + }); + }); + describe('Fee Management', () => { const nockBasecoin: any = bitgo.coin('txrp');