Skip to content

Commit 6e0ef40

Browse files
Merge pull request #7563 from BitGo/WP-6461-eth-address-verification
feat: add wallet address verification for eth
2 parents 95cf288 + cf67e8b commit 6e0ef40

File tree

8 files changed

+1018
-203
lines changed

8 files changed

+1018
-203
lines changed

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 266 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ import {
4040
VerifyAddressOptions as BaseVerifyAddressOptions,
4141
VerifyTransactionOptions,
4242
Wallet,
43+
verifyMPCWalletAddress,
44+
TssVerifyAddressOptions,
45+
isTssVerifyAddressOptions,
4346
} from '@bitgo/sdk-core';
4447
import { getDerivationPath } from '@bitgo/sdk-lib-mpc';
4548
import { bip32 } from '@bitgo/secp256k1';
@@ -369,6 +372,7 @@ interface PresignTransactionOptions extends TransactionPrebuild, BasePresignTran
369372
interface EthAddressCoinSpecifics extends AddressCoinSpecific {
370373
forwarderVersion: number;
371374
salt?: string;
375+
feeAddress?: string;
372376
}
373377

374378
export const DEFAULT_SCAN_FACTOR = 20;
@@ -401,7 +405,44 @@ export interface EthConsolidationRecoveryOptions {
401405
export interface VerifyEthAddressOptions extends BaseVerifyAddressOptions {
402406
baseAddress: string;
403407
coinSpecific: EthAddressCoinSpecifics;
404-
forwarderVersion: number;
408+
forwarderVersion?: number;
409+
walletVersion?: number;
410+
}
411+
412+
export type TssVerifyEthAddressOptions = TssVerifyAddressOptions & VerifyEthAddressOptions;
413+
414+
/**
415+
* Keychain with ethAddress for BIP32 wallet verification (V1, V2, V4)
416+
* Used for wallets that derive addresses using Ethereum addresses from keychains
417+
*/
418+
export interface KeychainWithEthAddress {
419+
ethAddress: string;
420+
pub: string;
421+
}
422+
423+
/**
424+
* BIP32 wallet base address verification options
425+
* Supports V1, V2, and V4 wallets that use ethAddress-based derivation
426+
*/
427+
export interface VerifyContractBaseAddressOptions extends VerifyEthAddressOptions {
428+
walletVersion: number;
429+
keychains: KeychainWithEthAddress[];
430+
}
431+
432+
/**
433+
* Type guard to check if params are for BIP32 base address verification (V1, V2, V4)
434+
* These wallet versions use ethAddress for address derivation
435+
*/
436+
export function isVerifyContractBaseAddressOptions(
437+
params: VerifyEthAddressOptions | TssVerifyEthAddressOptions
438+
): params is VerifyContractBaseAddressOptions {
439+
return (
440+
(params.walletVersion === 1 || params.walletVersion === 2 || params.walletVersion === 4) &&
441+
'keychains' in params &&
442+
Array.isArray(params.keychains) &&
443+
params.keychains.length === 3 &&
444+
params.keychains.every((kc: any) => 'ethAddress' in kc && typeof kc.ethAddress === 'string')
445+
);
405446
}
406447

407448
const debug = debugLib('bitgo:v2:ethlike');
@@ -2725,6 +2766,186 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
27252766
return {};
27262767
}
27272768

2769+
/**
2770+
* Get forwarder factory and implementation addresses for deposit address verification.
2771+
* Forwarders are smart contracts that forward funds to the base wallet address.
2772+
*
2773+
* @param {number | undefined} forwarderVersion - The wallet version
2774+
* @returns {object} Factory and implementation addresses for forwarders
2775+
*/
2776+
getForwarderFactoryAddressesAndForwarderImplementationAddress(forwarderVersion: number | undefined): {
2777+
forwarderFactoryAddress: string;
2778+
forwarderImplementationAddress: string;
2779+
} {
2780+
const ethNetwork = this.getNetwork();
2781+
2782+
switch (forwarderVersion) {
2783+
case 1:
2784+
if (!ethNetwork?.forwarderFactoryAddress || !ethNetwork?.forwarderImplementationAddress) {
2785+
throw new Error('Forwarder factory addresses not configured for this network');
2786+
}
2787+
return {
2788+
forwarderFactoryAddress: ethNetwork.forwarderFactoryAddress,
2789+
forwarderImplementationAddress: ethNetwork.forwarderImplementationAddress,
2790+
};
2791+
case 2:
2792+
if (!ethNetwork?.walletV2ForwarderFactoryAddress || !ethNetwork?.walletV2ForwarderImplementationAddress) {
2793+
throw new Error('Wallet v2 factory addresses not configured for this network');
2794+
}
2795+
return {
2796+
forwarderFactoryAddress: ethNetwork.walletV2ForwarderFactoryAddress,
2797+
forwarderImplementationAddress: ethNetwork.walletV2ForwarderImplementationAddress,
2798+
};
2799+
case 4:
2800+
case 5:
2801+
if (!ethNetwork?.walletV4ForwarderFactoryAddress || !ethNetwork?.walletV4ForwarderImplementationAddress) {
2802+
throw new Error(`Forwarder v${forwarderVersion} factory addresses not configured for this network`);
2803+
}
2804+
return {
2805+
forwarderFactoryAddress: ethNetwork.walletV4ForwarderFactoryAddress,
2806+
forwarderImplementationAddress: ethNetwork.walletV4ForwarderImplementationAddress,
2807+
};
2808+
default:
2809+
throw new Error(`Forwarder version ${forwarderVersion} not supported`);
2810+
}
2811+
}
2812+
2813+
/**
2814+
* Get wallet base address factory and implementation addresses.
2815+
* This is used for base address verification for V1, V2, V4, and V5 wallets.
2816+
* The base address is the main wallet contract deployed via CREATE2.
2817+
*
2818+
* @param {number} walletVersion - The wallet version (1, 2, 4, or 5)
2819+
* @returns {object} Factory and implementation addresses for the wallet base address
2820+
* @throws {Error} if wallet version addresses are not configured
2821+
*/
2822+
getWalletAddressFactoryAddressesAndImplementationAddress(walletVersion: number): {
2823+
walletFactoryAddress: string;
2824+
walletImplementationAddress: string;
2825+
} {
2826+
const ethNetwork = this.getNetwork();
2827+
2828+
switch (walletVersion) {
2829+
case 1:
2830+
if (!ethNetwork?.walletFactoryAddress || !ethNetwork?.walletImplementationAddress) {
2831+
throw new Error('Wallet v1 factory addresses not configured for this network');
2832+
}
2833+
return {
2834+
walletFactoryAddress: ethNetwork.walletFactoryAddress,
2835+
walletImplementationAddress: ethNetwork.walletImplementationAddress,
2836+
};
2837+
case 2:
2838+
if (!ethNetwork?.walletV2FactoryAddress || !ethNetwork?.walletV2ImplementationAddress) {
2839+
throw new Error('Wallet v2 factory addresses not configured for this network');
2840+
}
2841+
return {
2842+
walletFactoryAddress: ethNetwork.walletV2FactoryAddress,
2843+
walletImplementationAddress: ethNetwork.walletV2ImplementationAddress,
2844+
};
2845+
case 4:
2846+
case 5:
2847+
if (!ethNetwork?.walletV4ForwarderFactoryAddress || !ethNetwork?.walletV4ForwarderImplementationAddress) {
2848+
throw new Error(`Wallet v${walletVersion} factory addresses not configured for this network`);
2849+
}
2850+
return {
2851+
walletFactoryAddress: ethNetwork.walletV4ForwarderFactoryAddress,
2852+
walletImplementationAddress: ethNetwork.walletV4ForwarderImplementationAddress,
2853+
};
2854+
default:
2855+
throw new Error(`Wallet version ${walletVersion} not supported`);
2856+
}
2857+
}
2858+
2859+
/**
2860+
* Helper method to create a salt buffer from hex string.
2861+
* Converts a hex salt string to a 32-byte buffer.
2862+
*
2863+
* @param {string} salt - The hex salt string
2864+
* @returns {Buffer} 32-byte salt buffer
2865+
*/
2866+
private createSaltBuffer(salt: string): Buffer {
2867+
const ethUtil = optionalDeps.ethUtil;
2868+
return ethUtil.setLengthLeft(Buffer.from(ethUtil.padToEven(ethUtil.stripHexPrefix(salt || '')), 'hex'), 32);
2869+
}
2870+
2871+
/**
2872+
* Verify BIP32 wallet base address (V1, V2, V4).
2873+
* These wallets use a wallet factory to deploy base addresses with CREATE2.
2874+
* The address is derived from the keychains' ethAddresses and a salt.
2875+
*
2876+
* @param {VerifyBip32BaseAddressOptions} params - Verification parameters
2877+
* @returns {object} Expected and actual addresses for comparison
2878+
*/
2879+
private verifyCreate2BaseAddress(params: VerifyContractBaseAddressOptions): boolean {
2880+
const { address, coinSpecific, keychains, walletVersion } = params;
2881+
2882+
if (!coinSpecific.salt) {
2883+
throw new Error(`missing salt for v${walletVersion} base address verification`);
2884+
}
2885+
2886+
// Get wallet factory and implementation addresses for the wallet version
2887+
const { walletFactoryAddress, walletImplementationAddress } =
2888+
this.getWalletAddressFactoryAddressesAndImplementationAddress(walletVersion);
2889+
const initcode = getProxyInitcode(walletImplementationAddress);
2890+
2891+
// Convert the wallet salt to a buffer, pad to 32 bytes
2892+
const saltBuffer = this.createSaltBuffer(coinSpecific.salt);
2893+
2894+
// Reconstruct calculationSalt using keychains' ethAddresses and wallet salt
2895+
const ethAddresses = keychains.map((kc) => {
2896+
if (!kc.ethAddress) {
2897+
throw new Error(`keychain missing ethAddress for v${walletVersion} base address verification`);
2898+
}
2899+
return kc.ethAddress;
2900+
});
2901+
2902+
const calculationSalt = optionalDeps.ethUtil.bufferToHex(
2903+
optionalDeps.ethAbi.soliditySHA3(['address[]', 'bytes32'], [ethAddresses, saltBuffer])
2904+
);
2905+
2906+
const expectedAddress = calculateForwarderV1Address(walletFactoryAddress, calculationSalt, initcode);
2907+
2908+
if (expectedAddress !== address) {
2909+
throw new UnexpectedAddressError(`address validation failure: expected ${expectedAddress} but got ${address}`);
2910+
}
2911+
2912+
return true;
2913+
}
2914+
2915+
/**
2916+
* Verify forwarder receive address (deposit address).
2917+
* Forwarder addresses are derived using CREATE2 from the base address and salt.
2918+
*
2919+
* @param {VerifyEthAddressOptions} params - Verification parameters
2920+
* @param {number} forwarderVersion - The forwarder version
2921+
* @returns {object} Expected and actual addresses for comparison
2922+
*/
2923+
private verifyForwarderAddress(params: VerifyEthAddressOptions, forwarderVersion: number): boolean {
2924+
const { address, coinSpecific, baseAddress } = params;
2925+
2926+
const { forwarderFactoryAddress, forwarderImplementationAddress } =
2927+
this.getForwarderFactoryAddressesAndForwarderImplementationAddress(forwarderVersion);
2928+
const initcode = getProxyInitcode(forwarderImplementationAddress);
2929+
const saltBuffer = this.createSaltBuffer(coinSpecific.salt || '');
2930+
2931+
const { createForwarderParams, createForwarderTypes } =
2932+
forwarderVersion === 4
2933+
? getCreateForwarderParamsAndTypes(baseAddress, saltBuffer, coinSpecific.feeAddress)
2934+
: getCreateForwarderParamsAndTypes(baseAddress, saltBuffer);
2935+
2936+
const calculationSalt = optionalDeps.ethUtil.bufferToHex(
2937+
optionalDeps.ethAbi.soliditySHA3(createForwarderTypes, createForwarderParams)
2938+
);
2939+
2940+
const expectedAddress = calculateForwarderV1Address(forwarderFactoryAddress, calculationSalt, initcode);
2941+
2942+
if (expectedAddress !== address) {
2943+
throw new UnexpectedAddressError(`address validation failure: expected ${expectedAddress} but got ${address}`);
2944+
}
2945+
2946+
return true;
2947+
}
2948+
27282949
/**
27292950
* Make sure an address is a wallet address and throw an error if it's not.
27302951
* @param {Object} params
@@ -2736,19 +2957,46 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
27362957
* @throws {UnexpectedAddressError}
27372958
* @returns {boolean} True iff address is a wallet address
27382959
*/
2739-
async isWalletAddress(params: VerifyEthAddressOptions): Promise<boolean> {
2740-
const ethUtil = optionalDeps.ethUtil;
2741-
2742-
let expectedAddress;
2743-
let actualAddress;
2744-
2745-
const { address, coinSpecific, baseAddress, impliedForwarderVersion = coinSpecific?.forwarderVersion } = params;
2960+
async isWalletAddress(params: VerifyEthAddressOptions | TssVerifyEthAddressOptions): Promise<boolean> {
2961+
const { address, impliedForwarderVersion, coinSpecific, baseAddress } = params;
2962+
const forwarderVersion = impliedForwarderVersion ?? coinSpecific?.forwarderVersion;
27462963

2964+
// Validate address format
27472965
if (address && !this.isValidAddress(address)) {
27482966
throw new InvalidAddressError(`invalid address: ${address}`);
27492967
}
27502968

2751-
// base address is required to calculate the salt which is used in calculateForwarderV1Address method
2969+
// Forwarder version 0 addresses cannot be verified because we do not store the nonce value required for address derivation.
2970+
if (forwarderVersion === 0) {
2971+
return true;
2972+
}
2973+
2974+
// Determine if we are verifying a base address
2975+
const isVerifyingBaseAddress = baseAddress && address === baseAddress;
2976+
2977+
// TSS/MPC wallet address verification (V3, V5, V6)
2978+
// V5 base addresses use TSS, but V5 forwarders use the regular forwarder verification
2979+
const isTssWalletVersion = params.walletVersion === 3 || params.walletVersion === 5 || params.walletVersion === 6;
2980+
const shouldUseTssVerification =
2981+
isTssVerifyAddressOptions(params) && isTssWalletVersion && (params.walletVersion !== 5 || isVerifyingBaseAddress);
2982+
2983+
if (shouldUseTssVerification) {
2984+
if (isVerifyingBaseAddress) {
2985+
const index = typeof params.index === 'string' ? parseInt(params.index, 10) : params.index;
2986+
if (index !== 0) {
2987+
throw new Error(
2988+
`Base address verification requires index 0, but got index ${params.index}. ` +
2989+
`The base address is always derived at index 0.`
2990+
);
2991+
}
2992+
}
2993+
2994+
return verifyMPCWalletAddress({ ...params, keyCurve: 'secp256k1' }, this.isValidAddress, (pubKey) => {
2995+
return new KeyPairLib({ pub: pubKey }).getAddress();
2996+
});
2997+
}
2998+
2999+
// From here on, we need baseAddress and coinSpecific for non-TSS verifications
27523000
if (_.isUndefined(baseAddress) || !this.isValidAddress(baseAddress)) {
27533001
throw new InvalidAddressError('invalid base address');
27543002
}
@@ -2759,33 +3007,18 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
27593007
);
27603008
}
27613009

2762-
if (impliedForwarderVersion === 0 || impliedForwarderVersion === 3 || impliedForwarderVersion === 5) {
2763-
return true;
2764-
} else {
2765-
const ethNetwork = this.getNetwork();
2766-
const forwarderFactoryAddress = ethNetwork?.forwarderFactoryAddress as string;
2767-
const forwarderImplementationAddress = ethNetwork?.forwarderImplementationAddress as string;
2768-
2769-
const initcode = getProxyInitcode(forwarderImplementationAddress);
2770-
const saltBuffer = ethUtil.setLengthLeft(
2771-
Buffer.from(ethUtil.padToEven(ethUtil.stripHexPrefix(coinSpecific.salt || '')), 'hex'),
2772-
32
2773-
);
2774-
2775-
// Hash the wallet base address with the given salt, so the address directly relies on the base address
2776-
const calculationSalt = optionalDeps.ethUtil.bufferToHex(
2777-
optionalDeps.ethAbi.soliditySHA3(['address', 'bytes32'], [baseAddress, saltBuffer])
2778-
);
2779-
2780-
expectedAddress = calculateForwarderV1Address(forwarderFactoryAddress, calculationSalt, initcode);
2781-
actualAddress = address;
3010+
// BIP32 wallet base address verification (V1, V2, V4)
3011+
if (isVerifyingBaseAddress && isVerifyContractBaseAddressOptions(params)) {
3012+
return this.verifyCreate2BaseAddress(params);
27823013
}
27833014

2784-
if (expectedAddress !== actualAddress) {
2785-
throw new UnexpectedAddressError(`address validation failure: expected ${expectedAddress} but got ${address}`);
3015+
// Forwarder receive address verification (deposit addresses)
3016+
if (!isVerifyingBaseAddress) {
3017+
return this.verifyForwarderAddress(params, forwarderVersion);
27863018
}
27873019

2788-
return true;
3020+
// If we reach here, it's a base address verification for an unsupported wallet version
3021+
throw new Error(`Base address verification not supported for wallet version ${params.walletVersion}`);
27893022
}
27903023

27913024
/**
@@ -3056,7 +3289,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
30563289
}
30573290
const typedDataRaw = JSON.parse(typedData.typedDataRaw);
30583291
const sanitizedData = TypedDataUtils.sanitizeData(typedDataRaw as unknown as TypedMessage<any>);
3059-
const parts = [Buffer.from('1901', 'hex')];
3292+
const parts: Buffer[] = [Buffer.from('1901', 'hex')];
30603293
const eip712Domain = 'EIP712Domain';
30613294
parts.push(TypedDataUtils.hashStruct(eip712Domain, sanitizedData.domain, sanitizedData.types, version));
30623295

0 commit comments

Comments
 (0)