Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions modules/sdk-coin-xrp/src/lib/mptTransferBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CoinConfig>) {
super(_coinConfig);
Expand All @@ -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.
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

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<string, unknown>;

(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<string, unknown>;

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<string, unknown>;

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<string, unknown>;

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();
Expand Down
Loading