@@ -23,6 +23,7 @@ import {
2323 MPCTx ,
2424 MPCTxs ,
2525 ParsedTransaction ,
26+ ITransactionRecipient ,
2627 ParseTransactionOptions ,
2728 PrebuildTransactionResult ,
2829 PresignTransactionOptions as BasePresignTransactionOptions ,
@@ -71,8 +72,11 @@ import secp256k1 from 'secp256k1';
7172import { AbstractEthLikeCoin } from './abstractEthLikeCoin' ;
7273import { EthLikeToken } from './ethLikeToken' ;
7374import {
75+ batchMethodId ,
7476 calculateForwarderV1Address ,
7577 coinFamiliesWithL1Fees ,
78+ decodeBatchTransferData ,
79+ decodeNativeTransferData ,
7680 decodeTransferData ,
7781 ERC1155TransferBuilder ,
7882 ERC721TransferBuilder ,
@@ -83,6 +87,7 @@ import {
8387 getRawDecoded ,
8488 getToken ,
8589 KeyPair as KeyPairLib ,
90+ sendMultisigMethodId ,
8691 TransactionBuilder ,
8792 TransferBuilder ,
8893} from './lib' ;
@@ -1633,6 +1638,152 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
16331638 } ;
16341639 }
16351640
1641+ /**
1642+ * Verify that the inner batch(address[],uint256[]) calldata embedded in txPrebuild.txHex matches
1643+ * the user-supplied recipients. Used by the multi-sig (sendMultiSig) batch path. Throws via
1644+ * throwRecipientMismatch if any pair differs or if the calldata cannot be decoded. Fails closed:
1645+ * missing txHex, an unexpected outer selector, or an unexpected inner selector all reject.
1646+ */
1647+ private async verifyBatchInnerRecipients (
1648+ txPrebuild : TransactionPrebuild ,
1649+ recipients : ITransactionRecipient [ ] ,
1650+ throwRecipientMismatch : ( message : string , mismatchedRecipients : Recipient [ ] ) => Promise < never >
1651+ ) : Promise < void > {
1652+ if ( ! txPrebuild . txHex ) {
1653+ await throwRecipientMismatch ( 'batch txPrebuild missing txHex required for inner calldata verification' , [ ] ) ;
1654+ return ;
1655+ }
1656+
1657+ let outerCalldata : string ;
1658+ try {
1659+ const txBuffer = optionalDeps . ethUtil . toBuffer ( txPrebuild . txHex ) ;
1660+ const decodedTx = optionalDeps . EthTx . TransactionFactory . fromSerializedData ( txBuffer ) ;
1661+ outerCalldata = optionalDeps . ethUtil . bufferToHex ( decodedTx . data ) ;
1662+ } catch ( e ) {
1663+ await throwRecipientMismatch ( `failed to parse batch txHex: ${ e instanceof Error ? e . message : String ( e ) } ` , [ ] ) ;
1664+ return ;
1665+ }
1666+
1667+ if ( ! outerCalldata . toLowerCase ( ) . startsWith ( sendMultisigMethodId ) ) {
1668+ await throwRecipientMismatch ( 'batch txPrebuild outer call is not sendMultiSig' , [ ] ) ;
1669+ return ;
1670+ }
1671+
1672+ let innerBatchData : string ;
1673+ try {
1674+ innerBatchData = decodeNativeTransferData ( outerCalldata ) . data ;
1675+ } catch ( e ) {
1676+ await throwRecipientMismatch (
1677+ `failed to decode outer sendMultiSig wrapper: ${ e instanceof Error ? e . message : String ( e ) } ` ,
1678+ [ ]
1679+ ) ;
1680+ return ;
1681+ }
1682+
1683+ await this . compareBatchCalldataAgainstRecipients ( innerBatchData , recipients , throwRecipientMismatch ) ;
1684+ }
1685+
1686+ /**
1687+ * Verify that the batch(address[],uint256[]) calldata embedded directly in the outer TSS
1688+ * transaction matches the user-supplied recipients. TSS wallets are EOAs controlled by MPC keys
1689+ * and call the batcher contract directly, so the outer tx.data IS the batch calldata (no
1690+ * sendMultiSig wrapper). Verifies the outer to == batcherContractAddress and the outer value
1691+ * matches the total amount, then decodes and compares each inner (address, amount) pair.
1692+ */
1693+ private async verifyTssBatchInnerRecipients (
1694+ txPrebuild : TransactionPrebuild ,
1695+ recipients : ITransactionRecipient [ ] ,
1696+ batcherContractAddress : string ,
1697+ throwRecipientMismatch : ( message : string , mismatchedRecipients : Recipient [ ] ) => Promise < never >
1698+ ) : Promise < void > {
1699+ if ( ! txPrebuild . txHex ) {
1700+ await throwRecipientMismatch ( 'batch txPrebuild missing txHex required for inner calldata verification' , [ ] ) ;
1701+ return ;
1702+ }
1703+
1704+ let outerTo : string ;
1705+ let outerValue : string ;
1706+ let outerCalldata : string ;
1707+ try {
1708+ const txBuffer = optionalDeps . ethUtil . toBuffer ( txPrebuild . txHex ) ;
1709+ const decodedTx = optionalDeps . EthTx . TransactionFactory . fromSerializedData ( txBuffer ) ;
1710+ outerTo = decodedTx . to ? decodedTx . to . toString ( ) : '' ;
1711+ outerValue = decodedTx . value . toString ( ) ;
1712+ outerCalldata = optionalDeps . ethUtil . bufferToHex ( decodedTx . data ) ;
1713+ } catch ( e ) {
1714+ await throwRecipientMismatch ( `failed to parse batch txHex: ${ e instanceof Error ? e . message : String ( e ) } ` , [ ] ) ;
1715+ return ;
1716+ }
1717+
1718+ if ( ! outerTo || outerTo . toLowerCase ( ) !== batcherContractAddress . toLowerCase ( ) ) {
1719+ await throwRecipientMismatch ( 'batch txPrebuild outer to does not match batcher contract address' , [
1720+ { address : outerTo , amount : outerValue } ,
1721+ ] ) ;
1722+ return ;
1723+ }
1724+
1725+ const expectedTotal = recipients
1726+ . reduce ( ( sum , r ) => sum . plus ( new BigNumber ( r . amount as string | number ) ) , new BigNumber ( 0 ) )
1727+ . toFixed ( ) ;
1728+ if ( ! new BigNumber ( outerValue ) . isEqualTo ( expectedTotal ) ) {
1729+ await throwRecipientMismatch (
1730+ `batch txPrebuild outer value (${ outerValue } ) does not match sum of txParams recipients (${ expectedTotal } )` ,
1731+ [ { address : outerTo , amount : outerValue } ]
1732+ ) ;
1733+ return ;
1734+ }
1735+
1736+ await this . compareBatchCalldataAgainstRecipients ( outerCalldata , recipients , throwRecipientMismatch ) ;
1737+ }
1738+
1739+ /**
1740+ * Shared comparator: verify that the given batch calldata starts with the batch selector,
1741+ * decode it, and compare each inner (address, amount) pair to the user-supplied recipients.
1742+ */
1743+ private async compareBatchCalldataAgainstRecipients (
1744+ batchCalldata : string ,
1745+ recipients : ITransactionRecipient [ ] ,
1746+ throwRecipientMismatch : ( message : string , mismatchedRecipients : Recipient [ ] ) => Promise < never >
1747+ ) : Promise < void > {
1748+ if ( ! batchCalldata || ! batchCalldata . toLowerCase ( ) . startsWith ( batchMethodId ) ) {
1749+ await throwRecipientMismatch ( 'batch txPrebuild inner method selector is not batch(address[],uint256[])' , [ ] ) ;
1750+ return ;
1751+ }
1752+
1753+ let decoded ;
1754+ try {
1755+ decoded = decodeBatchTransferData ( batchCalldata ) ;
1756+ } catch ( e ) {
1757+ await throwRecipientMismatch (
1758+ `failed to decode inner batch calldata: ${ e instanceof Error ? e . message : String ( e ) } ` ,
1759+ [ ]
1760+ ) ;
1761+ return ;
1762+ }
1763+
1764+ if ( decoded . recipients . length !== recipients . length ) {
1765+ await throwRecipientMismatch (
1766+ `batch txPrebuild inner recipient count (${ decoded . recipients . length } ) does not match txParams (${ recipients . length } )` ,
1767+ decoded . recipients
1768+ ) ;
1769+ return ;
1770+ }
1771+
1772+ for ( let i = 0 ; i < recipients . length ; i ++ ) {
1773+ const expected = recipients [ i ] ;
1774+ const actual = decoded . recipients [ i ] ;
1775+ // Skip address comparison for non-hex inputs (e.g. unresolved ENS); mirrors normal-tx path.
1776+ if ( this . isETHAddress ( expected . address ) && expected . address . toLowerCase ( ) !== actual . address . toLowerCase ( ) ) {
1777+ await throwRecipientMismatch ( 'batch txPrebuild inner recipient address does not match txParams' , [ actual ] ) ;
1778+ return ;
1779+ }
1780+ if ( ! new BigNumber ( expected . amount ) . isEqualTo ( actual . amount ) ) {
1781+ await throwRecipientMismatch ( 'batch txPrebuild inner recipient amount does not match txParams' , [ actual ] ) ;
1782+ return ;
1783+ }
1784+ }
1785+ }
1786+
16361787 /**
16371788 * Extract recipients from transaction hex
16381789 * @param txHex - The transaction hex string
@@ -3088,6 +3239,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
30883239 * @throws {TxIntentMismatchRecipientError } if transaction recipients don't match user intent
30893240 */
30903241 async verifyTssTransaction ( params : VerifyEthTransactionOptions ) : Promise < boolean > {
3242+ const ethNetwork = this . getNetwork ( ) ;
30913243 const { txParams, txPrebuild, wallet } = params ;
30923244
30933245 // Helper to throw TxIntentMismatchRecipientError with recipient details
@@ -3123,6 +3275,23 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
31233275 throw new Error ( 'tx cannot be both a batch and hop transaction' ) ;
31243276 }
31253277
3278+ // TSS batch sends call the batcher contract directly (no sendMultiSig wrapper). Decode the
3279+ // inner batch calldata and compare each (address, amount) pair to user intent. Token batches
3280+ // are not supported through the same pattern, so they keep existing behavior.
3281+ if ( ! txParams . tokenName && txParams . recipients && txParams . recipients . length > 1 ) {
3282+ const batcherContractAddress = ethNetwork ?. batcherContractAddress ;
3283+ if ( ! batcherContractAddress ) {
3284+ await throwRecipientMismatch ( 'batch txPrebuild for tss has no configured batcher contract address' , [ ] ) ;
3285+ } else {
3286+ await this . verifyTssBatchInnerRecipients (
3287+ txPrebuild ,
3288+ txParams . recipients ,
3289+ batcherContractAddress ,
3290+ throwRecipientMismatch
3291+ ) ;
3292+ }
3293+ }
3294+
31263295 if ( txParams . type && [ 'transfer' ] . includes ( txParams . type ) ) {
31273296 if ( txParams . recipients && txParams . recipients . length === 1 ) {
31283297 const recipients = txParams . recipients ;
@@ -3325,6 +3494,13 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
33253494 { address : txPrebuild . recipients [ 0 ] . address , amount : txPrebuild . recipients [ 0 ] . amount . toString ( ) } ,
33263495 ] ) ;
33273496 }
3497+
3498+ // Decode the inner batch(address[],uint256[]) calldata and verify each (address, amount) pair
3499+ // matches user intent. Without this, a compromised platform could swap inner recipients while
3500+ // preserving the outer total amount and batcher-address checks.
3501+ if ( ! txParams . tokenName ) {
3502+ await this . verifyBatchInnerRecipients ( txPrebuild , recipients , throwRecipientMismatch ) ;
3503+ }
33283504 } else {
33293505 // Check recipient address and amount for normal transaction
33303506 if ( recipients . length !== 1 ) {
0 commit comments