Skip to content

Commit 43fa087

Browse files
Merge pull request #9073 from BitGo/CGD-1827
feat(sdk-coin-xrp): support MPToken via xrpl codec and enableMpt
2 parents fd8929d + 87964d0 commit 43fa087

6 files changed

Lines changed: 210 additions & 5 deletions

File tree

modules/sdk-coin-xrp/src/lib/iface.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ export type TransactionExplanation =
104104
| BaseTransactionExplanation
105105
| AccountSetTransactionExplanation
106106
| TrustSetTransactionExplanation
107-
| SignerListSetTransactionExplanation;
107+
| SignerListSetTransactionExplanation
108+
| MPTokenAuthorizeTransactionExplanation;
108109

109110
export interface AccountSetTransactionExplanation extends BaseTransactionExplanation {
110111
accountSet: {
@@ -129,6 +130,11 @@ export interface SignerListSetTransactionExplanation extends BaseTransactionExpl
129130
};
130131
}
131132

133+
export interface MPTokenAuthorizeTransactionExplanation extends BaseTransactionExplanation {
134+
mptIssuanceId: string;
135+
mptHolder?: string;
136+
}
137+
132138
export interface TxData {
133139
// mandatory fields
134140
from: string;

modules/sdk-coin-xrp/src/ripple.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import * as xrpl from 'xrpl';
99
import { ECPair } from '@bitgo/secp256k1';
1010
import BigNumber from 'bignumber.js';
1111

12-
import * as binary from 'ripple-binary-codec';
12+
// xrpl re-exports ripple-binary-codec@2.7.0 which supports MPTokenAuthorize.
13+
// The standalone ripple-binary-codec dep is 2.1.0 (pre-MPT), so use xrpl as the codec.
14+
const binary = xrpl;
1315

1416
/**
1517
* Convert an XRP address to a BigNumber for numeric comparison.
@@ -24,7 +26,7 @@ function addressToBigNumber(address: string): BigNumber {
2426
}
2527

2628
function computeSignature(tx, privateKey, signAs) {
27-
const signingData = signAs ? binary.encodeForMultisigning(tx, signAs) : binary.encodeForSigning(tx);
29+
const signingData = signAs ? binary.encodeForMultiSigning(tx, signAs) : binary.encodeForSigning(tx);
2830
return rippleKeypairs.sign(signingData, privateKey);
2931
}
3032

modules/sdk-coin-xrp/src/xrp.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@ import {
2626
VerifyTransactionOptions,
2727
} from '@bitgo/sdk-core';
2828
import { coins, BaseCoin as StaticsBaseCoin, XrpCoin } from '@bitgo/statics';
29-
import * as rippleBinaryCodec from 'ripple-binary-codec';
3029
import * as rippleKeypairs from 'ripple-keypairs';
3130
import * as xrpl from 'xrpl';
3231

32+
// xrpl re-exports ripple-binary-codec@2.7.0 which supports MPTokenAuthorize.
33+
// The standalone ripple-binary-codec dep is 2.1.0 (pre-MPT), so use xrpl as the codec.
34+
const rippleBinaryCodec = xrpl;
35+
3336
import { AccountDeleteBuilder, TokenTransferBuilder, TransactionBuilderFactory, TransferBuilder } from './lib';
3437
import {
3538
ExplainTransactionOptions,
@@ -276,6 +279,22 @@ export class Xrp extends BaseCoin {
276279
value: transaction.LimitAmount.value,
277280
},
278281
};
282+
} else if (transaction.TransactionType === 'MPTokenAuthorize') {
283+
return {
284+
displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'mptIssuanceId'],
285+
id: id,
286+
changeOutputs: [],
287+
outputAmount: 0,
288+
changeAmount: 0,
289+
outputs: [],
290+
fee: {
291+
fee: transaction.Fee,
292+
feeRate: undefined,
293+
size: txHex.length / 2,
294+
},
295+
mptIssuanceId: transaction.MPTokenIssuanceID,
296+
...(transaction.MPTHolder !== undefined && { mptHolder: transaction.MPTHolder }),
297+
};
279298
}
280299

281300
const address =
@@ -337,6 +356,18 @@ export class Xrp extends BaseCoin {
337356
throw new Error(`tx type ${actualTypeFromDecoded} does not match the expected type enabletoken`);
338357
}
339358

359+
verifyMptTxType(txPrebuildDecoded: TransactionExplanation, txHexPrebuild: string | undefined): void {
360+
if (!txHexPrebuild) throw new Error('Missing txHexPrebuild to verify token type for enableMpt tx');
361+
const transactionType = this.getTransactionTypeRawTxHex(txHexPrebuild);
362+
if (transactionType === undefined) throw new Error('Missing TransactionType on MPT enablement tx');
363+
if (transactionType !== XrpTransactionType.MPTokenAuthorize)
364+
throw new Error(`tx type ${transactionType} does not match expected type MPTokenAuthorize`);
365+
const actualTypeFromDecoded =
366+
'type' in txPrebuildDecoded && typeof txPrebuildDecoded.type === 'string' ? txPrebuildDecoded.type : undefined;
367+
if (!actualTypeFromDecoded || actualTypeFromDecoded === 'enableMpt') return;
368+
throw new Error(`tx type ${actualTypeFromDecoded} does not match the expected type enableMpt`);
369+
}
370+
340371
verifyTokenName(
341372
txParams: TransactionParams,
342373
txPrebuildDecoded: TransactionExplanation,
@@ -415,6 +446,15 @@ export class Xrp extends BaseCoin {
415446
this.verifyRequiredKeys(txParams, explanation);
416447
}
417448

449+
if (txParams.type === 'enableMpt') {
450+
if (verification?.verifyTokenEnablement) {
451+
this.verifyMptTxType(explanation, txPrebuild.txHex);
452+
}
453+
// MPTokenAuthorize transactions have no destination or amount;
454+
// the output comparator below does not apply.
455+
return true;
456+
}
457+
418458
const output = [...explanation.outputs, ...explanation.changeOutputs][0];
419459
const expectedOutput = txParams.recipients && txParams.recipients[0];
420460

modules/sdk-coin-xrp/test/unit/xrp.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as xrpl from 'xrpl';
1515
import { XrpToken } from '../../src';
1616
import * as testData from '../resources/xrp';
1717
import { SIGNER_BACKUP, SIGNER_BITGO, SIGNER_USER } from '../resources/xrp';
18+
import { getMptBuilderFactory } from './getBuilderFactory';
1819

1920
nock.disableNetConnect();
2021

@@ -202,6 +203,41 @@ describe('XRP:', function () {
202203
(signedTransaction.Signers as Array<string>).length.should.equal(2);
203204
});
204205

206+
it('should multi-sign an MPTokenAuthorize transaction using the xrpl codec (encodeForMultiSigning)', async function () {
207+
// Build an unsigned MPTokenAuthorize tx using the builder so we get a real XRPL-encoded hex.
208+
// This exercises the ripple.ts encodeForMultiSigning path via the xrpl codec (v2.7.0)
209+
// which supports the MPTokenAuthorize transaction type absent in ripple-binary-codec v2.1.0.
210+
const factory = getMptBuilderFactory(testData.MPT_ISSUANCE_ID);
211+
const sender = testData.TEST_MULTI_SIG_ACCOUNT.address.split('?')[0]; // strip destination tag
212+
213+
const builder = factory.getMPTokenAuthorizeBuilder();
214+
builder.sender(sender);
215+
builder.mptIssuanceId(testData.MPT_ISSUANCE_ID);
216+
builder.sequence(1600000);
217+
builder.fee('12');
218+
builder.flags(2147483648);
219+
220+
const unsignedTx = await builder.build();
221+
const unsignedHex = unsignedTx.toBroadcastFormat();
222+
223+
// Sign with first signer
224+
const firstSigned = ripple.signWithPrivateKey(unsignedHex, SIGNER_USER.prv, {
225+
signAs: SIGNER_USER.address,
226+
});
227+
228+
// Add second signature
229+
const fullySigned = ripple.signWithPrivateKey(firstSigned.signedTransaction, SIGNER_BITGO.prv, {
230+
signAs: SIGNER_BITGO.address,
231+
});
232+
233+
// Must use xrpl.decode (ripple-binary-codec v2.7.0) — the standalone
234+
// ripple-binary-codec v2.1.0 doesn't know the MPTokenAuthorize type.
235+
const decoded = xrpl.decode(fullySigned.signedTransaction);
236+
(decoded.TransactionType as string).should.equal('MPTokenAuthorize');
237+
assert(Array.isArray(decoded.Signers));
238+
(decoded.Signers as Array<unknown>).length.should.equal(2);
239+
});
240+
205241
it('should be able to cosign XRP transaction in any form', function () {
206242
const unsignedTxHex =
207243
'120000228000000024000000072E00000000201B0018D07161400000000003DE2968400000000000002D8114726D0D8A26568D5D9680AC80577C912236717191831449EE221CCACC4DD2BF8862B22B0960A84FC771D9';
@@ -965,5 +1001,41 @@ describe('XRP:', function () {
9651001
{ message: 'Invalid token issuer or currency on token enablement tx' }
9661002
);
9671003
});
1004+
1005+
it('should pass verifyMptTxType for a valid MPTokenAuthorize prebuild', async function () {
1006+
const factory = getMptBuilderFactory(testData.MPT_ISSUANCE_ID);
1007+
const sender = testData.TEST_MULTI_SIG_ACCOUNT.address.split('?')[0];
1008+
1009+
const builder = factory.getMPTokenAuthorizeBuilder();
1010+
builder.sender(sender);
1011+
builder.mptIssuanceId(testData.MPT_ISSUANCE_ID);
1012+
builder.sequence(1600000);
1013+
builder.fee('12');
1014+
builder.flags(2147483648);
1015+
1016+
const txHex = (await builder.build()).toBroadcastFormat();
1017+
1018+
const result = await basecoin.verifyTransaction({
1019+
txParams: { type: 'enableMpt' },
1020+
txPrebuild: { txHex },
1021+
verification: { verifyTokenEnablement: true },
1022+
});
1023+
result.should.equal(true);
1024+
});
1025+
1026+
it('should reject enableMpt when on-chain type is not MPTokenAuthorize', async function () {
1027+
// TrustSet hex — wrong on-chain type for an enableMpt intent
1028+
const txHex = `{"TransactionType":"TrustSet","Account":"rBSpCz8PafXTJHppDcNnex7dYnbe3tSuFG","LimitAmount":{"currency":"524C555344000000000000000000000000000000","issuer":"rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV","value":"99999999"},"Flags":2147483648,"Fee":"45","Sequence":7}`;
1029+
1030+
await assert.rejects(
1031+
async () =>
1032+
basecoin.verifyTransaction({
1033+
txParams: { type: 'enableMpt' },
1034+
txPrebuild: { txHex },
1035+
verification: { verifyTokenEnablement: true },
1036+
}),
1037+
{ message: 'tx type TrustSet does not match expected type MPTokenAuthorize' }
1038+
);
1039+
});
9681040
});
9691041
});

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4058,7 +4058,10 @@ export class Wallet implements IWallet {
40584058
teConfig.validateWallet(this._wallet.type);
40594059
}
40604060

4061-
if (typeof params.prebuildTx === 'string' || params.prebuildTx?.buildParams?.type !== 'enabletoken') {
4061+
if (
4062+
typeof params.prebuildTx === 'string' ||
4063+
(params.prebuildTx?.buildParams?.type !== 'enabletoken' && params.prebuildTx?.buildParams?.type !== 'enableMpt')
4064+
) {
40624065
throw new Error('Invalid build of token enablement.');
40634066
}
40644067

modules/sdk-core/test/unit/bitgo/wallet/tokenApproval.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,86 @@ describe('Wallet - Token Approval', function () {
150150
await wallet.buildErc20TokenApproval('USDC', 'passphrase123').should.be.rejectedWith('signing error');
151151
});
152152
});
153+
154+
describe('sendTokenEnablement', function () {
155+
let teWallet: Wallet;
156+
let teBaseCoin: any;
157+
let teBitGo: any;
158+
159+
beforeEach(function () {
160+
teBitGo = {
161+
post: sinon.stub(),
162+
get: sinon.stub(),
163+
setRequestTracer: sinon.stub(),
164+
};
165+
166+
teBaseCoin = {
167+
getFamily: sinon.stub().returns('txrp'),
168+
getFullName: sinon.stub().returns('Testnet XRP'),
169+
url: sinon.stub(),
170+
keychains: sinon.stub(),
171+
supportsTss: sinon.stub().returns(false),
172+
getMPCAlgorithm: sinon.stub(),
173+
getTokenEnablementConfig: sinon.stub().returns({ requiresTokenEnablement: true }),
174+
};
175+
176+
// custodial wallet so the path after validation calls initiateTransaction
177+
const walletData = {
178+
id: 'te-wallet-id',
179+
coin: 'txrp',
180+
type: 'custodial',
181+
keys: ['user-key', 'backup-key', 'bitgo-key'],
182+
};
183+
184+
teWallet = new Wallet(teBitGo, teBaseCoin, walletData);
185+
});
186+
187+
it('should throw "Invalid build of token enablement." when prebuildTx is a string', async function () {
188+
await teWallet
189+
.sendTokenEnablement({ prebuildTx: 'raw-hex-string' as any })
190+
.should.be.rejectedWith('Invalid build of token enablement.');
191+
});
192+
193+
it('should throw "Invalid build of token enablement." when buildParams.type is undefined', async function () {
194+
await teWallet
195+
.sendTokenEnablement({ prebuildTx: { buildParams: {} } as any })
196+
.should.be.rejectedWith('Invalid build of token enablement.');
197+
});
198+
199+
it('should throw "Invalid build of token enablement." when buildParams.type is an unrecognised type', async function () {
200+
await teWallet
201+
.sendTokenEnablement({ prebuildTx: { buildParams: { type: 'transfer' } } as any })
202+
.should.be.rejectedWith('Invalid build of token enablement.');
203+
});
204+
205+
it('should pass validation and proceed when buildParams.type is "enabletoken"', async function () {
206+
const initiateStub = sinon.stub(teWallet as any, 'initiateTransaction').resolves({ txid: 'abc123' });
207+
208+
const result = await teWallet.sendTokenEnablement({
209+
prebuildTx: { buildParams: { type: 'enabletoken' } } as any,
210+
});
211+
212+
result.should.eql({ txid: 'abc123' });
213+
sinon.assert.calledOnce(initiateStub);
214+
});
215+
216+
it('should pass validation and proceed when buildParams.type is "enableMpt"', async function () {
217+
const initiateStub = sinon.stub(teWallet as any, 'initiateTransaction').resolves({ txid: 'mpt456' });
218+
219+
const result = await teWallet.sendTokenEnablement({
220+
prebuildTx: { buildParams: { type: 'enableMpt' } } as any,
221+
});
222+
223+
result.should.eql({ txid: 'mpt456' });
224+
sinon.assert.calledOnce(initiateStub);
225+
});
226+
227+
it('should throw when the coin does not require token enablement', async function () {
228+
teBaseCoin.getTokenEnablementConfig.returns({ requiresTokenEnablement: false });
229+
230+
await teWallet
231+
.sendTokenEnablement({ prebuildTx: { buildParams: { type: 'enableMpt' } } as any })
232+
.should.be.rejectedWith(/does not require token enablement transactions/);
233+
});
234+
});
153235
});

0 commit comments

Comments
 (0)