diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 203e85b89b..6b6fcfcbea 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 }; + referenceWalletId?: string; } export interface FeeEstimateOptions { diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index c6d0c4af8c..fbc74eeb91 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -500,6 +500,8 @@ export interface CreateAddressOptions { derivedAddress?: string; index?: number; onToken?: string; + referenceCoin?: string; + referenceAddress?: 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..d5442828c6 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'; + referenceWalletId?: string; } export const GenerateLightningWalletOptionsCodec = t.strict( @@ -170,6 +171,7 @@ export interface AddWalletOptions { initializationTxs?: any; disableTransactionNotifications?: boolean; gasPrice?: number; + referenceWalletId?: 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..a0472136ea 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -1251,6 +1251,8 @@ export class Wallet implements IWallet { baseAddress, allowSkipVerifyAddress = true, onToken, + referenceCoin, + referenceAddress, } = params; if (!_.isUndefined(chain)) { @@ -1325,6 +1327,30 @@ export class Wallet implements IWallet { } } + // Validate EVM keyring params, referenceAddress is required and referenceCoin is optional for EVM keyring + if (!_.isUndefined(referenceAddress)) { + if (!_.isString(referenceAddress)) { + throw new Error('referenceAddress has to be a string'); + } + if (!this.baseCoin.isEVM()) { + throw new Error('referenceAddress is only supported for EVM chains'); + } + if (!this.baseCoin.isValidAddress(referenceAddress)) { + throw new Error('referenceAddress must be a valid address'); + } + addressParams.referenceAddress = referenceAddress; + + if (!_.isUndefined(referenceCoin)) { + if (!_.isString(referenceCoin)) { + throw new Error('referenceCoin has to be a string'); + } + addressParams.referenceCoin = referenceCoin; + } + } else if (!_.isUndefined(referenceCoin)) { + // referenceCoin cannot be used without referenceAddress + throw new Error('referenceAddress is required when using referenceCoin for EVM keyring'); + } + // 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..8c19a07dad 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -101,8 +101,21 @@ 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') { + // Validate referenceWalletId parameter + if (params.referenceWalletId) { + if (!_.isString(params.referenceWalletId)) { + throw new Error('invalid referenceWalletId argument, expecting string'); + } + if (!this.baseCoin.isEVM()) { + throw new Error('referenceWalletId is only supported for EVM chains'); + } + } + + // For wallets with referenceWalletId, skip multisig validation as configuration is inherited + if (params.referenceWalletId) { + // Skip all multisig validation - configuration will be inherited from reference wallet + } else if (params.type !== 'custodial') { + // Standard validation for non-custodial wallets without referenceWalletId if (Array.isArray(params.keys) === false || !_.isNumber(params.m) || !_.isNumber(params.n)) { throw new Error('invalid argument'); } @@ -272,9 +285,10 @@ 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, referenceWalletId } = params; const isTss = params.multisigType === 'tss' && this.baseCoin.supportsTss(); const canEncrypt = !!passphrase && typeof passphrase === 'string'; + const isEVMWithReference = this.baseCoin.isEVM() && referenceWalletId; const walletParams: SupplementGenerateWalletOptions = { label: label, @@ -284,6 +298,11 @@ export class Wallets implements IWallets { type: !!params.userKey && params.multisigType !== 'onchain' ? 'cold' : type, }; + // Add referenceWalletId to walletParams if provided for EVM chains + if (isEVMWithReference) { + walletParams.referenceWalletId = referenceWalletId; + } + if (!_.isUndefined(params.passcodeEncryptionCode)) { if (!_.isString(params.passcodeEncryptionCode)) { throw new Error('passcodeEncryptionCode must be a string'); @@ -297,15 +316,59 @@ export class Wallets implements IWallets { walletParams.enterprise = enterprise; } + // Validate referenceWalletId for EVM keyring + if (!_.isUndefined(referenceWalletId)) { + if (!_.isString(referenceWalletId)) { + throw new Error('invalid referenceWalletId argument, expecting string'); + } + if (!this.baseCoin.isEVM()) { + throw new Error('referenceWalletId is only supported for EVM chains'); + } + } + // EVM TSS wallets must use wallet version 3, 5 and 6 + // Skip this validation for EVM keyring wallets as they inherit version from reference wallet if ( isTss && this.baseCoin.isEVM() && + !referenceWalletId && !(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'); } + // Handle EVM keyring wallet creation with referenceWalletId + if (isEVMWithReference) { + // For EVM keyring wallets, multisigType will be inferred from the reference wallet + // No need to explicitly validate TSS requirement here as it will be handled by bgms + + // For EVM keyring wallets, we use the add method directly with referenceWalletId + // This bypasses the normal key generation process since keys are shared via keyring + const addWalletParams = { + label, + referenceWalletId, + }; + + const newWallet = await this.bitgo.post(this.baseCoin.url('/wallet/add')).send(addWalletParams).result(); + + // For EVM keyring wallets, we need to get the keychains from the reference wallet + const userKeychain = this.baseCoin.keychains().get({ id: newWallet.keys[KeyIndices.USER] }); + const backupKeychain = this.baseCoin.keychains().get({ id: newWallet.keys[KeyIndices.BACKUP] }); + const bitgoKeychain = this.baseCoin.keychains().get({ id: newWallet.keys[KeyIndices.BITGO] }); + + const [userKey, backupKey, bitgoKey] = await Promise.all([userKeychain, backupKeychain, bitgoKeychain]); + + const result: WalletWithKeychains = { + wallet: new Wallet(this.bitgo, this.baseCoin, newWallet), + userKeychain: userKey, + backupKeychain: backupKey, + bitgoKeychain: bitgoKey, + responseType: 'WalletWithKeychains', + }; + + return result; + } + if (isTss) { if (!this.baseCoin.supportsTss()) { throw new Error(`coin ${this.baseCoin.getFamily()} does not support TSS at this time`); diff --git a/modules/sdk-core/test/unit/account-lib/mpc/tss/ecdsa/ecdsa.ts b/modules/sdk-core/test/unit/account-lib/mpc/tss/ecdsa/ecdsa.ts index c416cdb2cd..61f22d7047 100644 --- a/modules/sdk-core/test/unit/account-lib/mpc/tss/ecdsa/ecdsa.ts +++ b/modules/sdk-core/test/unit/account-lib/mpc/tss/ecdsa/ecdsa.ts @@ -21,7 +21,8 @@ import { loadWebAssembly } from '@bitgo/sdk-opensslbytes'; const openSSLBytes = loadWebAssembly().buffer; -describe('ecdsa tss', function () { +describe('ecdsa tss', function (this: Mocha.Context) { + this.timeout(60000); const ecdsa = new Ecdsa(); let signCombine1: SignCombineRT, signCombine2: SignCombineRT; 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..fe4390a3d7 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletEvmAddressCreation.ts @@ -0,0 +1,254 @@ +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 referenceCoin and referenceAddress', 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', + referenceCoin: 'hteth', + referenceAddress: '0x742d35Cc6634C0532925a3b8D404fddF4f780EAD', + }); + + result.should.have.property('id', '507f1f77bcf86cd799439012'); + result.should.have.property('address', '0x1234567890123456789012345678901234567890'); + mockBitGo.post.should.have.been.calledOnce; + }); + + it('should throw error if only referenceCoin provided without referenceAddress', async function () { + try { + await wallet.createAddress({ + chain: 0, + label: 'Test Address', + referenceCoin: 'hteth', + // Missing referenceAddress + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceAddress is required when using referenceCoin for EVM keyring'); + } + }); + + it('should create address with only referenceAddress (referenceCoin is optional)', async function () { + const mockAddressResponse = { + id: '507f1f77bcf86cd799439012', + address: '0x1234567890123456789012345678901234567890', + }; + + mockBitGo.post.returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(mockAddressResponse), + }), + } as any); + + const result = await wallet.createAddress({ + chain: 0, + label: 'Test Address', + referenceAddress: '0x742d35Cc6634C0532925a3b8D404fddF4f780EAD', + // referenceCoin is optional + }); + + result.should.have.property('id', '507f1f77bcf86cd799439012'); + result.should.have.property('address', '0x1234567890123456789012345678901234567890'); + }); + + it('should throw error if referenceCoin is not a string', async function () { + try { + await wallet.createAddress({ + chain: 0, + label: 'Test Address', + referenceCoin: 123 as any, + referenceAddress: '0x742d35Cc6634C0532925a3b8D404fddF4f780EAD', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceCoin has to be a string'); + } + }); + + it('should throw error if referenceAddress is not a string', async function () { + try { + await wallet.createAddress({ + chain: 0, + label: 'Test Address', + referenceCoin: 'hteth', + referenceAddress: 123 as any, + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceAddress has to be a string'); + } + }); + + it('should throw error if referenceAddress is not a valid address', async function () { + mockBaseCoin.isValidAddress.returns(false); + + try { + await wallet.createAddress({ + chain: 0, + label: 'Test Address', + referenceCoin: 'hteth', + referenceAddress: 'invalid-address', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceAddress must be a valid address'); + } + }); + + it('should throw error for non-EVM chains with referenceCoin', async function () { + mockBaseCoin.isEVM.returns(false); + + try { + await wallet.createAddress({ + chain: 0, + label: 'Test Address', + referenceCoin: 'btc', + referenceAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceAddress is only supported for EVM chains'); + } + }); + + it('should throw error for non-EVM chains with referenceAddress', async function () { + mockBaseCoin.isEVM.returns(false); + + try { + await wallet.createAddress({ + chain: 0, + label: 'Test Address', + referenceCoin: 'btc', + referenceAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceAddress 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', + // No reference parameters + }); + + 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 not allow referenceCoin for non-EVM chains', async function () { + try { + await wallet.createAddress({ + chain: 0, + label: 'Test Address', + referenceCoin: 'btc', + referenceAddress: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceAddress is only supported for EVM chains'); + } + }); + + 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..e9828a13f0 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsEvmKeyring.ts @@ -0,0 +1,203 @@ +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(), + }; + + 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 referenceWalletId', 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', + referenceWalletId: '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 referenceWalletId provided for non-EVM chain', async function () { + mockBaseCoin.isEVM.returns(false); + + try { + await wallets.generateWallet({ + label: 'Test Wallet', + referenceWalletId: '507f1f77bcf86cd799439011', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceWalletId is only supported for EVM chains'); + } + }); + + it('should throw error if referenceWalletId is not a string', async function () { + try { + await wallets.generateWallet({ + label: 'Test Wallet', + referenceWalletId: 123 as any, + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('invalid referenceWalletId argument, expecting string'); + } + }); + }); + + describe('EVM Keyring - add method', function () { + beforeEach(function () { + mockBaseCoin.isEVM.returns(true); + }); + + it('should add EVM wallet with referenceWalletId 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', + referenceWalletId: 'reference-wallet-id', + }); + + result.should.have.property('wallet'); + mockBitGo.post.should.have.been.calledOnce; + }); + + it('should skip multisig validation for EVM wallets with referenceWalletId', 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', + referenceWalletId: '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 referenceWalletId', async function () { + mockBaseCoin.isEVM.returns(false); + + try { + await wallets.add({ + label: 'Test Wallet', + referenceWalletId: '507f1f77bcf86cd799439011', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceWalletId 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 referenceWalletId, 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 referenceWalletId for non-EVM chains in generateWallet', async function () { + try { + await wallets.generateWallet({ + label: 'Test Wallet', + referenceWalletId: 'reference-wallet-id', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceWalletId is only supported for EVM chains'); + } + }); + + it('should not allow referenceWalletId for non-EVM chains in add', async function () { + try { + await wallets.add({ + label: 'Test Wallet', + referenceWalletId: 'reference-wallet-id', + }); + assert.fail('Should have thrown error'); + } catch (error) { + error.message.should.equal('referenceWalletId is only supported for EVM chains'); + } + }); + }); +});