Skip to content

Commit 38e258b

Browse files
Merge pull request #8797 from BitGo/COINS-208
fix(abstract-eth): decode ERC20 calldata in consolidation base address
2 parents 880f2f0 + ee5cdca commit 38e258b

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3208,7 +3208,26 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
32083208
throw new Error('Consolidation transaction is missing recipient address');
32093209
}
32103210

3211-
if (txJson.to.toLowerCase() !== baseAddress.toLowerCase()) {
3211+
const erc20TransferSelector = addHexPrefix(
3212+
optionalDeps.ethAbi.methodID('transfer', ['address', 'uint256']).toString('hex')
3213+
);
3214+
3215+
if (txJson.data && txJson.data.startsWith(erc20TransferSelector)) {
3216+
// For ERC20 token consolidations, txJson.to is the token contract address,
3217+
// not the actual transfer recipient. The real recipient is encoded in the
3218+
// transfer(address,uint256) calldata. Decode it and verify against baseAddress.
3219+
const [recipientAddress] = getRawDecoded(
3220+
['address', 'uint256'],
3221+
getBufferedByteCode(erc20TransferSelector, txJson.data)
3222+
);
3223+
const decodedRecipient = addHexPrefix(recipientAddress.toString()).toLowerCase();
3224+
if (decodedRecipient !== baseAddress.toLowerCase()) {
3225+
await throwRecipientMismatch('Consolidation transaction recipient does not match wallet base address', [
3226+
{ address: decodedRecipient, amount: txJson.value },
3227+
]);
3228+
}
3229+
} else if (txJson.to.toLowerCase() !== baseAddress.toLowerCase()) {
3230+
// Native coin consolidation: txJson.to is the actual recipient
32123231
await throwRecipientMismatch('Consolidation transaction recipient does not match wallet base address', [
32133232
{ address: txJson.to, amount: txJson.value },
32143233
]);

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { getBuilder } from './getBuilder';
3434
import * as testData from '../resources/eth';
3535
import * as mockData from '../fixtures/eth';
3636
import should from 'should';
37+
import EthereumAbi from 'ethereumjs-abi';
3738
import { ethMultiSigBackupKey } from './fixtures/ethMultiSigBackupKey';
3839
import { ethTssBackupKey } from './fixtures/ethTssBackupKey';
3940

@@ -1007,6 +1008,76 @@ describe('ETH:', function () {
10071008
.should.be.rejectedWith('missing txHex in txPrebuild');
10081009
});
10091010

1011+
it('should verify ERC20 token consolidation when calldata recipient matches base address', async function () {
1012+
const coin = bitgo.coin('hteth') as Hteth;
1013+
const baseAddress = '0x174cfd823af8ce27ed0afee3fcf3c3ba259116be';
1014+
const tokenContractAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
1015+
1016+
// Build an ERC20 transfer(address, uint256) tx — mimics what the server returns
1017+
// for a v6 TSS wallet token consolidation: txJson.to = token contract,
1018+
// actual recipient is encoded in the 0xa9059cbb calldata
1019+
const methodId = EthereumAbi.methodID('transfer', ['address', 'uint256']);
1020+
const encodedParams = EthereumAbi.rawEncode(['address', 'uint256'], [baseAddress, '10000000']);
1021+
const erc20TransferData = '0x' + Buffer.concat([methodId, encodedParams]).toString('hex');
1022+
1023+
const txBuilder = getBuilder('hteth') as TransactionBuilder;
1024+
txBuilder.type(TransactionType.ContractCall);
1025+
txBuilder.fee({ fee: '10', gasLimit: '60000' });
1026+
txBuilder.counter(1);
1027+
txBuilder.contract(tokenContractAddress);
1028+
txBuilder.data(erc20TransferData);
1029+
const tx = await txBuilder.build();
1030+
const txHex = tx.toBroadcastFormat();
1031+
1032+
const wallet = new Wallet(bitgo, coin, {
1033+
coinSpecific: { baseAddress },
1034+
});
1035+
1036+
const isTransactionVerified = await coin.verifyTransaction({
1037+
txParams: { type: 'consolidate', wallet, walletPassphrase: 'fake' } as any,
1038+
txPrebuild: { consolidateId: 'abc123', txHex, coin: 'hteth', walletId: 'fakeWalletId' } as any,
1039+
wallet,
1040+
verification: { consolidationToBaseAddress: true },
1041+
walletType: 'tss',
1042+
});
1043+
isTransactionVerified.should.equal(true);
1044+
});
1045+
1046+
it('should reject ERC20 token consolidation when calldata recipient does not match base address', async function () {
1047+
const coin = bitgo.coin('hteth') as Hteth;
1048+
const baseAddress = '0x174cfd823af8ce27ed0afee3fcf3c3ba259116be';
1049+
const wrongRecipient = '0x7e85bdc27c050e3905ebf4b8e634d9ad6edd0de6';
1050+
const tokenContractAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';
1051+
1052+
// Build an ERC20 transfer where calldata recipient is a WRONG address
1053+
const methodId = EthereumAbi.methodID('transfer', ['address', 'uint256']);
1054+
const encodedParams = EthereumAbi.rawEncode(['address', 'uint256'], [wrongRecipient, '10000000']);
1055+
const erc20TransferData = '0x' + Buffer.concat([methodId, encodedParams]).toString('hex');
1056+
1057+
const txBuilder = getBuilder('hteth') as TransactionBuilder;
1058+
txBuilder.type(TransactionType.ContractCall);
1059+
txBuilder.fee({ fee: '10', gasLimit: '60000' });
1060+
txBuilder.counter(1);
1061+
txBuilder.contract(tokenContractAddress);
1062+
txBuilder.data(erc20TransferData);
1063+
const tx = await txBuilder.build();
1064+
const txHex = tx.toBroadcastFormat();
1065+
1066+
const wallet = new Wallet(bitgo, coin, {
1067+
coinSpecific: { baseAddress },
1068+
});
1069+
1070+
await coin
1071+
.verifyTransaction({
1072+
txParams: { type: 'consolidate', wallet, walletPassphrase: 'fake' } as any,
1073+
txPrebuild: { consolidateId: 'abc123', txHex, coin: 'hteth', walletId: 'fakeWalletId' } as any,
1074+
wallet,
1075+
verification: { consolidationToBaseAddress: true },
1076+
walletType: 'tss',
1077+
})
1078+
.should.be.rejectedWith('Consolidation transaction recipient does not match wallet base address');
1079+
});
1080+
10101081
it('should throw error when wallet is missing baseAddress for consolidation verification', async function () {
10111082
const coin = bitgo.coin('hteth') as Hteth;
10121083
const baseAddress = '0x174cfd823af8ce27ed0afee3fcf3c3ba259116be';

0 commit comments

Comments
 (0)