Skip to content
Merged
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
8 changes: 7 additions & 1 deletion modules/sdk-coin-xrp/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ export type TransactionExplanation =
| BaseTransactionExplanation
| AccountSetTransactionExplanation
| TrustSetTransactionExplanation
| SignerListSetTransactionExplanation;
| SignerListSetTransactionExplanation
| MPTokenAuthorizeTransactionExplanation;

export interface AccountSetTransactionExplanation extends BaseTransactionExplanation {
accountSet: {
Expand All @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions modules/sdk-coin-xrp/src/ripple.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
}

Expand Down
42 changes: 41 additions & 1 deletion modules/sdk-coin-xrp/src/xrp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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];

Expand Down
72 changes: 72 additions & 0 deletions modules/sdk-coin-xrp/test/unit/xrp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -202,6 +203,41 @@ describe('XRP:', function () {
(signedTransaction.Signers as Array<string>).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<unknown>).length.should.equal(2);
});

it('should be able to cosign XRP transaction in any form', function () {
const unsignedTxHex =
'120000228000000024000000072E00000000201B0018D07161400000000003DE2968400000000000002D8114726D0D8A26568D5D9680AC80577C912236717191831449EE221CCACC4DD2BF8862B22B0960A84FC771D9';
Expand Down Expand Up @@ -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' }
);
});
});
});
5 changes: 4 additions & 1 deletion modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}

Expand Down
82 changes: 82 additions & 0 deletions modules/sdk-core/test/unit/bitgo/wallet/tokenApproval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
});
Loading