From 9a5b56dff3df530baba7289454881f439d3ecc7f Mon Sep 17 00:00:00 2001 From: Prabhsharan Singh Date: Tue, 7 Jul 2026 15:23:58 +0530 Subject: [PATCH] fix(sdk-coin-xrp): set SendMax to cover MPT TransferFee on withdrawal When an MPTokenIssuance has a non-zero TransferFee, rippled deducts Amount + ceil(Amount * fee / 100_000) from the sender. Without SendMax the tx fails with tecPATH_PARTIAL. This fix computes and sets SendMax correctly, and restores it across the multi-sig rebuild round-trip. Ticket: CHALO-802 --- .../src/lib/mptTransferBuilder.ts | 44 +++++++++ .../transactionBuilder/mptTransferBuilder.ts | 93 +++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/modules/sdk-coin-xrp/src/lib/mptTransferBuilder.ts b/modules/sdk-coin-xrp/src/lib/mptTransferBuilder.ts index 85c2d49e0e..f112526541 100644 --- a/modules/sdk-coin-xrp/src/lib/mptTransferBuilder.ts +++ b/modules/sdk-coin-xrp/src/lib/mptTransferBuilder.ts @@ -11,6 +11,12 @@ export class MPTokenTransferBuilder extends TransactionBuilder { private _value?: string; private _destination?: string; private _destinationTag?: number; + // transferFee is the issuance-level per-100,000 fee (e.g. 100 = 0.1%). + // When set, DeliverMax is computed as Amount + ⌈Amount × transferFee / 100_000⌉ + // so the sender covers the fee charged by the protocol on delivery. + private _transferFee?: number; + // Preserved SendMax from a deserialized transaction (multi-sig rebuild path). + private _restoredSendMax?: MPTAmount; constructor(_coinConfig: Readonly) { super(_coinConfig); @@ -36,6 +42,21 @@ export class MPTokenTransferBuilder extends TransactionBuilder { return this; } + /** + * Set the per-issuance transfer fee so DeliverMax is computed correctly. + * Matches the MPTokenIssuance.TransferFee ledger field: an integer in [0, 50_000] + * representing a fraction of 100,000 (e.g. 100 = 0.1%). + * When non-zero, DeliverMax is set to Amount + ⌈Amount × fee / 100_000⌉. + * @param {number} fee - TransferFee value from the MPTokenIssuance ledger object + */ + transferFee(fee: number): this { + if (!Number.isInteger(fee) || fee < 0 || fee > 50_000) { + throw new BuildTransactionError('MPT transferFee must be an integer between 0 and 50,000'); + } + this._transferFee = fee; + return this; + } + /** * Set the MPT issuance ID and raw integer amount to transfer. * The value is a raw integer string — AssetScale is display-only and never applied here. @@ -64,6 +85,13 @@ export class MPTokenTransferBuilder extends TransactionBuilder { if (mptAmount) { this.mptAmount(mptAmount.mpt_issuance_id, mptAmount.value); } + // Restore SendMax from the raw payment so it survives a build→serialize→rebuild round-trip. + // This is necessary for multi-sig flows where a second signer rebuilds the transaction. + const rawPayload = tx.getSignablePayload() as Payment; + const sendMax = rawPayload?.SendMax; + if (sendMax !== undefined && typeof sendMax === 'object' && 'mpt_issuance_id' in (sendMax as MPTAmount)) { + this._restoredSendMax = sendMax as MPTAmount; + } } /** @inheritdoc */ @@ -93,6 +121,22 @@ export class MPTokenTransferBuilder extends TransactionBuilder { paymentFields.DestinationTag = this._destinationTag; } + // Set SendMax so the sender covers the TransferFee charged by the issuer. + // Without SendMax, rippled defaults to SendMax = Amount; when TransferFee > 0 the + // protocol deducts Amount + fee from the sender, so the tx fails with tecPATH_PARTIAL. + // Note: DeliverMax is a JSON-level alias that the binary codec does not recognise; + // SendMax (field code 9) must be used for binary serialisation. + if (this._transferFee !== undefined && this._transferFee > 0) { + const amountBig = BigInt(this._value); + const feeBig = (amountBig * BigInt(this._transferFee) + BigInt(99_999)) / BigInt(100_000); + paymentFields.SendMax = { + mpt_issuance_id: this._mptIssuanceId, + value: String(amountBig + feeBig), + }; + } else if (this._restoredSendMax) { + paymentFields.SendMax = this._restoredSendMax; + } + this._specificFields = paymentFields; return await super.buildImplementation(); diff --git a/modules/sdk-coin-xrp/test/unit/transactionBuilder/mptTransferBuilder.ts b/modules/sdk-coin-xrp/test/unit/transactionBuilder/mptTransferBuilder.ts index 2cd75a4fff..db493b3380 100644 --- a/modules/sdk-coin-xrp/test/unit/transactionBuilder/mptTransferBuilder.ts +++ b/modules/sdk-coin-xrp/test/unit/transactionBuilder/mptTransferBuilder.ts @@ -185,6 +185,99 @@ describe('XRP MPTokenTransfer Builder', () => { }); }); + describe('transferFee / SendMax', () => { + it('should set SendMax when transferFee is non-zero', async () => { + const builder = factory.getMptTransferBuilder(); + builder.sender(sender); + builder.to(destination); + builder.mptAmount(testData.MPT_ISSUANCE_ID, '1000000'); + builder.transferFee(100); // 0.1% — same as feesec token + builder.sequence(1600010); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + const payload = tx.getSignablePayload() as Record; + + should.exist(payload.SendMax); + (payload.SendMax as { mpt_issuance_id: string; value: string }).value.should.equal('1001000'); + }); + + it('should use ceiling division for fractional fees', async () => { + const builder = factory.getMptTransferBuilder(); + builder.sender(sender); + builder.to(destination); + builder.mptAmount(testData.MPT_ISSUANCE_ID, '1'); // 1 unit, fee = ceil(1 * 100 / 100000) = 1 + builder.transferFee(100); + builder.sequence(1600010); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + const payload = tx.getSignablePayload() as Record; + + (payload.SendMax as { value: string }).value.should.equal('2'); + }); + + it('should not set SendMax when transferFee is 0', async () => { + const builder = factory.getMptTransferBuilder(); + builder.sender(sender); + builder.to(destination); + builder.mptAmount(testData.MPT_ISSUANCE_ID, testData.MPT_AMOUNT_VALUE); + builder.transferFee(0); + builder.sequence(1600010); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + const payload = tx.getSignablePayload() as Record; + + should.not.exist(payload.SendMax); + }); + + it('should not set SendMax when transferFee is not called', async () => { + const builder = factory.getMptTransferBuilder(); + builder.sender(sender); + builder.to(destination); + builder.mptAmount(testData.MPT_ISSUANCE_ID, testData.MPT_AMOUNT_VALUE); + builder.sequence(1600010); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + const payload = tx.getSignablePayload() as Record; + + should.not.exist(payload.SendMax); + }); + + it('should throw if transferFee is out of range', () => { + const builder = factory.getMptTransferBuilder(); + should(() => builder.transferFee(-1)).throw(/0 and 50,000/); + should(() => builder.transferFee(50_001)).throw(/0 and 50,000/); + }); + + it('should preserve SendMax through build→serialize→rebuild round-trip', async () => { + const builder = factory.getMptTransferBuilder(); + builder.sender(sender); + builder.to(destination); + builder.mptAmount(testData.MPT_ISSUANCE_ID, '1000000'); + builder.transferFee(100); + builder.sequence(1600010); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + const rawTx = tx.toBroadcastFormat(); + + const rebuilder = factory.from(rawTx); + const rebuiltTx = await rebuilder.build(); + const rebuiltPayload = rebuiltTx.getSignablePayload() as Record; + + should.exist(rebuiltPayload.SendMax); + (rebuiltPayload.SendMax as { value: string }).value.should.equal('1001000'); + }); + }); + describe('explainTransaction', () => { it('should return explanation with outputs for MPT transfer', async () => { const builder = factory.getMptTransferBuilder();