diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 3effa6a05a..0467a23d12 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -23,6 +23,7 @@ import { MPCTx, MPCTxs, ParsedTransaction, + ITransactionRecipient, ParseTransactionOptions, PrebuildTransactionResult, PresignTransactionOptions as BasePresignTransactionOptions, @@ -71,8 +72,11 @@ import secp256k1 from 'secp256k1'; import { AbstractEthLikeCoin } from './abstractEthLikeCoin'; import { EthLikeToken } from './ethLikeToken'; import { + batchMethodId, calculateForwarderV1Address, coinFamiliesWithL1Fees, + decodeBatchTransferData, + decodeNativeTransferData, decodeTransferData, ERC1155TransferBuilder, ERC721TransferBuilder, @@ -83,6 +87,7 @@ import { getRawDecoded, getToken, KeyPair as KeyPairLib, + sendMultisigMethodId, TransactionBuilder, TransferBuilder, } from './lib'; @@ -1633,6 +1638,152 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { }; } + /** + * Verify that the inner batch(address[],uint256[]) calldata embedded in txPrebuild.txHex matches + * the user-supplied recipients. Used by the multi-sig (sendMultiSig) batch path. Throws via + * throwRecipientMismatch if any pair differs or if the calldata cannot be decoded. Fails closed: + * missing txHex, an unexpected outer selector, or an unexpected inner selector all reject. + */ + private async verifyBatchInnerRecipients( + txPrebuild: TransactionPrebuild, + recipients: ITransactionRecipient[], + throwRecipientMismatch: (message: string, mismatchedRecipients: Recipient[]) => Promise + ): Promise { + if (!txPrebuild.txHex) { + await throwRecipientMismatch('batch txPrebuild missing txHex required for inner calldata verification', []); + return; + } + + let outerCalldata: string; + try { + const txBuffer = optionalDeps.ethUtil.toBuffer(txPrebuild.txHex); + const decodedTx = optionalDeps.EthTx.TransactionFactory.fromSerializedData(txBuffer); + outerCalldata = optionalDeps.ethUtil.bufferToHex(decodedTx.data); + } catch (e) { + await throwRecipientMismatch(`failed to parse batch txHex: ${e instanceof Error ? e.message : String(e)}`, []); + return; + } + + if (!outerCalldata.toLowerCase().startsWith(sendMultisigMethodId)) { + await throwRecipientMismatch('batch txPrebuild outer call is not sendMultiSig', []); + return; + } + + let innerBatchData: string; + try { + innerBatchData = decodeNativeTransferData(outerCalldata).data; + } catch (e) { + await throwRecipientMismatch( + `failed to decode outer sendMultiSig wrapper: ${e instanceof Error ? e.message : String(e)}`, + [] + ); + return; + } + + await this.compareBatchCalldataAgainstRecipients(innerBatchData, recipients, throwRecipientMismatch); + } + + /** + * Verify that the batch(address[],uint256[]) calldata embedded directly in the outer TSS + * transaction matches the user-supplied recipients. TSS wallets are EOAs controlled by MPC keys + * and call the batcher contract directly, so the outer tx.data IS the batch calldata (no + * sendMultiSig wrapper). Verifies the outer to == batcherContractAddress and the outer value + * matches the total amount, then decodes and compares each inner (address, amount) pair. + */ + private async verifyTssBatchInnerRecipients( + txPrebuild: TransactionPrebuild, + recipients: ITransactionRecipient[], + batcherContractAddress: string, + throwRecipientMismatch: (message: string, mismatchedRecipients: Recipient[]) => Promise + ): Promise { + if (!txPrebuild.txHex) { + await throwRecipientMismatch('batch txPrebuild missing txHex required for inner calldata verification', []); + return; + } + + let outerTo: string; + let outerValue: string; + let outerCalldata: string; + try { + const txBuffer = optionalDeps.ethUtil.toBuffer(txPrebuild.txHex); + const decodedTx = optionalDeps.EthTx.TransactionFactory.fromSerializedData(txBuffer); + outerTo = decodedTx.to ? decodedTx.to.toString() : ''; + outerValue = decodedTx.value.toString(); + outerCalldata = optionalDeps.ethUtil.bufferToHex(decodedTx.data); + } catch (e) { + await throwRecipientMismatch(`failed to parse batch txHex: ${e instanceof Error ? e.message : String(e)}`, []); + return; + } + + if (!outerTo || outerTo.toLowerCase() !== batcherContractAddress.toLowerCase()) { + await throwRecipientMismatch('batch txPrebuild outer to does not match batcher contract address', [ + { address: outerTo, amount: outerValue }, + ]); + return; + } + + const expectedTotal = recipients + .reduce((sum, r) => sum.plus(new BigNumber(r.amount as string | number)), new BigNumber(0)) + .toFixed(); + if (!new BigNumber(outerValue).isEqualTo(expectedTotal)) { + await throwRecipientMismatch( + `batch txPrebuild outer value (${outerValue}) does not match sum of txParams recipients (${expectedTotal})`, + [{ address: outerTo, amount: outerValue }] + ); + return; + } + + await this.compareBatchCalldataAgainstRecipients(outerCalldata, recipients, throwRecipientMismatch); + } + + /** + * Shared comparator: verify that the given batch calldata starts with the batch selector, + * decode it, and compare each inner (address, amount) pair to the user-supplied recipients. + */ + private async compareBatchCalldataAgainstRecipients( + batchCalldata: string, + recipients: ITransactionRecipient[], + throwRecipientMismatch: (message: string, mismatchedRecipients: Recipient[]) => Promise + ): Promise { + if (!batchCalldata || !batchCalldata.toLowerCase().startsWith(batchMethodId)) { + await throwRecipientMismatch('batch txPrebuild inner method selector is not batch(address[],uint256[])', []); + return; + } + + let decoded; + try { + decoded = decodeBatchTransferData(batchCalldata); + } catch (e) { + await throwRecipientMismatch( + `failed to decode inner batch calldata: ${e instanceof Error ? e.message : String(e)}`, + [] + ); + return; + } + + if (decoded.recipients.length !== recipients.length) { + await throwRecipientMismatch( + `batch txPrebuild inner recipient count (${decoded.recipients.length}) does not match txParams (${recipients.length})`, + decoded.recipients + ); + return; + } + + for (let i = 0; i < recipients.length; i++) { + const expected = recipients[i]; + const actual = decoded.recipients[i]; + // Skip address comparison for non-hex inputs (e.g. unresolved ENS); mirrors normal-tx path. + if (this.isETHAddress(expected.address) && expected.address.toLowerCase() !== actual.address.toLowerCase()) { + await throwRecipientMismatch('batch txPrebuild inner recipient address does not match txParams', [actual]); + return; + } + if (!new BigNumber(expected.amount).isEqualTo(actual.amount)) { + await throwRecipientMismatch('batch txPrebuild inner recipient amount does not match txParams', [actual]); + return; + } + } + } + /** * Extract recipients from transaction hex * @param txHex - The transaction hex string @@ -3088,6 +3239,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { * @throws {TxIntentMismatchRecipientError} if transaction recipients don't match user intent */ async verifyTssTransaction(params: VerifyEthTransactionOptions): Promise { + const ethNetwork = this.getNetwork(); const { txParams, txPrebuild, wallet } = params; // Helper to throw TxIntentMismatchRecipientError with recipient details @@ -3123,6 +3275,23 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { throw new Error('tx cannot be both a batch and hop transaction'); } + // TSS batch sends call the batcher contract directly (no sendMultiSig wrapper). Decode the + // inner batch calldata and compare each (address, amount) pair to user intent. Token batches + // are not supported through the same pattern, so they keep existing behavior. + if (!txParams.tokenName && txParams.recipients && txParams.recipients.length > 1) { + const batcherContractAddress = ethNetwork?.batcherContractAddress; + if (!batcherContractAddress) { + await throwRecipientMismatch('batch txPrebuild for tss has no configured batcher contract address', []); + } else { + await this.verifyTssBatchInnerRecipients( + txPrebuild, + txParams.recipients, + batcherContractAddress, + throwRecipientMismatch + ); + } + } + if (txParams.type && ['transfer'].includes(txParams.type)) { if (txParams.recipients && txParams.recipients.length === 1) { const recipients = txParams.recipients; @@ -3325,6 +3494,13 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { { address: txPrebuild.recipients[0].address, amount: txPrebuild.recipients[0].amount.toString() }, ]); } + + // Decode the inner batch(address[],uint256[]) calldata and verify each (address, amount) pair + // matches user intent. Without this, a compromised platform could swap inner recipients while + // preserving the outer total amount and batcher-address checks. + if (!txParams.tokenName) { + await this.verifyBatchInnerRecipients(txPrebuild, recipients, throwRecipientMismatch); + } } else { // Check recipient address and amount for normal transaction if (recipients.length !== 1) { diff --git a/modules/abstract-eth/src/lib/iface.ts b/modules/abstract-eth/src/lib/iface.ts index a97a67eb0f..3ceea19ada 100644 --- a/modules/abstract-eth/src/lib/iface.ts +++ b/modules/abstract-eth/src/lib/iface.ts @@ -144,6 +144,15 @@ export interface NativeTransferData extends TransferData { data: string; } +export interface BatchTransferRecipient { + address: string; + amount: string; +} + +export interface BatchTransferData { + recipients: BatchTransferRecipient[]; +} + export interface WalletInitializationData { salt?: string; owners: string[]; diff --git a/modules/abstract-eth/src/lib/utils.ts b/modules/abstract-eth/src/lib/utils.ts index 05dcdadc51..0e1016d928 100644 --- a/modules/abstract-eth/src/lib/utils.ts +++ b/modules/abstract-eth/src/lib/utils.ts @@ -31,6 +31,7 @@ import { } from '@bitgo/sdk-core'; import { + BatchTransferData, ERC1155TransferData, ERC721TransferData, FlushTokensData, @@ -65,6 +66,8 @@ import { flushERC1155ForwarderTokensMethodIdV4, flushERC1155TokensTypes, flushERC1155TokensTypesv4, + batchMethodId, + batchMethodTypes, sendMultisigMethodId, sendMultisigTokenMethodId, sendMultiSigTokenTypes, @@ -459,6 +462,34 @@ export function decodeTransferData(data: string, isFirstSigner?: boolean): Trans } } +/** + * Decode the inner batch(address[],uint256[]) calldata produced for batcher contract sends. + * The data is the inner payload nested inside a sendMultiSig wrapper, not a full transaction. + * + * @param data Hex string starting with the batch method selector + * @returns Decoded recipients and amounts in the order they appear in the calldata + */ +export function decodeBatchTransferData(data: string): BatchTransferData { + if (!data.toLowerCase().startsWith(batchMethodId)) { + throw new BuildTransactionError(`Invalid batch transfer bytecode: ${data}`); + } + const [addresses, amounts] = getRawDecoded(batchMethodTypes, getBufferedByteCode(batchMethodId, data)); + if (!Array.isArray(addresses) || !Array.isArray(amounts)) { + throw new BuildTransactionError(`Invalid batch transfer bytecode: ${data}`); + } + if (addresses.length !== amounts.length) { + throw new BuildTransactionError( + `Mismatched batch address/amount array lengths: ${addresses.length} vs ${amounts.length}` + ); + } + return { + recipients: addresses.map((addr, i) => ({ + address: addHexPrefix(addr as string), + amount: new BigNumber(bufferToHex(amounts[i] as Buffer)).toFixed(), + })), + }; +} + /** * Decode the given ABI-encoded transfer data for the sendMultisigToken function and return parsed fields * diff --git a/modules/abstract-eth/src/lib/walletUtil.ts b/modules/abstract-eth/src/lib/walletUtil.ts index add5c680f6..e10d367c7f 100644 --- a/modules/abstract-eth/src/lib/walletUtil.ts +++ b/modules/abstract-eth/src/lib/walletUtil.ts @@ -1,5 +1,7 @@ export const sendMultisigMethodId = '0x39125215'; export const sendMultisigTokenMethodId = '0x0dcd7a6c'; +// Selector for batch(address[],uint256[]) used by batcher contract sends. +export const batchMethodId = '0xc00c4e9e'; export const v1CreateForwarderMethodId = '0xfb90b320'; export const v4CreateForwarderMethodId = '0x13b2f75c'; export const v1WalletInitializationFirstBytes = '0x60806040'; @@ -38,6 +40,9 @@ export const sendMultiSigTypesFirstSigner = ['string', 'address', 'uint', 'bytes export const sendMultiSigTokenTypes = ['address', 'uint', 'address', 'uint', 'uint', 'bytes']; export const sendMultiSigTokenTypesFirstSigner = ['string', 'address', 'uint', 'address', 'uint', 'uint']; +export const batchMethodName = 'batch'; +export const batchMethodTypes = ['address[]', 'uint256[]']; + export const ERC721SafeTransferTypes = ['address', 'address', 'uint256', 'bytes']; export const ERC721TransferFromTypes = ['address', 'address', 'uint256']; diff --git a/modules/abstract-eth/test/unit/utils.ts b/modules/abstract-eth/test/unit/utils.ts index 68cd2e47fd..d45f72d98a 100644 --- a/modules/abstract-eth/test/unit/utils.ts +++ b/modules/abstract-eth/test/unit/utils.ts @@ -1,12 +1,20 @@ import should from 'should'; +import EthereumAbi from 'ethereumjs-abi'; import { flushERC721TokensData, flushERC1155TokensData, decodeFlushERC721TokensData, decodeFlushERC1155TokensData, + decodeBatchTransferData, } from '../../src/lib/utils'; import { ERC721TransferBuilder } from '../../src/lib/transferBuilders/transferBuilderERC721'; -import { ERC721TransferFromMethodId, ERC721SafeTransferTypeMethodId } from '../../src/lib/walletUtil'; +import { + ERC721TransferFromMethodId, + ERC721SafeTransferTypeMethodId, + batchMethodId, + batchMethodName, + batchMethodTypes, +} from '../../src/lib/walletUtil'; describe('Abstract ETH Utils', () => { describe('ERC721 Flush Functions', () => { @@ -268,4 +276,42 @@ describe('Abstract ETH Utils', () => { decoded1155.tokenAddress.toLowerCase().should.equal(tokenAddressChecksum.toLowerCase()); }); }); + + describe('decodeBatchTransferData', () => { + const address1 = '0x1111111111111111111111111111111111111111'; + const address2 = '0x2222222222222222222222222222222222222222'; + const encodeBatch = (addresses: string[], amounts: string[]): string => { + const selector = EthereumAbi.methodID(batchMethodName, batchMethodTypes); + const args = EthereumAbi.rawEncode(batchMethodTypes, [addresses, amounts]); + return '0x' + Buffer.concat([selector, args]).toString('hex'); + }; + + it('hardcoded batchMethodId matches the runtime-computed selector', () => { + const computed = '0x' + EthereumAbi.methodID(batchMethodName, batchMethodTypes).toString('hex'); + computed.should.equal(batchMethodId); + }); + + it('round-trips encode/decode for multiple recipients', () => { + const data = encodeBatch([address1, address2], ['1000', '2500']); + const decoded = decodeBatchTransferData(data); + + decoded.recipients.length.should.equal(2); + decoded.recipients[0].address.toLowerCase().should.equal(address1); + decoded.recipients[0].amount.should.equal('1000'); + decoded.recipients[1].address.toLowerCase().should.equal(address2); + decoded.recipients[1].amount.should.equal('2500'); + }); + + it('throws on wrong method selector', () => { + should.throws(() => decodeBatchTransferData('0xdeadbeef00000000'), /Invalid batch transfer bytecode/); + }); + + it('throws when the encoded address[] and uint256[] arrays have different lengths', () => { + // Encode the batch payload directly with mismatched array lengths. + const payload = EthereumAbi.rawEncode(batchMethodTypes, [[address1, address2], ['1000']]); + const tampered = + '0x' + Buffer.concat([EthereumAbi.methodID(batchMethodName, batchMethodTypes), payload]).toString('hex'); + should.throws(() => decodeBatchTransferData(tampered), /Mismatched batch address\/amount array lengths/); + }); + }); }); diff --git a/modules/sdk-coin-arbeth/package.json b/modules/sdk-coin-arbeth/package.json index f5d2996c63..a28990cf8f 100644 --- a/modules/sdk-coin-arbeth/package.json +++ b/modules/sdk-coin-arbeth/package.json @@ -51,6 +51,8 @@ "devDependencies": { "@bitgo/sdk-api": "^1.79.2", "@bitgo/sdk-test": "^9.1.42", + "@ethereumjs/tx": "^3.3.0", + "bignumber.js": "^9.1.1", "secp256k1": "5.0.1" }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", diff --git a/modules/sdk-coin-arbeth/test/unit/arbeth.ts b/modules/sdk-coin-arbeth/test/unit/arbeth.ts index 15b1557f5f..c7f4ba6e98 100644 --- a/modules/sdk-coin-arbeth/test/unit/arbeth.ts +++ b/modules/sdk-coin-arbeth/test/unit/arbeth.ts @@ -6,8 +6,47 @@ import nock from 'nock'; import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; import { OfflineVaultTxInfo, optionalDeps, SignTransactionOptions } from '@bitgo/abstract-eth'; +import { EthereumNetwork } from '@bitgo/statics'; +import { Transaction as LegacyTransaction } from '@ethereumjs/tx'; +import EthereumAbi from 'ethereumjs-abi'; +import { addHexPrefix } from 'ethereumjs-util'; +import BigNumber from 'bignumber.js'; import { Arbeth, Tarbeth, TransactionBuilder, TransferBuilder } from '../../src'; + +/** + * Build a valid serialized batch transaction hex. Outer call is sendMultiSig to the wallet contract; + * its inner data is the batch(address[],uint256[]) calldata that goes to the batcher contract. + */ +function buildBatchTxHex( + recipients: { address: string; amount: string }[], + batcherAddress: string, + opts: { overrideInnerAddresses?: string[]; overrideInnerAmounts?: string[] } = {} +): string { + const innerAddresses = opts.overrideInnerAddresses ?? recipients.map((r) => r.address); + const innerAmounts = opts.overrideInnerAmounts ?? recipients.map((r) => r.amount); + const innerData = Buffer.concat([ + EthereumAbi.methodID('batch', ['address[]', 'uint256[]']), + EthereumAbi.rawEncode(['address[]', 'uint256[]'], [innerAddresses, innerAmounts]), + ]); + const total = recipients.reduce((s, r) => s.plus(r.amount), new BigNumber(0)).toFixed(); + const sendMultisigData = Buffer.concat([ + EthereumAbi.methodID('sendMultiSig', ['address', 'uint', 'bytes', 'uint', 'uint', 'bytes']), + EthereumAbi.rawEncode( + ['address', 'uint', 'bytes', 'uint', 'uint', 'bytes'], + [batcherAddress, total, innerData, 1700000000, 1, Buffer.alloc(0)] + ), + ]); + const tx = LegacyTransaction.fromTxData({ + to: '0x' + '11'.repeat(20), + data: addHexPrefix(sendMultisigData.toString('hex')), + nonce: 0, + gasPrice: 20000000000, + gasLimit: 500000, + value: 0, + }); + return addHexPrefix(tx.serialize().toString('hex')); +} import * as mockData from '../fixtures/arbeth'; import { getBuilder } from '../getBuilder'; @@ -407,6 +446,64 @@ describe('Arbitrum', function () { ); }); + it('should verify a batch txPrebuild whose inner calldata matches txParams.recipients', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const batcherAddress = (basecoin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + const batchRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients: batchRecipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildBatchTxHex(batchRecipients, batcherAddress), + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'tarbeth', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + const isTransactionVerified = await basecoin.verifyTransaction({ + txParams, + txPrebuild: txPrebuild as any, + wallet, + verification: {}, + }); + isTransactionVerified.should.equal(true); + }); + + it('should reject a batch txPrebuild whose inner recipient address differs from txParams', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const batcherAddress = (basecoin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + const batchRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients: batchRecipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildBatchTxHex(batchRecipients, batcherAddress, { + overrideInnerAddresses: [address1, '0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead'], + }), + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'tarbeth', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification: {} }) + .should.be.rejectedWith('batch txPrebuild inner recipient address does not match txParams'); + }); + it('should reject a hop txPrebuild that does not send to its hop address', async function () { const wallet = new Wallet(bitgo, basecoin, {}); diff --git a/modules/sdk-coin-eth/test/unit/eth.ts b/modules/sdk-coin-eth/test/unit/eth.ts index d3aee49106..8ec743f283 100644 --- a/modules/sdk-coin-eth/test/unit/eth.ts +++ b/modules/sdk-coin-eth/test/unit/eth.ts @@ -5,6 +5,10 @@ import sinon from 'sinon'; import { bip32 } from '@bitgo/secp256k1'; import * as secp256k1 from 'secp256k1'; import request from 'superagent'; +import { Transaction as LegacyTransaction } from '@ethereumjs/tx'; +import EthereumAbi from 'ethereumjs-abi'; +import { addHexPrefix } from 'ethereumjs-util'; +import BigNumber from 'bignumber.js'; import { common, generateRandomPassword, @@ -39,6 +43,88 @@ import { ethTssBackupKey } from './fixtures/ethTssBackupKey'; nock.enableNetConnect(); +/** + * Build a valid serialized batch transaction hex. Outer call is sendMultiSig to the wallet contract; + * its inner data is the batch(address[],uint256[]) calldata that goes to the batcher contract. + */ +function buildBatchTxHex( + recipients: { address: string; amount: string }[], + batcherAddress: string, + opts: { + walletContractAddress?: string; + sequenceId?: number; + expireTime?: number; + innerMethodSignature?: { name: string; types: string[] }; + overrideInnerAddresses?: string[]; + overrideInnerAmounts?: string[]; + } = {} +): string { + const walletContractAddress = opts.walletContractAddress || '0x' + '11'.repeat(20); + const sequenceId = opts.sequenceId ?? 1; + const expireTime = opts.expireTime ?? 1700000000; + const innerSig = opts.innerMethodSignature || { name: 'batch', types: ['address[]', 'uint256[]'] }; + const innerAddresses = opts.overrideInnerAddresses ?? recipients.map((r) => r.address); + const innerAmounts = opts.overrideInnerAmounts ?? recipients.map((r) => r.amount); + + const innerData = Buffer.concat([ + EthereumAbi.methodID(innerSig.name, innerSig.types), + EthereumAbi.rawEncode(innerSig.types, [innerAddresses, innerAmounts]), + ]); + const total = recipients.reduce((s, r) => s.plus(r.amount), new BigNumber(0)).toFixed(); + const sendMultisigData = Buffer.concat([ + EthereumAbi.methodID('sendMultiSig', ['address', 'uint', 'bytes', 'uint', 'uint', 'bytes']), + EthereumAbi.rawEncode( + ['address', 'uint', 'bytes', 'uint', 'uint', 'bytes'], + [batcherAddress, total, innerData, expireTime, sequenceId, Buffer.alloc(0)] + ), + ]); + const tx = LegacyTransaction.fromTxData({ + to: walletContractAddress, + data: addHexPrefix(sendMultisigData.toString('hex')), + nonce: 0, + gasPrice: 20000000000, + gasLimit: 500000, + value: 0, + }); + return addHexPrefix(tx.serialize().toString('hex')); +} + +/** + * Build a TSS batch transaction hex. TSS wallets are EOAs, so the outer transaction calls the + * batcher contract directly: tx.to = batcher, tx.value = total, tx.data = batch(addr[],amt[]). + */ +function buildTssBatchTxHex( + recipients: { address: string; amount: string }[], + batcherAddress: string, + opts: { + overrideInnerAddresses?: string[]; + overrideInnerAmounts?: string[]; + innerMethodSignature?: { name: string; types: string[] }; + overrideTo?: string; + overrideValue?: string; + } = {} +): string { + const innerSig = opts.innerMethodSignature || { name: 'batch', types: ['address[]', 'uint256[]'] }; + const innerAddresses = opts.overrideInnerAddresses ?? recipients.map((r) => r.address); + const innerAmounts = opts.overrideInnerAmounts ?? recipients.map((r) => r.amount); + + const innerData = Buffer.concat([ + EthereumAbi.methodID(innerSig.name, innerSig.types), + EthereumAbi.rawEncode(innerSig.types, [innerAddresses, innerAmounts]), + ]); + const total = recipients.reduce((s, r) => s.plus(r.amount), new BigNumber(0)); + const rawValue = opts.overrideValue ?? total.toFixed(); + const tx = LegacyTransaction.fromTxData({ + to: opts.overrideTo ?? batcherAddress, + data: addHexPrefix(innerData.toString('hex')), + nonce: 0, + gasPrice: 20000000000, + gasLimit: 500000, + value: addHexPrefix(new BigNumber(rawValue).toString(16)), + }); + return addHexPrefix(tx.serialize().toString('hex')); +} + describe('ETH:', function () { let bitgo: TestBitGoAPI; let hopTxBitgoSignature; @@ -216,20 +302,21 @@ describe('ETH:', function () { it('should verify a batch txPrebuild from the bitgo server that matches the client txParams', async function () { const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = (coin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + const batchRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; const txParams = { - recipients: [ - { amount: '1000000000000', address: address1 }, - { amount: '2500000000000', address: address2 }, - ], + recipients: batchRecipients, wallet: wallet, walletPassphrase: 'fakeWalletPassphrase', }; const txPrebuild = { - recipients: [ - { amount: '3500000000000', address: (coin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress }, - ], + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildBatchTxHex(batchRecipients, batcherAddress), nextContractSequenceId: 0, gasPrice: 20000000000, gasLimit: 500000, @@ -541,6 +628,182 @@ describe('ETH:', function () { .should.be.rejectedWith('recipient address of txPrebuild does not match batcher address'); }); + it('should reject a batch txPrebuild that omits txHex', async function () { + const coin = bitgo.coin('teth') as Teth; + const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = (coin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + + const txParams = { + recipients: [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ], + wallet: wallet, + walletPassphrase: 'fakeWalletPassphrase', + }; + + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + // no txHex + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'teth', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + await coin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification: {} }) + .should.be.rejectedWith(/missing txHex required for inner calldata verification/); + }); + + it('should reject a batch txPrebuild whose inner recipient address differs from txParams', async function () { + const coin = bitgo.coin('teth') as Teth; + const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = (coin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + + const userRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + // Server-supplied txHex routes the second payment to an attacker address instead of address2. + const attackerAddress = '0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead'; + + const txParams = { + recipients: userRecipients, + wallet: wallet, + walletPassphrase: 'fakeWalletPassphrase', + }; + + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildBatchTxHex(userRecipients, batcherAddress, { + overrideInnerAddresses: [address1, attackerAddress], + }), + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'teth', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + await coin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification: {} }) + .should.be.rejectedWith('batch txPrebuild inner recipient address does not match txParams'); + }); + + it('should reject a batch txPrebuild whose inner recipient amount differs from txParams', async function () { + const coin = bitgo.coin('teth') as Teth; + const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = (coin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + + const userRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { + recipients: userRecipients, + wallet: wallet, + walletPassphrase: 'fakeWalletPassphrase', + }; + + // Total is unchanged (so outer check passes) but the per-recipient split is tampered. + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildBatchTxHex(userRecipients, batcherAddress, { + overrideInnerAmounts: ['500000000000', '3000000000000'], + }), + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'teth', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + await coin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification: {} }) + .should.be.rejectedWith('batch txPrebuild inner recipient amount does not match txParams'); + }); + + it('should reject a batch txPrebuild whose inner method selector is not batch(address[],uint256[])', async function () { + const coin = bitgo.coin('teth') as Teth; + const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = (coin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + + const userRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { + recipients: userRecipients, + wallet: wallet, + walletPassphrase: 'fakeWalletPassphrase', + }; + + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildBatchTxHex(userRecipients, batcherAddress, { + innerMethodSignature: { name: 'notBatch', types: ['address[]', 'uint256[]'] }, + }), + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'teth', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + await coin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification: {} }) + .should.be.rejectedWith(/inner method selector is not batch/); + }); + + it('should reject a batch txPrebuild whose inner recipient count differs from txParams', async function () { + const coin = bitgo.coin('teth') as Teth; + const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = (coin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + + const userRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { + recipients: userRecipients, + wallet: wallet, + walletPassphrase: 'fakeWalletPassphrase', + }; + + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + // Inner calldata sweeps the full total to a single attacker recipient. + txHex: buildBatchTxHex(userRecipients, batcherAddress, { + overrideInnerAddresses: ['0xabababababababababababababababababababab'], + overrideInnerAmounts: ['3500000000000'], + }), + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'teth', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + await coin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification: {} }) + .should.be.rejectedWith(/inner recipient count \(1\) does not match txParams \(2\)/); + }); + it('should reject a normal txPrebuild from the bitgo server with the wrong amount', async function () { const coin = bitgo.coin('teth') as Teth; const wallet = new Wallet(bitgo, coin, {}); @@ -1057,6 +1320,191 @@ describe('ETH:', function () { .should.be.rejectedWith('Unable to determine base address for consolidation'); }); }); + + describe('TSS batch transactions', function () { + const tssBatcherAddress = () => { + const coin = bitgo.coin('hteth') as Hteth; + return (coin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + }; + + it('should verify a TSS batch txPrebuild whose inner calldata matches txParams.recipients', async function () { + const coin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = tssBatcherAddress(); + const recipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildTssBatchTxHex(recipients, batcherAddress), + coin: 'hteth', + walletId: 'fakeWalletId', + gasPrice: 20000000000, + }; + + const isTransactionVerified = await coin.verifyTransaction({ + txParams: txParams as any, + txPrebuild: txPrebuild as any, + wallet, + verification: {}, + walletType: 'tss', + }); + isTransactionVerified.should.equal(true); + }); + + it('should reject a TSS batch txPrebuild when txHex is missing', async function () { + const coin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, coin, {}); + const recipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: tssBatcherAddress() }], + coin: 'hteth', + walletId: 'fakeWalletId', + gasPrice: 20000000000, + }; + + await coin + .verifyTransaction({ + txParams: txParams as any, + txPrebuild: txPrebuild as any, + wallet, + verification: {}, + walletType: 'tss', + }) + .should.be.rejectedWith(/missing txHex required for inner calldata verification/); + }); + + it('should reject a TSS batch txPrebuild whose outer to is not the batcher contract', async function () { + const coin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = tssBatcherAddress(); + const recipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + // Outer tx target is swapped to an attacker address. + txHex: buildTssBatchTxHex(recipients, batcherAddress, { + overrideTo: '0xabababababababababababababababababababab', + }), + coin: 'hteth', + walletId: 'fakeWalletId', + gasPrice: 20000000000, + }; + + await coin + .verifyTransaction({ + txParams: txParams as any, + txPrebuild: txPrebuild as any, + wallet, + verification: {}, + walletType: 'tss', + }) + .should.be.rejectedWith(/outer to does not match batcher contract address/); + }); + + it('should reject a TSS batch txPrebuild whose outer value does not match the total', async function () { + const coin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = tssBatcherAddress(); + const recipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildTssBatchTxHex(recipients, batcherAddress, { overrideValue: '4000000000000' }), + coin: 'hteth', + walletId: 'fakeWalletId', + gasPrice: 20000000000, + }; + + await coin + .verifyTransaction({ + txParams: txParams as any, + txPrebuild: txPrebuild as any, + wallet, + verification: {}, + walletType: 'tss', + }) + .should.be.rejectedWith(/outer value .* does not match sum of txParams recipients/); + }); + + it('should reject a TSS batch txPrebuild whose inner recipient address differs from txParams', async function () { + const coin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = tssBatcherAddress(); + const recipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildTssBatchTxHex(recipients, batcherAddress, { + overrideInnerAddresses: [address1, '0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead'], + }), + coin: 'hteth', + walletId: 'fakeWalletId', + gasPrice: 20000000000, + }; + + await coin + .verifyTransaction({ + txParams: txParams as any, + txPrebuild: txPrebuild as any, + wallet, + verification: {}, + walletType: 'tss', + }) + .should.be.rejectedWith('batch txPrebuild inner recipient address does not match txParams'); + }); + + it('should reject a TSS batch txPrebuild whose inner selector is not batch(address[],uint256[])', async function () { + const coin = bitgo.coin('hteth') as Hteth; + const wallet = new Wallet(bitgo, coin, {}); + const batcherAddress = tssBatcherAddress(); + const recipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildTssBatchTxHex(recipients, batcherAddress, { + innerMethodSignature: { name: 'notBatch', types: ['address[]', 'uint256[]'] }, + }), + coin: 'hteth', + walletId: 'fakeWalletId', + gasPrice: 20000000000, + }; + + await coin + .verifyTransaction({ + txParams: txParams as any, + txPrebuild: txPrebuild as any, + wallet, + verification: {}, + walletType: 'tss', + }) + .should.be.rejectedWith(/inner method selector is not batch/); + }); + }); }); describe('Address Verification', function () { diff --git a/modules/sdk-coin-opeth/package.json b/modules/sdk-coin-opeth/package.json index c7351bd067..d0d4766c42 100644 --- a/modules/sdk-coin-opeth/package.json +++ b/modules/sdk-coin-opeth/package.json @@ -51,6 +51,8 @@ "devDependencies": { "@bitgo/sdk-api": "^1.79.2", "@bitgo/sdk-test": "^9.1.42", + "@ethereumjs/tx": "^3.3.0", + "bignumber.js": "^9.1.1", "secp256k1": "5.0.1" }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", diff --git a/modules/sdk-coin-opeth/test/unit/opeth.ts b/modules/sdk-coin-opeth/test/unit/opeth.ts index 9230a1fdd0..e5e19059f4 100644 --- a/modules/sdk-coin-opeth/test/unit/opeth.ts +++ b/modules/sdk-coin-opeth/test/unit/opeth.ts @@ -6,8 +6,46 @@ import { common, FullySignedTransaction, TransactionType, Wallet } from '@bitgo/ import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { BitGoAPI } from '@bitgo/sdk-api'; import { OfflineVaultTxInfo, optionalDeps, SignTransactionOptions } from '@bitgo/abstract-eth'; +import { Transaction as LegacyTransaction } from '@ethereumjs/tx'; +import EthereumAbi from 'ethereumjs-abi'; +import { addHexPrefix } from 'ethereumjs-util'; +import BigNumber from 'bignumber.js'; import { Opeth, Topeth, TransactionBuilder, TransferBuilder } from '../../src/index'; + +/** + * Build a valid serialized batch transaction hex. Outer call is sendMultiSig to the wallet contract; + * its inner data is the batch(address[],uint256[]) calldata that goes to the batcher contract. + */ +function buildBatchTxHex( + recipients: { address: string; amount: string }[], + batcherAddress: string, + opts: { overrideInnerAddresses?: string[]; overrideInnerAmounts?: string[] } = {} +): string { + const innerAddresses = opts.overrideInnerAddresses ?? recipients.map((r) => r.address); + const innerAmounts = opts.overrideInnerAmounts ?? recipients.map((r) => r.amount); + const innerData = Buffer.concat([ + EthereumAbi.methodID('batch', ['address[]', 'uint256[]']), + EthereumAbi.rawEncode(['address[]', 'uint256[]'], [innerAddresses, innerAmounts]), + ]); + const total = recipients.reduce((s, r) => s.plus(r.amount), new BigNumber(0)).toFixed(); + const sendMultisigData = Buffer.concat([ + EthereumAbi.methodID('sendMultiSig', ['address', 'uint', 'bytes', 'uint', 'uint', 'bytes']), + EthereumAbi.rawEncode( + ['address', 'uint', 'bytes', 'uint', 'uint', 'bytes'], + [batcherAddress, total, innerData, 1700000000, 1, Buffer.alloc(0)] + ), + ]); + const tx = LegacyTransaction.fromTxData({ + to: '0x' + '11'.repeat(20), + data: addHexPrefix(sendMultisigData.toString('hex')), + nonce: 0, + gasPrice: 20000000000, + gasLimit: 500000, + value: 0, + }); + return addHexPrefix(tx.serialize().toString('hex')); +} import * as mockData from '../fixtures/opeth'; import { getBuilder } from '../getBuilder'; import { EthereumNetwork } from '@bitgo/statics'; @@ -678,6 +716,65 @@ describe('Optimism', function () { .verifyTransaction({ txParams, txPrebuild, wallet, verification }) .should.be.rejectedWith(`recipient address of txPrebuild does not match batcher address`); }); + + it('should verify a native batch txPrebuild whose inner calldata matches txParams.recipients', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const batcherAddress = (basecoin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + const batchRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients: batchRecipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildBatchTxHex(batchRecipients, batcherAddress), + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'topeth', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + const isTransactionVerified = await basecoin.verifyTransaction({ + txParams, + txPrebuild: txPrebuild as any, + wallet, + verification: {}, + }); + isTransactionVerified.should.equal(true); + }); + + it('should reject a native batch txPrebuild whose inner recipient amount differs from txParams', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const batcherAddress = (basecoin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + const batchRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients: batchRecipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + // Total preserved but per-recipient split tampered. + txHex: buildBatchTxHex(batchRecipients, batcherAddress, { + overrideInnerAmounts: ['500000000000', '3000000000000'], + }), + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'topeth', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification: {} }) + .should.be.rejectedWith('batch txPrebuild inner recipient amount does not match txParams'); + }); }); describe('Recover transaction:', function () { diff --git a/modules/sdk-coin-polygon/package.json b/modules/sdk-coin-polygon/package.json index cf9771b5a9..f07398c2eb 100644 --- a/modules/sdk-coin-polygon/package.json +++ b/modules/sdk-coin-polygon/package.json @@ -54,6 +54,7 @@ "devDependencies": { "@bitgo/sdk-api": "^1.79.2", "@bitgo/sdk-test": "^9.1.42", + "@ethereumjs/tx": "^3.3.0", "secp256k1": "5.0.1" }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", diff --git a/modules/sdk-coin-polygon/test/unit/polygon.ts b/modules/sdk-coin-polygon/test/unit/polygon.ts index 646bcf5a16..f70d7a67c6 100644 --- a/modules/sdk-coin-polygon/test/unit/polygon.ts +++ b/modules/sdk-coin-polygon/test/unit/polygon.ts @@ -8,7 +8,45 @@ import * as secp256k1 from 'secp256k1'; import * as should from 'should'; import { Polygon, Tpolygon, TransactionBuilder, TransferBuilder } from '../../src'; import { AbstractEthLikeNewCoins, UnsignedSweepTxMPCv2, OfflineVaultTxInfo, optionalDeps } from '@bitgo/abstract-eth'; +import { EthereumNetwork } from '@bitgo/statics'; +import { Transaction as LegacyTransaction } from '@ethereumjs/tx'; +import EthereumAbi from 'ethereumjs-abi'; +import { addHexPrefix } from 'ethereumjs-util'; import { getBuilder } from '../getBuilder'; + +/** + * Build a valid serialized batch transaction hex. Outer call is sendMultiSig to the wallet contract; + * its inner data is the batch(address[],uint256[]) calldata that goes to the batcher contract. + */ +function buildBatchTxHex( + recipients: { address: string; amount: string }[], + batcherAddress: string, + opts: { overrideInnerAddresses?: string[]; overrideInnerAmounts?: string[] } = {} +): string { + const innerAddresses = opts.overrideInnerAddresses ?? recipients.map((r) => r.address); + const innerAmounts = opts.overrideInnerAmounts ?? recipients.map((r) => r.amount); + const innerData = Buffer.concat([ + EthereumAbi.methodID('batch', ['address[]', 'uint256[]']), + EthereumAbi.rawEncode(['address[]', 'uint256[]'], [innerAddresses, innerAmounts]), + ]); + const total = recipients.reduce((s, r) => s.plus(r.amount), new BigNumber(0)).toFixed(); + const sendMultisigData = Buffer.concat([ + EthereumAbi.methodID('sendMultiSig', ['address', 'uint', 'bytes', 'uint', 'uint', 'bytes']), + EthereumAbi.rawEncode( + ['address', 'uint', 'bytes', 'uint', 'uint', 'bytes'], + [batcherAddress, total, innerData, 1700000000, 1, Buffer.alloc(0)] + ), + ]); + const tx = LegacyTransaction.fromTxData({ + to: '0x' + '11'.repeat(20), + data: addHexPrefix(sendMultisigData.toString('hex')), + nonce: 0, + gasPrice: 20000000000, + gasLimit: 500000, + value: 0, + }); + return addHexPrefix(tx.serialize().toString('hex')); +} import * as mockData from '../fixtures/polygon'; import * as sjcl from '@bitgo/sjcl'; import assert from 'assert'; @@ -481,6 +519,67 @@ describe('Polygon', function () { `tpolygon doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.` ); }); + + it('should verify a batch txPrebuild whose inner calldata matches txParams.recipients', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const batcherAddress = (basecoin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + const batchRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients: batchRecipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + txHex: buildBatchTxHex(batchRecipients, batcherAddress), + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'tpolygon', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + const isTransactionVerified = await basecoin.verifyTransaction({ + txParams, + txPrebuild: txPrebuild as any, + wallet, + verification: {}, + }); + isTransactionVerified.should.equal(true); + }); + + it('should reject a batch txPrebuild whose inner recipient count differs from txParams', async function () { + const wallet = new Wallet(bitgo, basecoin, {}); + const batcherAddress = (basecoin?.staticsCoin?.network as EthereumNetwork).batcherContractAddress as string; + const batchRecipients = [ + { amount: '1000000000000', address: address1 }, + { amount: '2500000000000', address: address2 }, + ]; + + const txParams = { recipients: batchRecipients, wallet, walletPassphrase: 'fakeWalletPassphrase' }; + const txPrebuild = { + recipients: [{ amount: '3500000000000', address: batcherAddress }], + // Inner calldata sweeps to a single attacker address. + txHex: buildBatchTxHex(batchRecipients, batcherAddress, { + overrideInnerAddresses: ['0xabababababababababababababababababababab'], + overrideInnerAmounts: ['3500000000000'], + }), + nextContractSequenceId: 0, + gasPrice: 20000000000, + gasLimit: 500000, + isBatch: true, + coin: 'tpolygon', + walletId: 'fakeWalletId', + walletContractAddress: 'fakeWalletContractAddress', + }; + + await basecoin + .verifyTransaction({ txParams, txPrebuild: txPrebuild as any, wallet, verification: {} }) + .should.be.rejectedWith(/inner recipient count \(1\) does not match txParams \(2\)/); + }); + it('should reject a hop txPrebuild that does not send to its hop address', async function () { const wallet = new Wallet(bitgo, basecoin, {});