Skip to content
Open
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
21 changes: 20 additions & 1 deletion modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3208,7 +3208,26 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
throw new Error('Consolidation transaction is missing recipient address');
}

if (txJson.to.toLowerCase() !== baseAddress.toLowerCase()) {
const erc20TransferSelector = addHexPrefix(
optionalDeps.ethAbi.methodID('transfer', ['address', 'uint256']).toString('hex')
);

if (txJson.data && txJson.data.startsWith(erc20TransferSelector)) {
// For ERC20 token consolidations, txJson.to is the token contract address,
// not the actual transfer recipient. The real recipient is encoded in the
// transfer(address,uint256) calldata. Decode it and verify against baseAddress.
const [recipientAddress] = getRawDecoded(
['address', 'uint256'],
getBufferedByteCode(erc20TransferSelector, txJson.data)
);
const decodedRecipient = addHexPrefix(recipientAddress.toString()).toLowerCase();
if (decodedRecipient !== baseAddress.toLowerCase()) {
await throwRecipientMismatch('Consolidation transaction recipient does not match wallet base address', [
{ address: decodedRecipient, amount: txJson.value },
]);
}
} else if (txJson.to.toLowerCase() !== baseAddress.toLowerCase()) {
// Native coin consolidation: txJson.to is the actual recipient
await throwRecipientMismatch('Consolidation transaction recipient does not match wallet base address', [
{ address: txJson.to, amount: txJson.value },
]);
Expand Down
71 changes: 71 additions & 0 deletions modules/sdk-coin-eth/test/unit/eth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { getBuilder } from './getBuilder';
import * as testData from '../resources/eth';
import * as mockData from '../fixtures/eth';
import should from 'should';
import EthereumAbi from 'ethereumjs-abi';
import { ethMultiSigBackupKey } from './fixtures/ethMultiSigBackupKey';
import { ethTssBackupKey } from './fixtures/ethTssBackupKey';

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

it('should verify ERC20 token consolidation when calldata recipient matches base address', async function () {
const coin = bitgo.coin('hteth') as Hteth;
const baseAddress = '0x174cfd823af8ce27ed0afee3fcf3c3ba259116be';
const tokenContractAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';

// Build an ERC20 transfer(address, uint256) tx — mimics what the server returns
// for a v6 TSS wallet token consolidation: txJson.to = token contract,
// actual recipient is encoded in the 0xa9059cbb calldata
const methodId = EthereumAbi.methodID('transfer', ['address', 'uint256']);
const encodedParams = EthereumAbi.rawEncode(['address', 'uint256'], [baseAddress, '10000000']);
const erc20TransferData = '0x' + Buffer.concat([methodId, encodedParams]).toString('hex');

const txBuilder = getBuilder('hteth') as TransactionBuilder;
txBuilder.type(TransactionType.ContractCall);
txBuilder.fee({ fee: '10', gasLimit: '60000' });
txBuilder.counter(1);
txBuilder.contract(tokenContractAddress);
txBuilder.data(erc20TransferData);
const tx = await txBuilder.build();
const txHex = tx.toBroadcastFormat();

const wallet = new Wallet(bitgo, coin, {
coinSpecific: { baseAddress },
});

const isTransactionVerified = await coin.verifyTransaction({
txParams: { type: 'consolidate', wallet, walletPassphrase: 'fake' } as any,
txPrebuild: { consolidateId: 'abc123', txHex, coin: 'hteth', walletId: 'fakeWalletId' } as any,
wallet,
verification: { consolidationToBaseAddress: true },
walletType: 'tss',
});
isTransactionVerified.should.equal(true);
});

it('should reject ERC20 token consolidation when calldata recipient does not match base address', async function () {
const coin = bitgo.coin('hteth') as Hteth;
const baseAddress = '0x174cfd823af8ce27ed0afee3fcf3c3ba259116be';
const wrongRecipient = '0x7e85bdc27c050e3905ebf4b8e634d9ad6edd0de6';
const tokenContractAddress = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48';

// Build an ERC20 transfer where calldata recipient is a WRONG address
const methodId = EthereumAbi.methodID('transfer', ['address', 'uint256']);
const encodedParams = EthereumAbi.rawEncode(['address', 'uint256'], [wrongRecipient, '10000000']);
const erc20TransferData = '0x' + Buffer.concat([methodId, encodedParams]).toString('hex');

const txBuilder = getBuilder('hteth') as TransactionBuilder;
txBuilder.type(TransactionType.ContractCall);
txBuilder.fee({ fee: '10', gasLimit: '60000' });
txBuilder.counter(1);
txBuilder.contract(tokenContractAddress);
txBuilder.data(erc20TransferData);
const tx = await txBuilder.build();
const txHex = tx.toBroadcastFormat();

const wallet = new Wallet(bitgo, coin, {
coinSpecific: { baseAddress },
});

await coin
.verifyTransaction({
txParams: { type: 'consolidate', wallet, walletPassphrase: 'fake' } as any,
txPrebuild: { consolidateId: 'abc123', txHex, coin: 'hteth', walletId: 'fakeWalletId' } as any,
wallet,
verification: { consolidationToBaseAddress: true },
walletType: 'tss',
})
.should.be.rejectedWith('Consolidation transaction recipient does not match wallet base address');
});

it('should throw error when wallet is missing baseAddress for consolidation verification', async function () {
const coin = bitgo.coin('hteth') as Hteth;
const baseAddress = '0x174cfd823af8ce27ed0afee3fcf3c3ba259116be';
Expand Down
Loading