From a580fa8f3b0e933b40cb92d42e9604d97cf6bb0f Mon Sep 17 00:00:00 2001 From: Himanshu Singroha Date: Mon, 22 Jun 2026 12:54:23 +0530 Subject: [PATCH] feat(sdk-coin-xrp): support MPToken via xrpl codec and enableMpt wallet type Ticket: CGD-1827 TICKET: CGD-1827 --- modules/sdk-coin-xrp/src/ripple.ts | 6 +- modules/sdk-coin-xrp/src/xrp.ts | 5 +- modules/sdk-coin-xrp/test/unit/xrp.ts | 36 ++++++++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 5 +- .../test/unit/bitgo/wallet/tokenApproval.ts | 82 +++++++++++++++++++ 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/modules/sdk-coin-xrp/src/ripple.ts b/modules/sdk-coin-xrp/src/ripple.ts index 22e87aca35..8c3b5796ec 100644 --- a/modules/sdk-coin-xrp/src/ripple.ts +++ b/modules/sdk-coin-xrp/src/ripple.ts @@ -9,7 +9,9 @@ import * as xrpl from 'xrpl'; import { ECPair } from '@bitgo/secp256k1'; import BigNumber from 'bignumber.js'; -import * as binary from 'ripple-binary-codec'; +// xrpl re-exports ripple-binary-codec@2.7.0 which supports MPTokenAuthorize. +// The standalone ripple-binary-codec dep is 2.1.0 (pre-MPT), so use xrpl as the codec. +const binary = xrpl; /** * Convert an XRP address to a BigNumber for numeric comparison. @@ -24,7 +26,7 @@ function addressToBigNumber(address: string): BigNumber { } function computeSignature(tx, privateKey, signAs) { - const signingData = signAs ? binary.encodeForMultisigning(tx, signAs) : binary.encodeForSigning(tx); + const signingData = signAs ? binary.encodeForMultiSigning(tx, signAs) : binary.encodeForSigning(tx); return rippleKeypairs.sign(signingData, privateKey); } diff --git a/modules/sdk-coin-xrp/src/xrp.ts b/modules/sdk-coin-xrp/src/xrp.ts index e88cf802d5..cb68519517 100644 --- a/modules/sdk-coin-xrp/src/xrp.ts +++ b/modules/sdk-coin-xrp/src/xrp.ts @@ -26,10 +26,13 @@ import { VerifyTransactionOptions, } from '@bitgo/sdk-core'; import { coins, BaseCoin as StaticsBaseCoin, XrpCoin } from '@bitgo/statics'; -import * as rippleBinaryCodec from 'ripple-binary-codec'; import * as rippleKeypairs from 'ripple-keypairs'; import * as xrpl from 'xrpl'; +// xrpl re-exports ripple-binary-codec@2.7.0 which supports MPTokenAuthorize. +// The standalone ripple-binary-codec dep is 2.1.0 (pre-MPT), so use xrpl as the codec. +const rippleBinaryCodec = xrpl; + import { AccountDeleteBuilder, TokenTransferBuilder, TransactionBuilderFactory, TransferBuilder } from './lib'; import { ExplainTransactionOptions, diff --git a/modules/sdk-coin-xrp/test/unit/xrp.ts b/modules/sdk-coin-xrp/test/unit/xrp.ts index b343cc95a3..a514febaaa 100644 --- a/modules/sdk-coin-xrp/test/unit/xrp.ts +++ b/modules/sdk-coin-xrp/test/unit/xrp.ts @@ -15,6 +15,7 @@ import * as xrpl from 'xrpl'; import { XrpToken } from '../../src'; import * as testData from '../resources/xrp'; import { SIGNER_BACKUP, SIGNER_BITGO, SIGNER_USER } from '../resources/xrp'; +import { getMptBuilderFactory } from './getBuilderFactory'; nock.disableNetConnect(); @@ -202,6 +203,41 @@ describe('XRP:', function () { (signedTransaction.Signers as Array).length.should.equal(2); }); + it('should multi-sign an MPTokenAuthorize transaction using the xrpl codec (encodeForMultiSigning)', async function () { + // Build an unsigned MPTokenAuthorize tx using the builder so we get a real XRPL-encoded hex. + // This exercises the ripple.ts encodeForMultiSigning path via the xrpl codec (v2.7.0) + // which supports the MPTokenAuthorize transaction type absent in ripple-binary-codec v2.1.0. + const factory = getMptBuilderFactory(testData.MPT_ISSUANCE_ID); + const sender = testData.TEST_MULTI_SIG_ACCOUNT.address.split('?')[0]; // strip destination tag + + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.sender(sender); + builder.mptIssuanceId(testData.MPT_ISSUANCE_ID); + builder.sequence(1600000); + builder.fee('12'); + builder.flags(2147483648); + + const unsignedTx = await builder.build(); + const unsignedHex = unsignedTx.toBroadcastFormat(); + + // Sign with first signer + const firstSigned = ripple.signWithPrivateKey(unsignedHex, SIGNER_USER.prv, { + signAs: SIGNER_USER.address, + }); + + // Add second signature + const fullySigned = ripple.signWithPrivateKey(firstSigned.signedTransaction, SIGNER_BITGO.prv, { + signAs: SIGNER_BITGO.address, + }); + + // Must use xrpl.decode (ripple-binary-codec v2.7.0) — the standalone + // ripple-binary-codec v2.1.0 doesn't know the MPTokenAuthorize type. + const decoded = xrpl.decode(fullySigned.signedTransaction); + (decoded.TransactionType as string).should.equal('MPTokenAuthorize'); + assert(Array.isArray(decoded.Signers)); + (decoded.Signers as Array).length.should.equal(2); + }); + it('should be able to cosign XRP transaction in any form', function () { const unsignedTxHex = '120000228000000024000000072E00000000201B0018D07161400000000003DE2968400000000000002D8114726D0D8A26568D5D9680AC80577C912236717191831449EE221CCACC4DD2BF8862B22B0960A84FC771D9'; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 8909f33e34..7a26884cf6 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -4058,7 +4058,10 @@ export class Wallet implements IWallet { teConfig.validateWallet(this._wallet.type); } - if (typeof params.prebuildTx === 'string' || params.prebuildTx?.buildParams?.type !== 'enabletoken') { + if ( + typeof params.prebuildTx === 'string' || + (params.prebuildTx?.buildParams?.type !== 'enabletoken' && params.prebuildTx?.buildParams?.type !== 'enableMpt') + ) { throw new Error('Invalid build of token enablement.'); } diff --git a/modules/sdk-core/test/unit/bitgo/wallet/tokenApproval.ts b/modules/sdk-core/test/unit/bitgo/wallet/tokenApproval.ts index b75db88ddc..7ccd1ae474 100644 --- a/modules/sdk-core/test/unit/bitgo/wallet/tokenApproval.ts +++ b/modules/sdk-core/test/unit/bitgo/wallet/tokenApproval.ts @@ -150,4 +150,86 @@ describe('Wallet - Token Approval', function () { await wallet.buildErc20TokenApproval('USDC', 'passphrase123').should.be.rejectedWith('signing error'); }); }); + + describe('sendTokenEnablement', function () { + let teWallet: Wallet; + let teBaseCoin: any; + let teBitGo: any; + + beforeEach(function () { + teBitGo = { + post: sinon.stub(), + get: sinon.stub(), + setRequestTracer: sinon.stub(), + }; + + teBaseCoin = { + getFamily: sinon.stub().returns('txrp'), + getFullName: sinon.stub().returns('Testnet XRP'), + url: sinon.stub(), + keychains: sinon.stub(), + supportsTss: sinon.stub().returns(false), + getMPCAlgorithm: sinon.stub(), + getTokenEnablementConfig: sinon.stub().returns({ requiresTokenEnablement: true }), + }; + + // custodial wallet so the path after validation calls initiateTransaction + const walletData = { + id: 'te-wallet-id', + coin: 'txrp', + type: 'custodial', + keys: ['user-key', 'backup-key', 'bitgo-key'], + }; + + teWallet = new Wallet(teBitGo, teBaseCoin, walletData); + }); + + it('should throw "Invalid build of token enablement." when prebuildTx is a string', async function () { + await teWallet + .sendTokenEnablement({ prebuildTx: 'raw-hex-string' as any }) + .should.be.rejectedWith('Invalid build of token enablement.'); + }); + + it('should throw "Invalid build of token enablement." when buildParams.type is undefined', async function () { + await teWallet + .sendTokenEnablement({ prebuildTx: { buildParams: {} } as any }) + .should.be.rejectedWith('Invalid build of token enablement.'); + }); + + it('should throw "Invalid build of token enablement." when buildParams.type is an unrecognised type', async function () { + await teWallet + .sendTokenEnablement({ prebuildTx: { buildParams: { type: 'transfer' } } as any }) + .should.be.rejectedWith('Invalid build of token enablement.'); + }); + + it('should pass validation and proceed when buildParams.type is "enabletoken"', async function () { + const initiateStub = sinon.stub(teWallet as any, 'initiateTransaction').resolves({ txid: 'abc123' }); + + const result = await teWallet.sendTokenEnablement({ + prebuildTx: { buildParams: { type: 'enabletoken' } } as any, + }); + + result.should.eql({ txid: 'abc123' }); + sinon.assert.calledOnce(initiateStub); + }); + + it('should pass validation and proceed when buildParams.type is "enableMpt"', async function () { + const initiateStub = sinon.stub(teWallet as any, 'initiateTransaction').resolves({ txid: 'mpt456' }); + + const result = await teWallet.sendTokenEnablement({ + prebuildTx: { buildParams: { type: 'enableMpt' } } as any, + }); + + result.should.eql({ txid: 'mpt456' }); + sinon.assert.calledOnce(initiateStub); + }); + + it('should throw when the coin does not require token enablement', async function () { + teBaseCoin.getTokenEnablementConfig.returns({ requiresTokenEnablement: false }); + + await teWallet + .sendTokenEnablement({ prebuildTx: { buildParams: { type: 'enableMpt' } } as any }) + .should.be.rejectedWith(/does not require token enablement transactions/); + }); + }); });