diff --git a/modules/sdk-coin-xrp/src/lib/iface.ts b/modules/sdk-coin-xrp/src/lib/iface.ts index 58d0cba56b..7354361997 100644 --- a/modules/sdk-coin-xrp/src/lib/iface.ts +++ b/modules/sdk-coin-xrp/src/lib/iface.ts @@ -83,6 +83,7 @@ export interface SupplementGenerateWalletOptions { export type TransactionExplanation = | BaseTransactionExplanation | AccountSetTransactionExplanation + | TrustSetTransactionExplanation | SignerListSetTransactionExplanation; export interface AccountSetTransactionExplanation extends BaseTransactionExplanation { @@ -92,6 +93,14 @@ export interface AccountSetTransactionExplanation extends BaseTransactionExplana }; } +export interface TrustSetTransactionExplanation extends BaseTransactionExplanation { + limitAmount: { + tokenName: string; + address: string; + amount: string; + }; +} + export interface SignerListSetTransactionExplanation extends BaseTransactionExplanation { signerListSet: { signerQuorum: number; diff --git a/modules/sdk-coin-xrp/src/xrp.ts b/modules/sdk-coin-xrp/src/xrp.ts index 2a4dadfd65..5b51fb9da8 100644 --- a/modules/sdk-coin-xrp/src/xrp.ts +++ b/modules/sdk-coin-xrp/src/xrp.ts @@ -16,10 +16,11 @@ import { ParsedTransaction, ParseTransactionOptions, promiseProps, + TokenEnablementConfig, UnexpectedAddressError, VerifyTransactionOptions, } from '@bitgo/sdk-core'; -import { BaseCoin as StaticsBaseCoin } from '@bitgo/statics'; +import { BaseCoin as StaticsBaseCoin, coins, XrpCoin } from '@bitgo/statics'; import * as rippleBinaryCodec from 'ripple-binary-codec'; import * as rippleKeypairs from 'ripple-keypairs'; import * as xrpl from 'xrpl'; @@ -107,6 +108,13 @@ export class Xrp extends BaseCoin { return this.bitgo.get(this.url('/public/feeinfo')).result(); } + public getTokenEnablementConfig(): TokenEnablementConfig { + return { + requiresTokenEnablement: true, + supportsMultipleTokenEnablements: false, + }; + } + /** * Assemble keychain and half-sign prebuilt transaction * @param params @@ -222,6 +230,25 @@ export class Xrp extends BaseCoin { setFlag: transaction.SetFlag, }, }; + } else if (transaction.TransactionType === 'TrustSet') { + return { + displayOrder: ['id', 'outputAmount', 'changeAmount', 'outputs', 'changeOutputs', 'fee', 'limitAmount'], + id: id, + changeOutputs: [], + outputAmount: 0, + changeAmount: 0, + outputs: [], + fee: { + fee: transaction.Fee, + feeRate: undefined, + size: txHex.length / 2, + }, + limitAmount: { + tokenName: transaction.LimitAmount.currency, + address: transaction.LimitAmount.issuer, + amount: transaction.LimitAmount.value, + }, + }; } const address = @@ -254,6 +281,7 @@ export class Xrp extends BaseCoin { * @returns {boolean} */ public async verifyTransaction({ txParams, txPrebuild }: VerifyTransactionOptions): Promise { + const coinConfig = coins.get(this.getChain()) as XrpCoin; const explanation = await this.explainTransaction({ txHex: txPrebuild.txHex, }); @@ -270,8 +298,34 @@ export class Xrp extends BaseCoin { return amount1.toFixed() === amount2.toFixed(); }; - if (!comparator(output, expectedOutput)) { - throw new Error('transaction prebuild does not match expected output'); + if (txParams.recipients) { + // for enabletoken, recipient output amount is 0 + const recipients = txParams.recipients.map((recipient) => ({ + ...recipient, + })); + if (coinConfig.isToken) { + recipients.forEach((recipient) => { + if ( + recipient.tokenName !== undefined && + utils.getXrpCurrencyFromTokenName(recipient.tokenName).currency !== coinConfig.currencyCode + ) { + throw new Error('Incorrect token name specified in recipients'); + } + recipient.tokenName = coinConfig.currencyCode; + }); + } + + // verify recipients from params and explainedTx + const filteredRecipients = recipients?.map((recipient) => _.pick(recipient, ['address', 'amount', 'tokenName'])); + const filteredOutputs = 'limitAmount' in explanation ? [explanation.limitAmount] : []; + + if (!_.isEqual(filteredOutputs, filteredRecipients)) { + throw new Error('Tx outputs does not match with expected txParams recipients'); + } + } else { + if (!comparator(output, expectedOutput)) { + throw new Error('transaction prebuild does not match expected output'); + } } return true; diff --git a/modules/sdk-coin-xrp/test/unit/xrp.ts b/modules/sdk-coin-xrp/test/unit/xrp.ts index ce4c35292f..726778db00 100644 --- a/modules/sdk-coin-xrp/test/unit/xrp.ts +++ b/modules/sdk-coin-xrp/test/unit/xrp.ts @@ -10,6 +10,8 @@ import assert from 'assert'; import * as rippleBinaryCodec from 'ripple-binary-codec'; import sinon from 'sinon'; import * as testData from '../resources/xrp'; +import * as _ from 'lodash'; +import { XrpToken } from '../../src'; nock.disableNetConnect(); @@ -18,10 +20,15 @@ bitgo.safeRegister('txrp', Txrp.createInstance); describe('XRP:', function () { let basecoin; + let token; before(function () { + XrpToken.createTokenConstructors().forEach(({ name, coinConstructor }) => { + bitgo.safeRegister(name, coinConstructor); + }); bitgo.initializeTestVars(); basecoin = bitgo.coin('txrp'); + token = bitgo.coin('txrp:rlusd'); }); after(function () { @@ -294,4 +301,82 @@ describe('XRP:', function () { ); }); }); + + describe('Verify Transaction', () => { + let newTxPrebuild; + let newTxParams; + + const txPrebuild = { + txHex: `{"TransactionType":"TrustSet","Account":"rBSpCz8PafXTJHppDcNnex7dYnbe3tSuFG","LimitAmount":{"currency":"524C555344000000000000000000000000000000","issuer":"rnox8i6h9GoAbuwr73JtaDxXoncLLUCpXH","value":"1000000000"},"Flags":2147483648,"Fee":"45","Sequence":7}`, + }; + + const txParams = { + recipients: [ + { + address: 'rnox8i6h9GoAbuwr73JtaDxXoncLLUCpXH', + amount: '10', + }, + { + address: 'raBSn6ipeWXYe7rNbNafZSx9dV2fU3zRyP', + amount: '15', + }, + ], + }; + + before(function () { + newTxPrebuild = () => { + return _.cloneDeep(txPrebuild); + }; + newTxParams = () => { + return _.cloneDeep(txParams); + }; + }); + + it('should verify token trustline transactions', async function () { + const txParams = newTxParams(); + const txPrebuild = newTxPrebuild(); + + txParams.recipients = [ + { + address: 'rnox8i6h9GoAbuwr73JtaDxXoncLLUCpXH', + amount: '1000000000', + tokenName: 'txrp:rlusd', + }, + ]; + + const validTransaction = await token.verifyTransaction({ + txParams, + txPrebuild, + }); + validTransaction.should.equal(true); + }); + + it('should fail verify trustline transaction with mismatch recipients', async function () { + const txParams = newTxParams(); + const txPrebuild = newTxPrebuild(); + txParams.recipients = [ + { + address: 'raBSn6ipeWXYe7rNbNafZSx9dV2fU3zRyP', + amount: '1000000000', + tokenName: 'txrp:rlusd', + }, + ]; + await token + .verifyTransaction({ txParams, txPrebuild }) + .should.be.rejectedWith('Tx outputs does not match with expected txParams recipients'); + }); + + it('should fail to verify trustline transaction with incorrect token name', async function () { + const txParams = newTxParams(); + const txPrebuild = newTxPrebuild(); + txParams.recipients = [ + { + address: 'rnox8i6h9GoAbuwr73JtaDxXoncLLUCpXH', + amount: '1000000000', + tokenName: 'txrp:usd', + }, + ]; + await token.verifyTransaction({ txParams, txPrebuild }).should.be.rejectedWith('txrp:usd is not supported'); + }); + }); });