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
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export interface SupplementGenerateWalletOptions {
type: 'hot' | 'cold' | 'custodial';
subType?: 'lightningCustody' | 'lightningSelfCustody' | 'onPrem';
coinSpecific?: { [coinName: string]: unknown };
evmKeyRingReferenceWalletId?: string;
}

export interface FeeEstimateOptions {
Expand Down
66 changes: 66 additions & 0 deletions modules/sdk-core/src/bitgo/evm/evmUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { BitGoBase } from '../bitgoBase';
import { IBaseCoin } from '../baseCoin';

import { Wallet } from '../wallet';
import { KeyIndices } from '../keychain';
import { WalletWithKeychains } from '../wallet/iWallets';

/**
* Interface for EVM keyring wallet creation parameters
*/
export interface CreateEvmKeyRingWalletParams {
label: string;
evmKeyRingReferenceWalletId: string;
bitgo: BitGoBase;
baseCoin: IBaseCoin;
}

/**
* @param params - The wallet creation parameters
* @param baseCoin - The base coin instance
* @throws Error if validation fails
* @returns boolean - true if validation passes
*/
export function validateEvmKeyRingWalletParams(params: any, baseCoin: IBaseCoin): boolean {
if (!params.evmKeyRingReferenceWalletId) return false;

if (typeof params.evmKeyRingReferenceWalletId !== 'string') {
throw new Error('invalid evmKeyRingReferenceWalletId argument, expecting string');
}
if (!baseCoin.isEVM()) {
throw new Error('evmKeyRingReferenceWalletId is only supported for EVM chains');
}
return true;
}

/**
* Creates an EVM keyring wallet with shared keys from a reference wallet
* @param params - The parameters for creating the EVM keyring wallet
* @returns Promise<WalletWithKeychains> - The created wallet with its keychains
*/
export async function createEvmKeyRingWallet(params: CreateEvmKeyRingWalletParams): Promise<WalletWithKeychains> {
const { label, evmKeyRingReferenceWalletId, bitgo, baseCoin } = params;
// For EVM keyring wallets, this bypasses the normal key generation process since keys are shared via keyring
const addWalletParams = {
label,
evmKeyRingReferenceWalletId,
};

const newWallet = await bitgo.post(baseCoin.url('/wallet/add')).send(addWalletParams).result();

const userKeychain = baseCoin.keychains().get({ id: newWallet.keys[KeyIndices.USER] });
const backupKeychain = baseCoin.keychains().get({ id: newWallet.keys[KeyIndices.BACKUP] });
const bitgoKeychain = baseCoin.keychains().get({ id: newWallet.keys[KeyIndices.BITGO] });

const [userKey, backupKey, bitgoKey] = await Promise.all([userKeychain, backupKeychain, bitgoKeychain]);

const result: WalletWithKeychains = {
wallet: new Wallet(bitgo, baseCoin, newWallet),
userKeychain: userKey,
backupKeychain: backupKey,
bitgoKeychain: bitgoKey,
responseType: 'WalletWithKeychains',
};

return result;
}
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ export interface CreateAddressOptions {
derivedAddress?: string;
index?: number;
onToken?: string;
evmKeyRingReferenceAddress?: string;
}

export interface UpdateAddressOptions {
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export interface GenerateWalletOptions {
commonKeychain?: string;
type?: 'hot' | 'cold' | 'custodial';
subType?: 'lightningCustody' | 'lightningSelfCustody';
evmKeyRingReferenceWalletId?: string;
}

export const GenerateLightningWalletOptionsCodec = t.strict(
Expand Down Expand Up @@ -170,6 +171,7 @@ export interface AddWalletOptions {
initializationTxs?: any;
disableTransactionNotifications?: boolean;
gasPrice?: number;
evmKeyRingReferenceWalletId?: string;
}

type KeySignatures = {
Expand Down
14 changes: 14 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,7 @@ export class Wallet implements IWallet {
baseAddress,
allowSkipVerifyAddress = true,
onToken,
evmKeyRingReferenceAddress,
} = params;

if (!_.isUndefined(chain)) {
Expand Down Expand Up @@ -1325,6 +1326,19 @@ export class Wallet implements IWallet {
}
}

if (!_.isUndefined(evmKeyRingReferenceAddress)) {
if (!_.isString(evmKeyRingReferenceAddress)) {
throw new Error('evmKeyRingReferenceAddress has to be a string');
}
if (!this.baseCoin.isEVM()) {
throw new Error('evmKeyRingReferenceAddress is only supported for EVM chains');
}
if (!this.baseCoin.isValidAddress(evmKeyRingReferenceAddress)) {
throw new Error('evmKeyRingReferenceAddress must be a valid address');
}
addressParams.evmKeyRingReferenceAddress = evmKeyRingReferenceAddress;
}

// get keychains for address verification
const keychains = await Promise.all(this._wallet.keys.map((k) => this.baseCoin.keychains().get({ id: k, reqId })));
const rootAddress = _.get(this._wallet, 'receiveAddress.address');
Expand Down
19 changes: 16 additions & 3 deletions modules/sdk-core/src/bitgo/wallet/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
import { WalletShare } from './iWallet';
import { Wallet } from './wallet';
import { TssSettings } from '@bitgo/public-types';
import { createEvmKeyRingWallet, validateEvmKeyRingWalletParams } from '../evm/evmUtils';

/**
* Check if a wallet is a WalletWithKeychains
Expand Down Expand Up @@ -101,8 +102,10 @@ export class Wallets implements IWallets {
throw new Error('missing required string parameter label');
}

// no need to pass keys for (single) custodial wallets
if (params.type !== 'custodial') {
validateEvmKeyRingWalletParams(params, this.baseCoin);

if (!params.evmKeyRingReferenceWalletId && params.type !== 'custodial') {
// no need to pass keys for (single) custodial wallets
if (Array.isArray(params.keys) === false || !_.isNumber(params.m) || !_.isNumber(params.n)) {
throw new Error('invalid argument');
}
Expand Down Expand Up @@ -272,10 +275,19 @@ export class Wallets implements IWallets {
throw new Error('missing required string parameter label');
}

const { type = 'hot', label, passphrase, enterprise, isDistributedCustody } = params;
const { type = 'hot', label, passphrase, enterprise, isDistributedCustody, evmKeyRingReferenceWalletId } = params;
const isTss = params.multisigType === 'tss' && this.baseCoin.supportsTss();
const canEncrypt = !!passphrase && typeof passphrase === 'string';

if (validateEvmKeyRingWalletParams(params, this.baseCoin)) {
return await createEvmKeyRingWallet({
label,
evmKeyRingReferenceWalletId: evmKeyRingReferenceWalletId!,
bitgo: this.bitgo,
baseCoin: this.baseCoin,
});
}

const walletParams: SupplementGenerateWalletOptions = {
label: label,
m: 2,
Expand All @@ -301,6 +313,7 @@ export class Wallets implements IWallets {
if (
isTss &&
this.baseCoin.isEVM() &&
!evmKeyRingReferenceWalletId &&
!(params.walletVersion === 3 || params.walletVersion === 5 || params.walletVersion === 6)
) {
throw new Error('EVM TSS wallets are only supported for wallet version 3, 5 and 6');
Expand Down
169 changes: 169 additions & 0 deletions modules/sdk-core/test/unit/bitgo/wallet/walletEvmAddressCreation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import * as assert from 'assert';
import * as sinon from 'sinon';
import 'should';
import { Wallet } from '../../../../src/bitgo/wallet/wallet';

describe('Wallet - EVM Keyring Address Creation', function () {
let wallet: Wallet;
let mockBitGo: any;
let mockBaseCoin: any;
let mockWalletData: any;

beforeEach(function () {
mockBitGo = {
post: sinon.stub(),
setRequestTracer: sinon.stub(),
};

mockBaseCoin = {
isEVM: sinon.stub(),
supportsTss: sinon.stub().returns(true),
getFamily: sinon.stub().returns('eth'),
isValidAddress: sinon.stub(),
keychains: sinon.stub(),
url: sinon.stub().returns('/test/wallet/address'),
};

mockWalletData = {
id: 'test-wallet-id',
keys: ['user-key', 'backup-key', 'bitgo-key'],
};

wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData);
});

afterEach(function () {
sinon.restore();
});

describe('createAddress with EVM keyring parameters', function () {
beforeEach(function () {
mockBaseCoin.isEVM.returns(true);
mockBaseCoin.isValidAddress.returns(true);
mockBaseCoin.keychains.returns({
get: sinon.stub().resolves({ id: 'keychain-id', pub: 'public-key' }),
});
});

it('should create address with evmKeyRingReferenceAddress', async function () {
const mockAddressResponse = {
id: '507f1f77bcf86cd799439012',
address: '0x1234567890123456789012345678901234567890',
};

mockBitGo.post.returns({
send: sinon.stub().returns({
result: sinon.stub().resolves(mockAddressResponse),
}),
});

const result = await wallet.createAddress({
chain: 0,
label: 'Test EVM Address',

evmKeyRingReferenceAddress: '0x742d35Cc6634C0532925a3b8D404fddF4f780EAD',
});

result.should.have.property('id', '507f1f77bcf86cd799439012');
result.should.have.property('address', '0x1234567890123456789012345678901234567890');
mockBitGo.post.should.have.been.calledOnce;
});

it('should throw error if evmKeyRingReferenceAddress is not a string', async function () {
try {
await wallet.createAddress({
chain: 0,
label: 'Test Address',
evmKeyRingReferenceAddress: 123 as any,
});
assert.fail('Should have thrown error');
} catch (error) {
error.message.should.equal('evmKeyRingReferenceAddress has to be a string');
}
});

it('should throw error if evmKeyRingReferenceAddress is not a valid address', async function () {
mockBaseCoin.isValidAddress.returns(false);

try {
await wallet.createAddress({
chain: 0,
label: 'Test Address',
evmKeyRingReferenceAddress: 'invalid-address',
});
assert.fail('Should have thrown error');
} catch (error) {
error.message.should.equal('evmKeyRingReferenceAddress must be a valid address');
}
});

it('should throw error for non-EVM chains with evmKeyRingReferenceAddress', async function () {
mockBaseCoin.isEVM.returns(false);

try {
await wallet.createAddress({
chain: 0,
label: 'Test Address',
evmKeyRingReferenceAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2',
});
assert.fail('Should have thrown error');
} catch (error) {
error.message.should.equal('evmKeyRingReferenceAddress is only supported for EVM chains');
}
});

it('should create address without reference parameters for regular addresses', async function () {
const mockAddressResponse = {
id: 'regular-address-id',
address: '0x9876543210987654321098765432109876543210',
};

mockBitGo.post.returns({
send: sinon.stub().returns({
result: sinon.stub().resolves(mockAddressResponse),
}),
});

const result = await wallet.createAddress({
chain: 0,
label: 'Regular Address',
});

result.should.have.property('id', 'regular-address-id');
result.should.have.property('address', '0x9876543210987654321098765432109876543210');
mockBitGo.post.should.have.been.calledOnce;
});
});

describe('Non-EVM chains', function () {
beforeEach(function () {
mockBaseCoin.isEVM.returns(false);
});

it('should create regular addresses for non-EVM chains', async function () {
const mockAddressResponse = {
id: 'btc-address-id',
address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa',
};

mockBitGo.post.returns({
send: sinon.stub().returns({
result: sinon.stub().resolves(mockAddressResponse),
}),
});

mockBaseCoin.keychains.returns({
get: sinon.stub().resolves({ id: 'keychain-id', pub: 'public-key' }),
});

const result = await wallet.createAddress({
chain: 0,
label: 'BTC Address',
});

result.should.have.property('id', 'btc-address-id');
result.should.have.property('address', '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa');
mockBitGo.post.should.have.been.calledOnce;
});
});
});
Loading