diff --git a/modules/sdk-coin-xrp/src/lib/iface.ts b/modules/sdk-coin-xrp/src/lib/iface.ts index 94ab683a01..b071f5ae7b 100644 --- a/modules/sdk-coin-xrp/src/lib/iface.ts +++ b/modules/sdk-coin-xrp/src/lib/iface.ts @@ -104,7 +104,8 @@ export type TransactionExplanation = | BaseTransactionExplanation | AccountSetTransactionExplanation | TrustSetTransactionExplanation - | SignerListSetTransactionExplanation; + | SignerListSetTransactionExplanation + | MPTokenAuthorizeTransactionExplanation; export interface AccountSetTransactionExplanation extends BaseTransactionExplanation { accountSet: { @@ -129,6 +130,11 @@ export interface SignerListSetTransactionExplanation extends BaseTransactionExpl }; } +export interface MPTokenAuthorizeTransactionExplanation extends BaseTransactionExplanation { + mptIssuanceId: string; + mptHolder?: string; +} + export interface TxData { // mandatory fields from: string; 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..31818992b2 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, @@ -276,6 +279,22 @@ export class Xrp extends BaseCoin { value: transaction.LimitAmount.value, }, }; + } else if (transaction.TransactionType === 'MPTokenAuthorize') { + return { + displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'mptIssuanceId'], + id: id, + changeOutputs: [], + outputAmount: 0, + changeAmount: 0, + outputs: [], + fee: { + fee: transaction.Fee, + feeRate: undefined, + size: txHex.length / 2, + }, + mptIssuanceId: transaction.MPTokenIssuanceID, + ...(transaction.MPTHolder !== undefined && { mptHolder: transaction.MPTHolder }), + }; } const address = @@ -337,6 +356,18 @@ export class Xrp extends BaseCoin { throw new Error(`tx type ${actualTypeFromDecoded} does not match the expected type enabletoken`); } + verifyMptTxType(txPrebuildDecoded: TransactionExplanation, txHexPrebuild: string | undefined): void { + if (!txHexPrebuild) throw new Error('Missing txHexPrebuild to verify token type for enableMpt tx'); + const transactionType = this.getTransactionTypeRawTxHex(txHexPrebuild); + if (transactionType === undefined) throw new Error('Missing TransactionType on MPT enablement tx'); + if (transactionType !== XrpTransactionType.MPTokenAuthorize) + throw new Error(`tx type ${transactionType} does not match expected type MPTokenAuthorize`); + const actualTypeFromDecoded = + 'type' in txPrebuildDecoded && typeof txPrebuildDecoded.type === 'string' ? txPrebuildDecoded.type : undefined; + if (!actualTypeFromDecoded || actualTypeFromDecoded === 'enableMpt') return; + throw new Error(`tx type ${actualTypeFromDecoded} does not match the expected type enableMpt`); + } + verifyTokenName( txParams: TransactionParams, txPrebuildDecoded: TransactionExplanation, @@ -415,6 +446,15 @@ export class Xrp extends BaseCoin { this.verifyRequiredKeys(txParams, explanation); } + if (txParams.type === 'enableMpt') { + if (verification?.verifyTokenEnablement) { + this.verifyMptTxType(explanation, txPrebuild.txHex); + } + // MPTokenAuthorize transactions have no destination or amount; + // the output comparator below does not apply. + return true; + } + const output = [...explanation.outputs, ...explanation.changeOutputs][0]; const expectedOutput = txParams.recipients && txParams.recipients[0]; diff --git a/modules/sdk-coin-xrp/test/unit/xrp.ts b/modules/sdk-coin-xrp/test/unit/xrp.ts index b343cc95a3..8a8807293c 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'; @@ -965,5 +1001,41 @@ describe('XRP:', function () { { message: 'Invalid token issuer or currency on token enablement tx' } ); }); + + it('should pass verifyMptTxType for a valid MPTokenAuthorize prebuild', async function () { + const factory = getMptBuilderFactory(testData.MPT_ISSUANCE_ID); + const sender = testData.TEST_MULTI_SIG_ACCOUNT.address.split('?')[0]; + + const builder = factory.getMPTokenAuthorizeBuilder(); + builder.sender(sender); + builder.mptIssuanceId(testData.MPT_ISSUANCE_ID); + builder.sequence(1600000); + builder.fee('12'); + builder.flags(2147483648); + + const txHex = (await builder.build()).toBroadcastFormat(); + + const result = await basecoin.verifyTransaction({ + txParams: { type: 'enableMpt' }, + txPrebuild: { txHex }, + verification: { verifyTokenEnablement: true }, + }); + result.should.equal(true); + }); + + it('should reject enableMpt when on-chain type is not MPTokenAuthorize', async function () { + // TrustSet hex — wrong on-chain type for an enableMpt intent + const txHex = `{"TransactionType":"TrustSet","Account":"rBSpCz8PafXTJHppDcNnex7dYnbe3tSuFG","LimitAmount":{"currency":"524C555344000000000000000000000000000000","issuer":"rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV","value":"99999999"},"Flags":2147483648,"Fee":"45","Sequence":7}`; + + await assert.rejects( + async () => + basecoin.verifyTransaction({ + txParams: { type: 'enableMpt' }, + txPrebuild: { txHex }, + verification: { verifyTokenEnablement: true }, + }), + { message: 'tx type TrustSet does not match expected type MPTokenAuthorize' } + ); + }); }); }); 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/); + }); + }); });