From 13e3231e808a85bef60f2652dea22dc8bc37391f Mon Sep 17 00:00:00 2001 From: Himanshu Singroha Date: Fri, 29 May 2026 11:24:52 +0530 Subject: [PATCH] feat(sdk-coin-xrp): add MPT transaction builders Ticket: CGD-1470 TICKET: CGD-1471 --- modules/sdk-coin-xrp/src/lib/iface.ts | 22 +- modules/sdk-coin-xrp/src/lib/index.ts | 2 + .../src/lib/mpTokenAuthorizeBuilder.ts | 78 +++++++ .../src/lib/mptTransferBuilder.ts | 100 +++++++++ modules/sdk-coin-xrp/src/lib/transaction.ts | 86 ++++++-- .../src/lib/transactionBuilderFactory.ts | 16 ++ modules/sdk-coin-xrp/src/lib/utils.ts | 12 +- modules/sdk-coin-xrp/test/resources/xrp.ts | 8 + .../test/unit/getBuilderFactory.ts | 30 ++- .../mpTokenAuthorizeBuilder.ts | 179 +++++++++++++++ .../transactionBuilder/mptTransferBuilder.ts | 207 ++++++++++++++++++ .../sdk-core/src/account-lib/baseCoin/enum.ts | 4 + 12 files changed, 715 insertions(+), 29 deletions(-) create mode 100644 modules/sdk-coin-xrp/src/lib/mpTokenAuthorizeBuilder.ts create mode 100644 modules/sdk-coin-xrp/src/lib/mptTransferBuilder.ts create mode 100644 modules/sdk-coin-xrp/test/unit/transactionBuilder/mpTokenAuthorizeBuilder.ts create mode 100644 modules/sdk-coin-xrp/test/unit/transactionBuilder/mptTransferBuilder.ts diff --git a/modules/sdk-coin-xrp/src/lib/iface.ts b/modules/sdk-coin-xrp/src/lib/iface.ts index b79099ae2f..e8547b650a 100644 --- a/modules/sdk-coin-xrp/src/lib/iface.ts +++ b/modules/sdk-coin-xrp/src/lib/iface.ts @@ -5,7 +5,18 @@ import { VerifyAddressOptions as BaseVerifyAddressOptions, TransactionPrebuild, } from '@bitgo/sdk-core'; -import { AccountDelete, AccountSet, Amount, Payment, Signer, SignerEntry, SignerListSet, TrustSet } from 'xrpl'; +import { + AccountDelete, + AccountSet, + Amount, + MPTAmount, + MPTokenAuthorize, + Payment, + Signer, + SignerEntry, + SignerListSet, + TrustSet, +} from 'xrpl'; export enum XrpTransactionType { AccountDelete = 'AccountDelete', @@ -13,9 +24,13 @@ export enum XrpTransactionType { Payment = 'Payment', SignerListSet = 'SignerListSet', TrustSet = 'TrustSet', + MPTokenAuthorize = 'MPTokenAuthorize', } -export type XrpTransaction = AccountDelete | Payment | AccountSet | SignerListSet | TrustSet; +// Re-export so consumers can import alongside other XRP types from this module. +export type { MPTAmount, MPTokenAuthorize }; + +export type XrpTransaction = AccountDelete | Payment | AccountSet | SignerListSet | TrustSet | MPTokenAuthorize; export interface Address { address: string; @@ -138,6 +153,9 @@ export interface TxData { // signer list set fields signerQuorum?: number; signerEntries?: SignerEntry[]; + // mpt fields + mptIssuanceId?: string; + mptAmount?: MPTAmount; } export interface SignerDetails { diff --git a/modules/sdk-coin-xrp/src/lib/index.ts b/modules/sdk-coin-xrp/src/lib/index.ts index 545457273c..391186f32d 100644 --- a/modules/sdk-coin-xrp/src/lib/index.ts +++ b/modules/sdk-coin-xrp/src/lib/index.ts @@ -5,6 +5,8 @@ export { AccountSetBuilder } from './accountSetBuilder'; export * from './constants'; export * from './iface'; export { KeyPair } from './keyPair'; +export { MPTokenAuthorizeBuilder } from './mpTokenAuthorizeBuilder'; +export { MptTransferBuilder } from './mptTransferBuilder'; export { TokenTransferBuilder } from './tokenTransferBuilder'; export { Transaction } from './transaction'; export { TransactionBuilder } from './transactionBuilder'; diff --git a/modules/sdk-coin-xrp/src/lib/mpTokenAuthorizeBuilder.ts b/modules/sdk-coin-xrp/src/lib/mpTokenAuthorizeBuilder.ts new file mode 100644 index 0000000000..982e3c0c20 --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/mpTokenAuthorizeBuilder.ts @@ -0,0 +1,78 @@ +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { MPTokenAuthorize } from 'xrpl'; +import { XrpTransactionType } from './iface'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; + +export class MPTokenAuthorizeBuilder extends TransactionBuilder { + private _mptIssuanceId?: string; + private _mptHolder?: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.MPTokenAuthorize; + } + + protected get xrpTransactionType(): XrpTransactionType.MPTokenAuthorize { + return XrpTransactionType.MPTokenAuthorize; + } + + /** + * Set the MPTokenIssuanceID to authorize. + * @param {string} id - 48-character hex MPTokenIssuanceID + */ + mptIssuanceId(id: string): this { + if (!/^[0-9a-fA-F]{48}$/.test(id)) { + throw new BuildTransactionError('MPTokenIssuanceID must be a 48-character hex string (192 bits)'); + } + this._mptIssuanceId = id; + return this; + } + + /** + * Set the Holder field for issuer-side authorization (Phase 2 only). + * Omit for standard holder self-authorization. + * @param {string} address - the holder account address + */ + mptHolder(address: string): this { + this._mptHolder = address; + return this; + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + const { mptIssuanceId } = tx.toJson(); + if (mptIssuanceId) { + this._mptIssuanceId = mptIssuanceId; + } + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + if (!this._sender) { + throw new BuildTransactionError('Sender must be set before building the transaction'); + } + if (!this._mptIssuanceId) { + throw new BuildTransactionError('MPTokenIssuanceID must be set before building the transaction'); + } + + const authorizeFields: MPTokenAuthorize = { + TransactionType: this.xrpTransactionType, + Account: this._sender, + MPTokenIssuanceID: this._mptIssuanceId, + }; + + // Omit Holder for self-authorization — setting it causes XRPL rejection on holder self-auth. + if (this._mptHolder) { + authorizeFields.Holder = this._mptHolder; + } + + this._specificFields = authorizeFields; + + return await super.buildImplementation(); + } +} diff --git a/modules/sdk-coin-xrp/src/lib/mptTransferBuilder.ts b/modules/sdk-coin-xrp/src/lib/mptTransferBuilder.ts new file mode 100644 index 0000000000..140bdc02b2 --- /dev/null +++ b/modules/sdk-coin-xrp/src/lib/mptTransferBuilder.ts @@ -0,0 +1,100 @@ +import { BuildTransactionError, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { MPTAmount, Payment } from 'xrpl'; +import { XrpTransactionType } from './iface'; +import { Transaction } from './transaction'; +import { TransactionBuilder } from './transactionBuilder'; +import utils from './utils'; + +export class MptTransferBuilder extends TransactionBuilder { + private _mptIssuanceId?: string; + private _value?: string; + private _destination?: string; + private _destinationTag?: number; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + protected get transactionType(): TransactionType { + return TransactionType.SendMPT; + } + + protected get xrpTransactionType(): XrpTransactionType.Payment { + // MPT transfers use a standard Payment transaction with an MPT Amount object + return XrpTransactionType.Payment; + } + + /** + * Set the recipient address (with optional destination tag). + * @param {string} address - the recipient XRP address, optionally with destination tag + */ + to(address: string): this { + const { address: xrpAddress, destinationTag } = utils.getAddressDetails(address); + this._destination = xrpAddress; + this._destinationTag = destinationTag; + 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. + * @param {string} issuanceId - 48-character hex MPTokenIssuanceID + * @param {string} value - raw integer string (e.g. "1000" = 1000 base units) + */ + mptAmount(issuanceId: string, value: string): this { + if (!/^[0-9a-fA-F]{48}$/.test(issuanceId)) { + throw new BuildTransactionError('MPTokenIssuanceID must be a 48-character hex string'); + } + if (!/^\d+$/.test(value)) { + throw new BuildTransactionError('MPT value must be a non-negative integer string'); + } + this._mptIssuanceId = issuanceId; + this._value = value; + return this; + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + const { destination, destinationTag, mptAmount } = tx.toJson(); + if (destination) { + const normalizedAddress = utils.normalizeAddress({ address: destination, destinationTag }); + this.to(normalizedAddress); + } + if (mptAmount) { + this.mptAmount(mptAmount.mpt_issuance_id, mptAmount.value); + } + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + if (!this._sender) { + throw new BuildTransactionError('Sender must be set before building the transaction'); + } + if (!this._destination || !this._mptIssuanceId || !this._value) { + throw new BuildTransactionError( + 'Missing mandatory MPT payment parameters: destination, mptIssuanceId, and value are all required' + ); + } + + const mptAmountObj: MPTAmount = { + mpt_issuance_id: this._mptIssuanceId, + value: this._value, + }; + + const paymentFields: Payment = { + TransactionType: this.xrpTransactionType, + Account: this._sender, + Destination: this._destination, + Amount: mptAmountObj, + }; + + if (typeof this._destinationTag === 'number') { + paymentFields.DestinationTag = this._destinationTag; + } + + this._specificFields = paymentFields; + + return await super.buildImplementation(); + } +} diff --git a/modules/sdk-coin-xrp/src/lib/transaction.ts b/modules/sdk-coin-xrp/src/lib/transaction.ts index fe5eb6efac..305e995470 100644 --- a/modules/sdk-coin-xrp/src/lib/transaction.ts +++ b/modules/sdk-coin-xrp/src/lib/transaction.ts @@ -13,7 +13,7 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; import utils from './utils'; import BigNumber from 'bignumber.js'; -import { Signer } from 'xrpl'; +import { MPTokenAuthorize, Signer } from 'xrpl'; import { AccountSetTransactionExplanation, SignerListSetTransactionExplanation, @@ -83,18 +83,26 @@ export class Transaction extends BaseTransaction { txData.destinationTag = this._xrpTransaction.DestinationTag; return txData; - case XrpTransactionType.Payment: - txData.destination = this._xrpTransaction.Destination; - txData.destinationTag = this._xrpTransaction.DestinationTag; - if ( - typeof this._xrpTransaction.Amount === 'string' || - utils.isIssuedCurrencyAmount(this._xrpTransaction.Amount) - ) { - txData.amount = this._xrpTransaction.Amount; + case XrpTransactionType.Payment: { + const paymentTx = this._xrpTransaction as xrpl.Payment; + txData.destination = paymentTx.Destination; + txData.destinationTag = paymentTx.DestinationTag; + const paymentAmount = paymentTx.Amount; + if (typeof paymentAmount === 'string' || utils.isIssuedCurrencyAmount(paymentAmount)) { + txData.amount = paymentAmount as xrpl.Amount; + } else if (utils.isMPTAmount(paymentAmount)) { + txData.mptAmount = paymentAmount; } else { throw new InvalidTransactionError('Invalid amount'); } return txData; + } + + case XrpTransactionType.MPTokenAuthorize: { + const mpTx = this._xrpTransaction as MPTokenAuthorize; + txData.mptIssuanceId = mpTx.MPTokenIssuanceID; + return txData; + } case XrpTransactionType.AccountSet: txData.setFlag = this._xrpTransaction.SetFlag; @@ -152,7 +160,8 @@ export class Transaction extends BaseTransaction { const signablePayload = this.getSignablePayload(); const xrpWallet = new xrpl.Wallet(pub, prv); - const signedTx = xrpWallet.sign(signablePayload, this._isMultiSig); + // Cast needed: MPTokenAuthorize is not in xrpl 4.6.0's Transaction union yet + const signedTx = xrpWallet.sign(signablePayload as unknown as xrpl.Transaction, this._isMultiSig); const xrpSignedTx = xrpl.decode(signedTx.tx_blob); xrpl.validate(xrpSignedTx); @@ -169,7 +178,7 @@ export class Transaction extends BaseTransaction { const sortedSigners = this.concatAndSortSigners(this._xrpTransaction.Signers || [], xrpSignedTx.Signers); this._xrpTransaction = xrpSignedTx as unknown as XrpTransaction; this._xrpTransaction.Signers = sortedSigners; - this._id = this.calculateIdFromRawTx(xrpl.encode(this._xrpTransaction)); + this._id = this.calculateIdFromRawTx(xrpl.encode(this._xrpTransaction as unknown as xrpl.Transaction)); } } } @@ -179,7 +188,7 @@ export class Transaction extends BaseTransaction { if (!this._xrpTransaction) { throw new InvalidTransactionError('Empty transaction'); } - return xrpl.encode(this._xrpTransaction); + return xrpl.encode(this._xrpTransaction as unknown as xrpl.Transaction); } explainTransaction(): TransactionExplanation { @@ -192,11 +201,29 @@ export class Transaction extends BaseTransaction { return this.explainAccountSetTransaction(); case XrpTransactionType.SignerListSet: return this.explainSignerListSetTransaction(); + case XrpTransactionType.MPTokenAuthorize: + return this.explainMPTokenAuthorizeTransaction(); default: throw new Error('Unsupported transaction type'); } } + private explainMPTokenAuthorizeTransaction(): BaseTransactionExplanation { + const tx = this._xrpTransaction as MPTokenAuthorize; + return { + displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'], + id: this._id as string, + changeOutputs: [], + outputAmount: 0, + changeAmount: 0, + outputs: [], + fee: { + fee: tx.Fee as string, + feeRate: undefined, + }, + }; + } + private explainAccountDeleteTransaction(): BaseTransactionExplanation { const tx = this._xrpTransaction as xrpl.AccountDelete; const address = utils.normalizeAddress({ address: tx.Destination, destinationTag: tx.DestinationTag }); @@ -223,7 +250,14 @@ export class Transaction extends BaseTransaction { private explainPaymentTransaction(): BaseTransactionExplanation { const tx = this._xrpTransaction as xrpl.Payment; const address = utils.normalizeAddress({ address: tx.Destination, destinationTag: tx.DestinationTag }); - const amount = _.isString(tx.Amount) ? tx.Amount : 0; + let amount: string | number; + if (_.isString(tx.Amount)) { + amount = tx.Amount; + } else if (utils.isMPTAmount(tx.Amount)) { + amount = tx.Amount.value; + } else { + amount = (tx.Amount as xrpl.IssuedCurrencyAmount).value; + } return { displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee'], @@ -234,7 +268,7 @@ export class Transaction extends BaseTransaction { outputs: [ { address, - amount, + amount: String(amount), }, ], fee: { @@ -351,16 +385,23 @@ export class Transaction extends BaseTransaction { case XrpTransactionType.AccountSet: this.setTransactionType(TransactionType.AccountUpdate); break; - case XrpTransactionType.Payment: - if (utils.isIssuedCurrencyAmount(this._xrpTransaction.Amount)) { + case XrpTransactionType.Payment: { + const paymentTx = this._xrpTransaction as xrpl.Payment; + if (utils.isMPTAmount(paymentTx.Amount)) { + this.setTransactionType(TransactionType.SendMPT); + } else if (utils.isIssuedCurrencyAmount(paymentTx.Amount)) { this.setTransactionType(TransactionType.SendToken); } else { this.setTransactionType(TransactionType.Send); } break; + } case XrpTransactionType.TrustSet: this.setTransactionType(TransactionType.TrustLine); break; + case XrpTransactionType.MPTokenAuthorize: + this.setTransactionType(TransactionType.MPTokenAuthorize); + break; } this.loadInputsAndOutputs(); } @@ -388,18 +429,17 @@ export class Transaction extends BaseTransaction { } if (this._xrpTransaction.TransactionType === XrpTransactionType.Payment) { + const paymentTx = this._xrpTransaction as xrpl.Payment; + const { Account, Destination, Amount, DestinationTag } = paymentTx; let value: string; - const { Account, Destination, Amount, DestinationTag } = this._xrpTransaction; if (typeof Amount === 'string') { value = Amount; - } else { + } else if (utils.isMPTAmount(Amount)) { value = Amount.value; + } else { + value = (Amount as xrpl.IssuedCurrencyAmount).value; } - this.inputs.push({ - address: Account, - value, - coin, - }); + this.inputs.push({ address: Account, value, coin }); this.outputs.push({ address: utils.normalizeAddress({ address: Destination, destinationTag: DestinationTag }), value, diff --git a/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts index ed726c168d..99a9ac6cf1 100644 --- a/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-xrp/src/lib/transactionBuilderFactory.ts @@ -3,6 +3,8 @@ import { BaseCoin as CoinConfig } from '@bitgo/statics'; import xrpl from 'xrpl'; import { AccountDeleteBuilder } from './accountDeleteBuilder'; import { AccountSetBuilder } from './accountSetBuilder'; +import { MPTokenAuthorizeBuilder } from './mpTokenAuthorizeBuilder'; +import { MptTransferBuilder } from './mptTransferBuilder'; import { TokenTransferBuilder } from './tokenTransferBuilder'; import { Transaction } from './transaction'; import { TransactionBuilder } from './transactionBuilder'; @@ -41,6 +43,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.getTokenTransferBuilder(tx); case TransactionType.TrustLine: return this.getTrustSetBuilder(tx); + case TransactionType.MPTokenAuthorize: + return this.getMPTokenAuthorizeBuilder(tx); + case TransactionType.SendMPT: + return this.getMptTransferBuilder(tx); default: throw new InvalidTransactionError('Invalid transaction'); } @@ -79,6 +85,16 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new TrustSetBuilder(this._coinConfig)); } + /** @inheritdoc */ + public getMPTokenAuthorizeBuilder(tx?: Transaction): MPTokenAuthorizeBuilder { + return this.initializeBuilder(tx, new MPTokenAuthorizeBuilder(this._coinConfig)); + } + + /** @inheritdoc */ + public getMptTransferBuilder(tx?: Transaction): MptTransferBuilder { + return this.initializeBuilder(tx, new MptTransferBuilder(this._coinConfig)); + } + /** * Initialize the builder with the given transaction * diff --git a/modules/sdk-coin-xrp/src/lib/utils.ts b/modules/sdk-coin-xrp/src/lib/utils.ts index 3414e66d35..e9ba4302ea 100644 --- a/modules/sdk-coin-xrp/src/lib/utils.ts +++ b/modules/sdk-coin-xrp/src/lib/utils.ts @@ -10,7 +10,7 @@ import * as querystring from 'querystring'; import * as rippleKeypairs from 'ripple-keypairs'; import * as url from 'url'; import * as xrpl from 'xrpl'; -import { Amount, IssuedCurrencyAmount, MPTAmount } from 'xrpl'; +import { Amount, IssuedCurrencyAmount, isMPTAmount, MPTAmount } from 'xrpl'; import { VALID_ACCOUNT_SET_FLAGS } from './constants'; import { Address, SignerDetails } from './iface'; import { KeyPair as XrpKeyPair } from './keyPair'; @@ -221,13 +221,19 @@ class Utils implements BaseUtils { } /** - * Determines if the provided `amount` is for a trust-line token payment (IssuedCurrencyAmount). - * Uses `in` narrowing — safe for Amount | MPTAmount without unsafe casts. + * Determines if the provided `amount` is a trust-line token payment amount (IssuedCurrencyAmount). */ public isIssuedCurrencyAmount(amount: Amount | MPTAmount): amount is IssuedCurrencyAmount { return typeof amount === 'object' && 'currency' in amount && 'issuer' in amount && 'value' in amount; } + /** + * Determines if the provided `amount` is an MPT payment amount. + */ + public isMPTAmount(amount: Amount | MPTAmount): amount is MPTAmount { + return isMPTAmount(amount); + } + /** * Get the associated XRP Currency details from token name. Throws an error if token is unsupported * @param {string} tokenName - The token name diff --git a/modules/sdk-coin-xrp/test/resources/xrp.ts b/modules/sdk-coin-xrp/test/resources/xrp.ts index 5047090e14..821bb766a8 100644 --- a/modules/sdk-coin-xrp/test/resources/xrp.ts +++ b/modules/sdk-coin-xrp/test/resources/xrp.ts @@ -502,6 +502,14 @@ export const destAccountInfoNotFound = { }, }; +// ─── MPT test fixtures ──────────────────────────────────────────────────────── + +/** A valid 48-character hex MPTokenIssuanceID (192 bits) */ +export const MPT_ISSUANCE_ID = '00F633BCDD435DCB9EE57E47809EDE01BBB050679C488A97'; + +/** Raw integer value (no AssetScale applied) */ +export const MPT_AMOUNT_VALUE = '1000'; + // ───────────────────────────────────────────────────────────────────────────── export const enableTokenFixtures = { diff --git a/modules/sdk-coin-xrp/test/unit/getBuilderFactory.ts b/modules/sdk-coin-xrp/test/unit/getBuilderFactory.ts index fe5f8a733b..fbea7eda96 100644 --- a/modules/sdk-coin-xrp/test/unit/getBuilderFactory.ts +++ b/modules/sdk-coin-xrp/test/unit/getBuilderFactory.ts @@ -1,6 +1,34 @@ -import { coins } from '@bitgo/statics'; +import { coins, Networks, UnderlyingAsset, AccountCoin, xrpMptToken } from '@bitgo/statics'; import { TransactionBuilderFactory } from '../../src/lib/transactionBuilderFactory'; export const getBuilderFactory = (coin: string): TransactionBuilderFactory => { return new TransactionBuilderFactory(coins.get(coin)); }; + +/** + * Returns a TransactionBuilderFactory backed by a dummy XrpMptCoin for unit tests. + * Instantiates the coin directly — no registration in allCoinsAndTokens.ts required. + */ +export const getMptBuilderFactory = ( + mptIssuanceId: string, + network: 'mainnet' | 'testnet' = 'testnet' +): TransactionBuilderFactory => { + const xrplNetwork = network === 'mainnet' ? Networks.main.xrp : Networks.test.xrp; + // Placeholder — real MPT tokens use their own UnderlyingAsset entry added during onboarding. + const underlyingAsset = network === 'mainnet' ? UnderlyingAsset['xrp:rlusd'] : UnderlyingAsset['txrp:rlusd']; + + const coinConfig = xrpMptToken( + 'b2902479-27f2-4fc8-83b4-5549cd75dc40', + network === 'mainnet' ? 'xrp:test-mpt' : 'txrp:test-mpt', + 'Test MPT Token', + mptIssuanceId, + true, // canTransfer + 2, // assetScale — raw 1000 displays as 10.00 + underlyingAsset, + AccountCoin.DEFAULT_FEATURES, + '', + 'TEST-MPT', + xrplNetwork + ); + return new TransactionBuilderFactory(coinConfig); +}; diff --git a/modules/sdk-coin-xrp/test/unit/transactionBuilder/mpTokenAuthorizeBuilder.ts b/modules/sdk-coin-xrp/test/unit/transactionBuilder/mpTokenAuthorizeBuilder.ts new file mode 100644 index 0000000000..53553921fc --- /dev/null +++ b/modules/sdk-coin-xrp/test/unit/transactionBuilder/mpTokenAuthorizeBuilder.ts @@ -0,0 +1,179 @@ +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import utils from '../../../src/lib/utils'; +import * as testData from '../../resources/xrp'; +import { getMptBuilderFactory } from '../getBuilderFactory'; + +describe('XRP MPTokenAuthorize Builder', () => { + const factory = getMptBuilderFactory(testData.MPT_ISSUANCE_ID); + + const sender = utils.getAddressDetails(testData.TEST_MULTI_SIG_ACCOUNT.address).address; + + describe('build', () => { + it('should build a valid MPTokenAuthorize transaction', async () => { + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.sender(sender); + builder.mptIssuanceId(testData.MPT_ISSUANCE_ID); + builder.sequence(1600000); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + const rawTx = tx.toBroadcastFormat(); + + should.equal(utils.isValidRawTransaction(rawTx), true); + + const txJson = tx.toJson(); + txJson.transactionType.should.equal('MPTokenAuthorize'); + txJson.from.should.equal(sender); + should.exist(txJson.mptIssuanceId); + (txJson.mptIssuanceId as string).toUpperCase().should.equal(testData.MPT_ISSUANCE_ID.toUpperCase()); + should.not.exist(txJson.mptAmount); + }); + + it('should not include Holder field for holder self-authorization', async () => { + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.sender(sender); + builder.mptIssuanceId(testData.MPT_ISSUANCE_ID); + builder.sequence(1600000); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + // Verify via toJson — Holder is Phase 2 only, absent for self-auth + const txJson = tx.toJson(); + should.not.exist((txJson as Record).holder); + should.equal(utils.isValidRawTransaction(tx.toBroadcastFormat()), true); + }); + + it('should include Holder field when set (issuer-side authorization)', async () => { + const holderAddress = testData.TEST_SINGLE_SIG_ACCOUNT.address; + + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.sender(sender); + builder.mptIssuanceId(testData.MPT_ISSUANCE_ID); + builder.mptHolder(holderAddress); + builder.sequence(1600001); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + // The transaction must be serializable; Holder presence is verified via internal state + should.equal(utils.isValidRawTransaction(tx.toBroadcastFormat()), true); + }); + + it('should build a zero-value authorization (no amount field)', async () => { + // MPTokenAuthorize carries no amount — the holder's initial MPTAmount is + // implicitly zero on-chain (XRPL sparse encoding omits zero-value fields). + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.sender(sender); + builder.mptIssuanceId(testData.MPT_ISSUANCE_ID); + builder.sequence(1600000); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + const txJson = tx.toJson(); + + should.not.exist(txJson.mptAmount); + should.not.exist((txJson as Record).amount); + should.equal(utils.isValidRawTransaction(tx.toBroadcastFormat()), true); + }); + + it('should set the correct internal TransactionType', async () => { + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.sender(sender); + builder.mptIssuanceId(testData.MPT_ISSUANCE_ID); + builder.sequence(1600000); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + tx.type.should.equal(TransactionType.MPTokenAuthorize); + }); + }); + + describe('round-trip: build → serialize → deserialize', () => { + it('should rebuild correctly from raw unsigned transaction', async () => { + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.sender(sender); + builder.mptIssuanceId(testData.MPT_ISSUANCE_ID); + builder.sequence(1600000); + 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 rebuiltJson = rebuiltTx.toJson(); + + rebuiltTx.type.should.equal(TransactionType.MPTokenAuthorize); + rebuiltJson.transactionType.should.equal('MPTokenAuthorize'); + rebuiltJson.from.should.equal(sender); + should.exist(rebuiltJson.mptIssuanceId); + (rebuiltJson.mptIssuanceId as string).toUpperCase().should.equal(testData.MPT_ISSUANCE_ID.toUpperCase()); + }); + + it('should sign correctly after rebuild (multi-sig)', async () => { + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.sender(sender); + builder.mptIssuanceId(testData.MPT_ISSUANCE_ID); + builder.sequence(1600000); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + const rawTx = tx.toBroadcastFormat(); + + const rebuilder = factory.from(rawTx); + rebuilder.setMultiSig(); + rebuilder.sign({ key: testData.SIGNER_USER.prv }); + rebuilder.sign({ key: testData.SIGNER_BITGO.prv }); + const signedTx = await rebuilder.build(); + + should.equal(utils.isValidRawTransaction(signedTx.toBroadcastFormat()), true); + }); + }); + + describe('input validation', () => { + it('should throw if sender is missing', async () => { + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.mptIssuanceId(testData.MPT_ISSUANCE_ID); + builder.sequence(1600000); + builder.fee('12'); + builder.flags(2147483648); + + await builder.build().should.be.rejectedWith(/missing sender/); + }); + + it('should throw if MPTokenIssuanceID is missing', async () => { + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.sender(sender); + builder.sequence(1600000); + builder.fee('12'); + builder.flags(2147483648); + + await builder.build().should.be.rejectedWith(/MPTokenIssuanceID must be set/); + }); + + it('should throw if MPTokenIssuanceID is fewer than 48 hex chars', () => { + const builder = factory.getMPTokenAuthorizeBuilder(); + should(() => builder.mptIssuanceId('00AABB')).throw(/48-character hex/); + }); + + it('should throw if MPTokenIssuanceID contains non-hex characters', () => { + const builder = factory.getMPTokenAuthorizeBuilder(); + // 48 chars but not valid hex + const notHex = 'ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ'; + should(() => builder.mptIssuanceId(notHex)).throw(/48-character hex/); + }); + + it('should throw if MPTokenIssuanceID is more than 48 hex chars', () => { + const builder = factory.getMPTokenAuthorizeBuilder(); + // Valid hex, but 50 chars + should(() => builder.mptIssuanceId(testData.MPT_ISSUANCE_ID + 'FF')).throw(/48-character hex/); + }); + }); +}); diff --git a/modules/sdk-coin-xrp/test/unit/transactionBuilder/mptTransferBuilder.ts b/modules/sdk-coin-xrp/test/unit/transactionBuilder/mptTransferBuilder.ts new file mode 100644 index 0000000000..3849d56f16 --- /dev/null +++ b/modules/sdk-coin-xrp/test/unit/transactionBuilder/mptTransferBuilder.ts @@ -0,0 +1,207 @@ +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import utils from '../../../src/lib/utils'; +import * as testData from '../../resources/xrp'; +import { getMptBuilderFactory } from '../getBuilderFactory'; + +describe('XRP MptTransfer Builder', () => { + const factory = getMptBuilderFactory(testData.MPT_ISSUANCE_ID); + + const sender = utils.getAddressDetails(testData.TEST_MULTI_SIG_ACCOUNT.address).address; + const destination = testData.TEST_SINGLE_SIG_ACCOUNT.address; + + describe('build', () => { + it('should build a valid MPT Payment transaction', 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 rawTx = tx.toBroadcastFormat(); + + should.equal(utils.isValidRawTransaction(rawTx), true); + + const txJson = tx.toJson(); + txJson.transactionType.should.equal('Payment'); + txJson.from.should.equal(sender); + txJson.destination.should.equal(destination); + should.exist(txJson.mptAmount); + (txJson.mptAmount as { mpt_issuance_id: string; value: string }).mpt_issuance_id + .toUpperCase() + .should.equal(testData.MPT_ISSUANCE_ID.toUpperCase()); + (txJson.mptAmount as { mpt_issuance_id: string; value: string }).value.should.equal(testData.MPT_AMOUNT_VALUE); + }); + + it('should set the correct internal TransactionType to SendMPT', 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(); + tx.type.should.equal(TransactionType.SendMPT); + }); + + it('should preserve DestinationTag when included in the address', async () => { + const destinationWithTag = `${destination}?dt=42`; + + const builder = factory.getMptTransferBuilder(); + builder.sender(sender); + builder.to(destinationWithTag); + builder.mptAmount(testData.MPT_ISSUANCE_ID, testData.MPT_AMOUNT_VALUE); + builder.sequence(1600011); + builder.fee('12'); + builder.flags(2147483648); + + const tx = await builder.build(); + const txJson = tx.toJson(); + + txJson.destination.should.equal(destination); + txJson.destinationTag.should.equal(42); + }); + + it('should not set XRP amount field on an MPT Payment', 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 txJson = tx.toJson(); + + // The XRP/IOU `amount` field must be absent for an MPT Payment + should.not.exist(txJson.amount); + }); + }); + + describe('round-trip: build → serialize → deserialize', () => { + it('should rebuild correctly from raw unsigned transaction', 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 rawTx = tx.toBroadcastFormat(); + + const rebuilder = factory.from(rawTx); + const rebuiltTx = await rebuilder.build(); + const rebuiltJson = rebuiltTx.toJson(); + + rebuiltTx.type.should.equal(TransactionType.SendMPT); + rebuiltJson.from.should.equal(sender); + rebuiltJson.destination.should.equal(destination); + should.exist(rebuiltJson.mptAmount); + (rebuiltJson.mptAmount as { mpt_issuance_id: string; value: string }).mpt_issuance_id + .toUpperCase() + .should.equal(testData.MPT_ISSUANCE_ID.toUpperCase()); + (rebuiltJson.mptAmount as { mpt_issuance_id: string; value: string }).value.should.equal( + testData.MPT_AMOUNT_VALUE + ); + }); + + it('should sign correctly after rebuild (multi-sig)', 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 rawTx = tx.toBroadcastFormat(); + + const rebuilder = factory.from(rawTx); + rebuilder.setMultiSig(); + rebuilder.sign({ key: testData.SIGNER_USER.prv }); + rebuilder.sign({ key: testData.SIGNER_BITGO.prv }); + const signedTx = await rebuilder.build(); + + should.equal(utils.isValidRawTransaction(signedTx.toBroadcastFormat()), true); + }); + }); + + describe('input validation', () => { + it('should throw if sender is missing', async () => { + const builder = factory.getMptTransferBuilder(); + builder.to(destination); + builder.mptAmount(testData.MPT_ISSUANCE_ID, testData.MPT_AMOUNT_VALUE); + builder.sequence(1600010); + builder.fee('12'); + builder.flags(2147483648); + + await builder.build().should.be.rejectedWith(/missing sender/); + }); + + it('should throw if destination is missing', async () => { + const builder = factory.getMptTransferBuilder(); + builder.sender(sender); + builder.mptAmount(testData.MPT_ISSUANCE_ID, testData.MPT_AMOUNT_VALUE); + builder.sequence(1600010); + builder.fee('12'); + builder.flags(2147483648); + + await builder.build().should.be.rejectedWith(/Missing mandatory MPT payment parameters/); + }); + + it('should throw if mptAmount is not set', async () => { + const builder = factory.getMptTransferBuilder(); + builder.sender(sender); + builder.to(destination); + builder.sequence(1600010); + builder.fee('12'); + builder.flags(2147483648); + + await builder.build().should.be.rejectedWith(/Missing mandatory MPT payment parameters/); + }); + + it('should throw if MPTokenIssuanceID is not 48-char hex', () => { + const builder = factory.getMptTransferBuilder(); + should(() => builder.mptAmount('TOOSHORT', testData.MPT_AMOUNT_VALUE)).throw(/48-character hex/); + }); + + it('should throw if mptAmount value is not an integer string', () => { + const builder = factory.getMptTransferBuilder(); + should(() => builder.mptAmount(testData.MPT_ISSUANCE_ID, '1.5')).throw(/non-negative integer/); + }); + + it('should throw if mptAmount value contains non-numeric characters', () => { + const builder = factory.getMptTransferBuilder(); + should(() => builder.mptAmount(testData.MPT_ISSUANCE_ID, 'abc')).throw(/non-negative integer/); + }); + }); + + describe('explainTransaction', () => { + it('should return explanation with outputs for MPT transfer', 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 explanation = tx.explainTransaction(); + + explanation.should.have.property('displayOrder'); + explanation.outputs.length.should.equal(1); + explanation.outputs[0].address.should.equal(destination); + explanation.outputs[0].amount.should.equal(testData.MPT_AMOUNT_VALUE); + }); + }); +}); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index a15789799d..4b45b6ef03 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -142,6 +142,10 @@ export enum TransactionType { // xrp — delete an account and recover the full balance including reserve AccountDelete, + // xrp — opt-in to receive a Multi-Purpose Token (MPTokenAuthorize) + MPTokenAuthorize, + // xrp — send a Multi-Purpose Token via Payment with MPT Amount object + SendMPT, // Delegate decryption access for Zama ERC-7984 confidential tokens via ACL contract DecryptionDelegation, }