diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 203e85b89b..d7856e81ab 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -208,6 +208,7 @@ export interface SupplementGenerateWalletOptions { type: 'hot' | 'cold' | 'custodial'; subType?: 'lightningCustody' | 'lightningSelfCustody' | 'onPrem'; coinSpecific?: { [coinName: string]: unknown }; + evmKeyRingReferenceWalletId?: string; } export interface FeeEstimateOptions { diff --git a/modules/sdk-core/src/bitgo/evm/evmUtils.ts b/modules/sdk-core/src/bitgo/evm/evmUtils.ts new file mode 100644 index 0000000000..cb549a88a0 --- /dev/null +++ b/modules/sdk-core/src/bitgo/evm/evmUtils.ts @@ -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 - The created wallet with its keychains + */ +export async function createEvmKeyRingWallet(params: CreateEvmKeyRingWalletParams): Promise { + 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; +} diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index c6d0c4af8c..ec1675ab2b 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -500,6 +500,7 @@ export interface CreateAddressOptions { derivedAddress?: string; index?: number; onToken?: string; + evmKeyRingReferenceAddress?: string; } export interface UpdateAddressOptions { diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index a6ca7e1311..a1a3ea9295 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -70,6 +70,7 @@ export interface GenerateWalletOptions { commonKeychain?: string; type?: 'hot' | 'cold' | 'custodial'; subType?: 'lightningCustody' | 'lightningSelfCustody'; + evmKeyRingReferenceWalletId?: string; } export const GenerateLightningWalletOptionsCodec = t.strict( @@ -170,6 +171,7 @@ export interface AddWalletOptions { initializationTxs?: any; disableTransactionNotifications?: boolean; gasPrice?: number; + evmKeyRingReferenceWalletId?: string; } type KeySignatures = { diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 8c96410124..dc62fb8c0a 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -1251,6 +1251,7 @@ export class Wallet implements IWallet { baseAddress, allowSkipVerifyAddress = true, onToken, + evmKeyRingReferenceAddress, } = params; if (!_.isUndefined(chain)) { @@ -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'); diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 1cfaba3e43..36da5fe171 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -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 @@ -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'); } @@ -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, @@ -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'); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletEvmAddressCreation.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletEvmAddressCreation.ts new file mode 100644 index 0000000000..290d217d95 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletEvmAddressCreation.ts @@ -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; + }); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsEvmKeyring.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsEvmKeyring.ts new file mode 100644 index 0000000000..8e929bc779 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsEvmKeyring.ts @@ -0,0 +1,204 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import 'should'; +import { Wallets } from '../../../../src/bitgo/wallet/wallets'; + +describe('Wallets', function () { + let wallets: Wallets; + let mockBitGo: any; + let mockBaseCoin: any; + + beforeEach(function () { + mockBitGo = { + post: sinon.stub(), + encrypt: sinon.stub(), + setRequestTracer: sinon.stub(), + }; + + mockBaseCoin = { + isEVM: sinon.stub(), + supportsTss: sinon.stub().returns(true), + getFamily: sinon.stub().returns('eth'), + getDefaultMultisigType: sinon.stub(), + keychains: sinon.stub(), + url: sinon.stub().returns('/test/url'), + isValidMofNSetup: sinon.stub(), + getConfig: sinon.stub().returns({ features: [] }), + }; + + wallets = new Wallets(mockBitGo, mockBaseCoin); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('EVM Keyring - generateWallet', function () { + beforeEach(function () { + mockBaseCoin.isEVM.returns(true); + mockBaseCoin.supportsTss.returns(true); + mockBaseCoin.getDefaultMultisigType.returns('tss'); + }); + + it('should create EVM wallet with evmKeyRingReferenceWalletId', async function () { + const mockWalletResponse = { + id: '597f1f77bcf86cd799439011', + keys: ['user-key', 'backup-key', 'bitgo-key'], + }; + + mockBitGo.post.returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(mockWalletResponse), + }), + } as any); + + mockBaseCoin.keychains.returns({ + get: sinon.stub().resolves({ id: 'keychain-id', pub: 'public-key' }), + } as any); + + const result = await wallets.generateWallet({ + label: 'Test EVM Wallet', + evmKeyRingReferenceWalletId: '507f1f77bcf86cd799439011', + }); + + result.should.have.property('wallet'); + result.should.have.property('userKeychain'); + result.should.have.property('backupKeychain'); + result.should.have.property('bitgoKeychain'); + }); + + it('should throw error if evmKeyRingReferenceWalletId provided for non-EVM chain', async function () { + mockBaseCoin.isEVM.returns(false); + + try { + await wallets.generateWallet({ + label: 'Test Wallet', + evmKeyRingReferenceWalletId: '507f1f77bcf86cd799439011', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('evmKeyRingReferenceWalletId is only supported for EVM chains'); + } + }); + + it('should throw error if evmKeyRingReferenceWalletId is not a string', async function () { + try { + await wallets.generateWallet({ + label: 'Test Wallet', + evmKeyRingReferenceWalletId: 123 as any, + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('invalid evmKeyRingReferenceWalletId argument, expecting string'); + } + }); + }); + + describe('EVM Keyring - add method', function () { + beforeEach(function () { + mockBaseCoin.isEVM.returns(true); + }); + + it('should add EVM wallet with evmKeyRingReferenceWalletId without multisig validation', async function () { + const mockWalletResponse = { + id: 'new-wallet-id', + keys: ['user-key', 'backup-key', 'bitgo-key'], + }; + + mockBitGo.post.returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(mockWalletResponse), + }), + } as any); + + const result = await wallets.add({ + label: 'Test EVM Wallet', + evmKeyRingReferenceWalletId: 'reference-wallet-id', + }); + + result.should.have.property('wallet'); + mockBitGo.post.should.have.been.calledOnce; + }); + + it('should skip multisig validation for EVM wallets with evmKeyRingReferenceWalletId', async function () { + const mockWalletResponse = { + id: 'new-wallet-id', + keys: ['user-key', 'backup-key', 'bitgo-key'], + }; + + mockBitGo.post.returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(mockWalletResponse), + }), + } as any); + + // This should not throw error even without keys, m, n parameters + const result = await wallets.add({ + label: 'Test EVM Wallet', + evmKeyRingReferenceWalletId: '507f1f77bcf86cd799439011', + // No keys, m, n provided - should be fine for EVM keyring + }); + + result.should.have.property('wallet'); + }); + + it('should throw error for non-EVM chains with evmKeyRingReferenceWalletId', async function () { + mockBaseCoin.isEVM.returns(false); + + try { + await wallets.add({ + label: 'Test Wallet', + evmKeyRingReferenceWalletId: '507f1f77bcf86cd799439011', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('evmKeyRingReferenceWalletId is only supported for EVM chains'); + } + }); + + it('should still validate multisig for regular wallets', async function () { + mockBaseCoin.isEVM.returns(true); + + try { + await wallets.add({ + label: 'Test Wallet', + type: 'hot', + // No evmKeyRingReferenceWalletId, so should require keys, m, n + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('invalid argument'); + } + }); + }); + + describe('Non-EVM chains', function () { + beforeEach(function () { + mockBaseCoin.isEVM.returns(false); + }); + + it('should not allow evmKeyRingReferenceWalletId for non-EVM chains in generateWallet', async function () { + try { + await wallets.generateWallet({ + label: 'Test Wallet', + evmKeyRingReferenceWalletId: 'reference-wallet-id', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('evmKeyRingReferenceWalletId is only supported for EVM chains'); + } + }); + + it('should not allow evmKeyRingReferenceWalletId for non-EVM chains in add', async function () { + try { + await wallets.add({ + label: 'Test Wallet', + evmKeyRingReferenceWalletId: 'reference-wallet-id', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('evmKeyRingReferenceWalletId is only supported for EVM chains'); + } + }); + }); +});