Skip to content
Merged
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
299 changes: 266 additions & 33 deletions modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ import {
VerifyAddressOptions as BaseVerifyAddressOptions,
VerifyTransactionOptions,
Wallet,
verifyMPCWalletAddress,
TssVerifyAddressOptions,
isTssVerifyAddressOptions,
} from '@bitgo/sdk-core';
import { getDerivationPath } from '@bitgo/sdk-lib-mpc';
import { bip32 } from '@bitgo/secp256k1';
Expand Down Expand Up @@ -369,6 +372,7 @@ interface PresignTransactionOptions extends TransactionPrebuild, BasePresignTran
interface EthAddressCoinSpecifics extends AddressCoinSpecific {
forwarderVersion: number;
salt?: string;
feeAddress?: string;
}

export const DEFAULT_SCAN_FACTOR = 20;
Expand Down Expand Up @@ -401,7 +405,44 @@ export interface EthConsolidationRecoveryOptions {
export interface VerifyEthAddressOptions extends BaseVerifyAddressOptions {
baseAddress: string;
coinSpecific: EthAddressCoinSpecifics;
forwarderVersion: number;
forwarderVersion?: number;
walletVersion?: number;
}

export type TssVerifyEthAddressOptions = TssVerifyAddressOptions & VerifyEthAddressOptions;

/**
* Keychain with ethAddress for BIP32 wallet verification (V1, V2, V4)
* Used for wallets that derive addresses using Ethereum addresses from keychains
*/
export interface KeychainWithEthAddress {
ethAddress: string;
pub: string;
}

/**
* BIP32 wallet base address verification options
* Supports V1, V2, and V4 wallets that use ethAddress-based derivation
*/
export interface VerifyContractBaseAddressOptions extends VerifyEthAddressOptions {
walletVersion: number;
keychains: KeychainWithEthAddress[];
}

/**
* Type guard to check if params are for BIP32 base address verification (V1, V2, V4)
* These wallet versions use ethAddress for address derivation
*/
export function isVerifyContractBaseAddressOptions(
params: VerifyEthAddressOptions | TssVerifyEthAddressOptions
): params is VerifyContractBaseAddressOptions {
return (
(params.walletVersion === 1 || params.walletVersion === 2 || params.walletVersion === 4) &&
'keychains' in params &&
Array.isArray(params.keychains) &&
params.keychains.length === 3 &&
params.keychains.every((kc: any) => 'ethAddress' in kc && typeof kc.ethAddress === 'string')
);
}

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

/**
* Get forwarder factory and implementation addresses for deposit address verification.
* Forwarders are smart contracts that forward funds to the base wallet address.
*
* @param {number | undefined} forwarderVersion - The wallet version
* @returns {object} Factory and implementation addresses for forwarders
*/
getForwarderFactoryAddressesAndForwarderImplementationAddress(forwarderVersion: number | undefined): {
forwarderFactoryAddress: string;
forwarderImplementationAddress: string;
} {
const ethNetwork = this.getNetwork();

switch (forwarderVersion) {
case 1:
if (!ethNetwork?.forwarderFactoryAddress || !ethNetwork?.forwarderImplementationAddress) {
throw new Error('Forwarder factory addresses not configured for this network');
}
return {
forwarderFactoryAddress: ethNetwork.forwarderFactoryAddress,
forwarderImplementationAddress: ethNetwork.forwarderImplementationAddress,
};
case 2:
if (!ethNetwork?.walletV2ForwarderFactoryAddress || !ethNetwork?.walletV2ForwarderImplementationAddress) {
throw new Error('Wallet v2 factory addresses not configured for this network');
}
return {
forwarderFactoryAddress: ethNetwork.walletV2ForwarderFactoryAddress,
forwarderImplementationAddress: ethNetwork.walletV2ForwarderImplementationAddress,
};
case 4:
case 5:
if (!ethNetwork?.walletV4ForwarderFactoryAddress || !ethNetwork?.walletV4ForwarderImplementationAddress) {
throw new Error(`Forwarder v${forwarderVersion} factory addresses not configured for this network`);
}
return {
forwarderFactoryAddress: ethNetwork.walletV4ForwarderFactoryAddress,
forwarderImplementationAddress: ethNetwork.walletV4ForwarderImplementationAddress,
};
default:
throw new Error(`Forwarder version ${forwarderVersion} not supported`);
}
}

/**
* Get wallet base address factory and implementation addresses.
* This is used for base address verification for V1, V2, V4, and V5 wallets.
* The base address is the main wallet contract deployed via CREATE2.
*
* @param {number} walletVersion - The wallet version (1, 2, 4, or 5)
* @returns {object} Factory and implementation addresses for the wallet base address
* @throws {Error} if wallet version addresses are not configured
*/
getWalletAddressFactoryAddressesAndImplementationAddress(walletVersion: number): {
walletFactoryAddress: string;
walletImplementationAddress: string;
} {
const ethNetwork = this.getNetwork();

switch (walletVersion) {
case 1:
if (!ethNetwork?.walletFactoryAddress || !ethNetwork?.walletImplementationAddress) {
throw new Error('Wallet v1 factory addresses not configured for this network');
}
return {
walletFactoryAddress: ethNetwork.walletFactoryAddress,
walletImplementationAddress: ethNetwork.walletImplementationAddress,
};
case 2:
if (!ethNetwork?.walletV2FactoryAddress || !ethNetwork?.walletV2ImplementationAddress) {
throw new Error('Wallet v2 factory addresses not configured for this network');
}
return {
walletFactoryAddress: ethNetwork.walletV2FactoryAddress,
walletImplementationAddress: ethNetwork.walletV2ImplementationAddress,
};
case 4:
case 5:
if (!ethNetwork?.walletV4ForwarderFactoryAddress || !ethNetwork?.walletV4ForwarderImplementationAddress) {
throw new Error(`Wallet v${walletVersion} factory addresses not configured for this network`);
}
return {
walletFactoryAddress: ethNetwork.walletV4ForwarderFactoryAddress,
walletImplementationAddress: ethNetwork.walletV4ForwarderImplementationAddress,
};
default:
throw new Error(`Wallet version ${walletVersion} not supported`);
}
}

/**
* Helper method to create a salt buffer from hex string.
* Converts a hex salt string to a 32-byte buffer.
*
* @param {string} salt - The hex salt string
* @returns {Buffer} 32-byte salt buffer
*/
private createSaltBuffer(salt: string): Buffer {
const ethUtil = optionalDeps.ethUtil;
return ethUtil.setLengthLeft(Buffer.from(ethUtil.padToEven(ethUtil.stripHexPrefix(salt || '')), 'hex'), 32);
}

/**
* Verify BIP32 wallet base address (V1, V2, V4).
* These wallets use a wallet factory to deploy base addresses with CREATE2.
* The address is derived from the keychains' ethAddresses and a salt.
*
* @param {VerifyBip32BaseAddressOptions} params - Verification parameters
* @returns {object} Expected and actual addresses for comparison
*/
private verifyCreate2BaseAddress(params: VerifyContractBaseAddressOptions): boolean {
const { address, coinSpecific, keychains, walletVersion } = params;

if (!coinSpecific.salt) {
throw new Error(`missing salt for v${walletVersion} base address verification`);
}

// Get wallet factory and implementation addresses for the wallet version
const { walletFactoryAddress, walletImplementationAddress } =
this.getWalletAddressFactoryAddressesAndImplementationAddress(walletVersion);
const initcode = getProxyInitcode(walletImplementationAddress);

// Convert the wallet salt to a buffer, pad to 32 bytes
const saltBuffer = this.createSaltBuffer(coinSpecific.salt);

// Reconstruct calculationSalt using keychains' ethAddresses and wallet salt
const ethAddresses = keychains.map((kc) => {
if (!kc.ethAddress) {
throw new Error(`keychain missing ethAddress for v${walletVersion} base address verification`);
}
return kc.ethAddress;
});

const calculationSalt = optionalDeps.ethUtil.bufferToHex(
optionalDeps.ethAbi.soliditySHA3(['address[]', 'bytes32'], [ethAddresses, saltBuffer])
);

const expectedAddress = calculateForwarderV1Address(walletFactoryAddress, calculationSalt, initcode);

if (expectedAddress !== address) {
throw new UnexpectedAddressError(`address validation failure: expected ${expectedAddress} but got ${address}`);
}

return true;
}

/**
* Verify forwarder receive address (deposit address).
* Forwarder addresses are derived using CREATE2 from the base address and salt.
*
* @param {VerifyEthAddressOptions} params - Verification parameters
* @param {number} forwarderVersion - The forwarder version
* @returns {object} Expected and actual addresses for comparison
*/
private verifyForwarderAddress(params: VerifyEthAddressOptions, forwarderVersion: number): boolean {
const { address, coinSpecific, baseAddress } = params;

const { forwarderFactoryAddress, forwarderImplementationAddress } =
this.getForwarderFactoryAddressesAndForwarderImplementationAddress(forwarderVersion);
const initcode = getProxyInitcode(forwarderImplementationAddress);
const saltBuffer = this.createSaltBuffer(coinSpecific.salt || '');

const { createForwarderParams, createForwarderTypes } =
forwarderVersion === 4
? getCreateForwarderParamsAndTypes(baseAddress, saltBuffer, coinSpecific.feeAddress)
: getCreateForwarderParamsAndTypes(baseAddress, saltBuffer);

const calculationSalt = optionalDeps.ethUtil.bufferToHex(
optionalDeps.ethAbi.soliditySHA3(createForwarderTypes, createForwarderParams)
);

const expectedAddress = calculateForwarderV1Address(forwarderFactoryAddress, calculationSalt, initcode);

if (expectedAddress !== address) {
throw new UnexpectedAddressError(`address validation failure: expected ${expectedAddress} but got ${address}`);
}

return true;
}

/**
* Make sure an address is a wallet address and throw an error if it's not.
* @param {Object} params
Expand All @@ -2736,19 +2957,46 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
* @throws {UnexpectedAddressError}
* @returns {boolean} True iff address is a wallet address
*/
async isWalletAddress(params: VerifyEthAddressOptions): Promise<boolean> {
const ethUtil = optionalDeps.ethUtil;

let expectedAddress;
let actualAddress;

const { address, coinSpecific, baseAddress, impliedForwarderVersion = coinSpecific?.forwarderVersion } = params;
async isWalletAddress(params: VerifyEthAddressOptions | TssVerifyEthAddressOptions): Promise<boolean> {
const { address, impliedForwarderVersion, coinSpecific, baseAddress } = params;
const forwarderVersion = impliedForwarderVersion ?? coinSpecific?.forwarderVersion;

// Validate address format
if (address && !this.isValidAddress(address)) {
throw new InvalidAddressError(`invalid address: ${address}`);
}

// base address is required to calculate the salt which is used in calculateForwarderV1Address method
// Forwarder version 0 addresses cannot be verified because we do not store the nonce value required for address derivation.
if (forwarderVersion === 0) {
return true;
}

// Determine if we are verifying a base address
const isVerifyingBaseAddress = baseAddress && address === baseAddress;

// TSS/MPC wallet address verification (V3, V5, V6)
// V5 base addresses use TSS, but V5 forwarders use the regular forwarder verification
const isTssWalletVersion = params.walletVersion === 3 || params.walletVersion === 5 || params.walletVersion === 6;
const shouldUseTssVerification =
isTssVerifyAddressOptions(params) && isTssWalletVersion && (params.walletVersion !== 5 || isVerifyingBaseAddress);

if (shouldUseTssVerification) {
if (isVerifyingBaseAddress) {
const index = typeof params.index === 'string' ? parseInt(params.index, 10) : params.index;
if (index !== 0) {
throw new Error(
`Base address verification requires index 0, but got index ${params.index}. ` +
`The base address is always derived at index 0.`
);
}
}

return verifyMPCWalletAddress({ ...params, keyCurve: 'secp256k1' }, this.isValidAddress, (pubKey) => {
return new KeyPairLib({ pub: pubKey }).getAddress();
});
}

// From here on, we need baseAddress and coinSpecific for non-TSS verifications
if (_.isUndefined(baseAddress) || !this.isValidAddress(baseAddress)) {
throw new InvalidAddressError('invalid base address');
}
Expand All @@ -2759,33 +3007,18 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
);
}

if (impliedForwarderVersion === 0 || impliedForwarderVersion === 3 || impliedForwarderVersion === 5) {
return true;
} else {
const ethNetwork = this.getNetwork();
const forwarderFactoryAddress = ethNetwork?.forwarderFactoryAddress as string;
const forwarderImplementationAddress = ethNetwork?.forwarderImplementationAddress as string;

const initcode = getProxyInitcode(forwarderImplementationAddress);
const saltBuffer = ethUtil.setLengthLeft(
Buffer.from(ethUtil.padToEven(ethUtil.stripHexPrefix(coinSpecific.salt || '')), 'hex'),
32
);

// Hash the wallet base address with the given salt, so the address directly relies on the base address
const calculationSalt = optionalDeps.ethUtil.bufferToHex(
optionalDeps.ethAbi.soliditySHA3(['address', 'bytes32'], [baseAddress, saltBuffer])
);

expectedAddress = calculateForwarderV1Address(forwarderFactoryAddress, calculationSalt, initcode);
actualAddress = address;
// BIP32 wallet base address verification (V1, V2, V4)
if (isVerifyingBaseAddress && isVerifyContractBaseAddressOptions(params)) {
return this.verifyCreate2BaseAddress(params);
}

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

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

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

Expand Down
Loading