Skip to content

Commit a514410

Browse files
authored
Merge pull request #6887 from BitGo/COIN-5193-EvmKeyring
feat(sdk-core): EVM keyring wallet and address creation api changes
2 parents ee94045 + 50a2f14 commit a514410

File tree

8 files changed

+473
-3
lines changed

8 files changed

+473
-3
lines changed

modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ export interface SupplementGenerateWalletOptions {
208208
type: 'hot' | 'cold' | 'custodial';
209209
subType?: 'lightningCustody' | 'lightningSelfCustody' | 'onPrem';
210210
coinSpecific?: { [coinName: string]: unknown };
211+
evmKeyRingReferenceWalletId?: string;
211212
}
212213

213214
export interface FeeEstimateOptions {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { BitGoBase } from '../bitgoBase';
2+
import { IBaseCoin } from '../baseCoin';
3+
4+
import { Wallet } from '../wallet';
5+
import { KeyIndices } from '../keychain';
6+
import { WalletWithKeychains } from '../wallet/iWallets';
7+
8+
/**
9+
* Interface for EVM keyring wallet creation parameters
10+
*/
11+
export interface CreateEvmKeyRingWalletParams {
12+
label: string;
13+
evmKeyRingReferenceWalletId: string;
14+
bitgo: BitGoBase;
15+
baseCoin: IBaseCoin;
16+
}
17+
18+
/**
19+
* @param params - The wallet creation parameters
20+
* @param baseCoin - The base coin instance
21+
* @throws Error if validation fails
22+
* @returns boolean - true if validation passes
23+
*/
24+
export function validateEvmKeyRingWalletParams(params: any, baseCoin: IBaseCoin): boolean {
25+
if (!params.evmKeyRingReferenceWalletId) return false;
26+
27+
if (typeof params.evmKeyRingReferenceWalletId !== 'string') {
28+
throw new Error('invalid evmKeyRingReferenceWalletId argument, expecting string');
29+
}
30+
if (!baseCoin.isEVM()) {
31+
throw new Error('evmKeyRingReferenceWalletId is only supported for EVM chains');
32+
}
33+
return true;
34+
}
35+
36+
/**
37+
* Creates an EVM keyring wallet with shared keys from a reference wallet
38+
* @param params - The parameters for creating the EVM keyring wallet
39+
* @returns Promise<WalletWithKeychains> - The created wallet with its keychains
40+
*/
41+
export async function createEvmKeyRingWallet(params: CreateEvmKeyRingWalletParams): Promise<WalletWithKeychains> {
42+
const { label, evmKeyRingReferenceWalletId, bitgo, baseCoin } = params;
43+
// For EVM keyring wallets, this bypasses the normal key generation process since keys are shared via keyring
44+
const addWalletParams = {
45+
label,
46+
evmKeyRingReferenceWalletId,
47+
};
48+
49+
const newWallet = await bitgo.post(baseCoin.url('/wallet/add')).send(addWalletParams).result();
50+
51+
const userKeychain = baseCoin.keychains().get({ id: newWallet.keys[KeyIndices.USER] });
52+
const backupKeychain = baseCoin.keychains().get({ id: newWallet.keys[KeyIndices.BACKUP] });
53+
const bitgoKeychain = baseCoin.keychains().get({ id: newWallet.keys[KeyIndices.BITGO] });
54+
55+
const [userKey, backupKey, bitgoKey] = await Promise.all([userKeychain, backupKeychain, bitgoKeychain]);
56+
57+
const result: WalletWithKeychains = {
58+
wallet: new Wallet(bitgo, baseCoin, newWallet),
59+
userKeychain: userKey,
60+
backupKeychain: backupKey,
61+
bitgoKeychain: bitgoKey,
62+
responseType: 'WalletWithKeychains',
63+
};
64+
65+
return result;
66+
}

modules/sdk-core/src/bitgo/wallet/iWallet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@ export interface CreateAddressOptions {
500500
derivedAddress?: string;
501501
index?: number;
502502
onToken?: string;
503+
evmKeyRingReferenceAddress?: string;
503504
}
504505

505506
export interface UpdateAddressOptions {

modules/sdk-core/src/bitgo/wallet/iWallets.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export interface GenerateWalletOptions {
7070
commonKeychain?: string;
7171
type?: 'hot' | 'cold' | 'custodial';
7272
subType?: 'lightningCustody' | 'lightningSelfCustody';
73+
evmKeyRingReferenceWalletId?: string;
7374
}
7475

7576
export const GenerateLightningWalletOptionsCodec = t.strict(
@@ -170,6 +171,7 @@ export interface AddWalletOptions {
170171
initializationTxs?: any;
171172
disableTransactionNotifications?: boolean;
172173
gasPrice?: number;
174+
evmKeyRingReferenceWalletId?: string;
173175
}
174176

175177
type KeySignatures = {

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,6 +1251,7 @@ export class Wallet implements IWallet {
12511251
baseAddress,
12521252
allowSkipVerifyAddress = true,
12531253
onToken,
1254+
evmKeyRingReferenceAddress,
12541255
} = params;
12551256

12561257
if (!_.isUndefined(chain)) {
@@ -1325,6 +1326,19 @@ export class Wallet implements IWallet {
13251326
}
13261327
}
13271328

1329+
if (!_.isUndefined(evmKeyRingReferenceAddress)) {
1330+
if (!_.isString(evmKeyRingReferenceAddress)) {
1331+
throw new Error('evmKeyRingReferenceAddress has to be a string');
1332+
}
1333+
if (!this.baseCoin.isEVM()) {
1334+
throw new Error('evmKeyRingReferenceAddress is only supported for EVM chains');
1335+
}
1336+
if (!this.baseCoin.isValidAddress(evmKeyRingReferenceAddress)) {
1337+
throw new Error('evmKeyRingReferenceAddress must be a valid address');
1338+
}
1339+
addressParams.evmKeyRingReferenceAddress = evmKeyRingReferenceAddress;
1340+
}
1341+
13281342
// get keychains for address verification
13291343
const keychains = await Promise.all(this._wallet.keys.map((k) => this.baseCoin.keychains().get({ id: k, reqId })));
13301344
const rootAddress = _.get(this._wallet, 'receiveAddress.address');

modules/sdk-core/src/bitgo/wallet/wallets.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
import { WalletShare } from './iWallet';
4242
import { Wallet } from './wallet';
4343
import { TssSettings } from '@bitgo/public-types';
44+
import { createEvmKeyRingWallet, validateEvmKeyRingWalletParams } from '../evm/evmUtils';
4445

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

104-
// no need to pass keys for (single) custodial wallets
105-
if (params.type !== 'custodial') {
105+
validateEvmKeyRingWalletParams(params, this.baseCoin);
106+
107+
if (!params.evmKeyRingReferenceWalletId && params.type !== 'custodial') {
108+
// no need to pass keys for (single) custodial wallets
106109
if (Array.isArray(params.keys) === false || !_.isNumber(params.m) || !_.isNumber(params.n)) {
107110
throw new Error('invalid argument');
108111
}
@@ -272,10 +275,19 @@ export class Wallets implements IWallets {
272275
throw new Error('missing required string parameter label');
273276
}
274277

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

282+
if (validateEvmKeyRingWalletParams(params, this.baseCoin)) {
283+
return await createEvmKeyRingWallet({
284+
label,
285+
evmKeyRingReferenceWalletId: evmKeyRingReferenceWalletId!,
286+
bitgo: this.bitgo,
287+
baseCoin: this.baseCoin,
288+
});
289+
}
290+
279291
const walletParams: SupplementGenerateWalletOptions = {
280292
label: label,
281293
m: 2,
@@ -301,6 +313,7 @@ export class Wallets implements IWallets {
301313
if (
302314
isTss &&
303315
this.baseCoin.isEVM() &&
316+
!evmKeyRingReferenceWalletId &&
304317
!(params.walletVersion === 3 || params.walletVersion === 5 || params.walletVersion === 6)
305318
) {
306319
throw new Error('EVM TSS wallets are only supported for wallet version 3, 5 and 6');
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import 'should';
4+
import { Wallet } from '../../../../src/bitgo/wallet/wallet';
5+
6+
describe('Wallet - EVM Keyring Address Creation', function () {
7+
let wallet: Wallet;
8+
let mockBitGo: any;
9+
let mockBaseCoin: any;
10+
let mockWalletData: any;
11+
12+
beforeEach(function () {
13+
mockBitGo = {
14+
post: sinon.stub(),
15+
setRequestTracer: sinon.stub(),
16+
};
17+
18+
mockBaseCoin = {
19+
isEVM: sinon.stub(),
20+
supportsTss: sinon.stub().returns(true),
21+
getFamily: sinon.stub().returns('eth'),
22+
isValidAddress: sinon.stub(),
23+
keychains: sinon.stub(),
24+
url: sinon.stub().returns('/test/wallet/address'),
25+
};
26+
27+
mockWalletData = {
28+
id: 'test-wallet-id',
29+
keys: ['user-key', 'backup-key', 'bitgo-key'],
30+
};
31+
32+
wallet = new Wallet(mockBitGo, mockBaseCoin, mockWalletData);
33+
});
34+
35+
afterEach(function () {
36+
sinon.restore();
37+
});
38+
39+
describe('createAddress with EVM keyring parameters', function () {
40+
beforeEach(function () {
41+
mockBaseCoin.isEVM.returns(true);
42+
mockBaseCoin.isValidAddress.returns(true);
43+
mockBaseCoin.keychains.returns({
44+
get: sinon.stub().resolves({ id: 'keychain-id', pub: 'public-key' }),
45+
});
46+
});
47+
48+
it('should create address with evmKeyRingReferenceAddress', async function () {
49+
const mockAddressResponse = {
50+
id: '507f1f77bcf86cd799439012',
51+
address: '0x1234567890123456789012345678901234567890',
52+
};
53+
54+
mockBitGo.post.returns({
55+
send: sinon.stub().returns({
56+
result: sinon.stub().resolves(mockAddressResponse),
57+
}),
58+
});
59+
60+
const result = await wallet.createAddress({
61+
chain: 0,
62+
label: 'Test EVM Address',
63+
64+
evmKeyRingReferenceAddress: '0x742d35Cc6634C0532925a3b8D404fddF4f780EAD',
65+
});
66+
67+
result.should.have.property('id', '507f1f77bcf86cd799439012');
68+
result.should.have.property('address', '0x1234567890123456789012345678901234567890');
69+
mockBitGo.post.should.have.been.calledOnce;
70+
});
71+
72+
it('should throw error if evmKeyRingReferenceAddress is not a string', async function () {
73+
try {
74+
await wallet.createAddress({
75+
chain: 0,
76+
label: 'Test Address',
77+
evmKeyRingReferenceAddress: 123 as any,
78+
});
79+
assert.fail('Should have thrown error');
80+
} catch (error) {
81+
error.message.should.equal('evmKeyRingReferenceAddress has to be a string');
82+
}
83+
});
84+
85+
it('should throw error if evmKeyRingReferenceAddress is not a valid address', async function () {
86+
mockBaseCoin.isValidAddress.returns(false);
87+
88+
try {
89+
await wallet.createAddress({
90+
chain: 0,
91+
label: 'Test Address',
92+
evmKeyRingReferenceAddress: 'invalid-address',
93+
});
94+
assert.fail('Should have thrown error');
95+
} catch (error) {
96+
error.message.should.equal('evmKeyRingReferenceAddress must be a valid address');
97+
}
98+
});
99+
100+
it('should throw error for non-EVM chains with evmKeyRingReferenceAddress', async function () {
101+
mockBaseCoin.isEVM.returns(false);
102+
103+
try {
104+
await wallet.createAddress({
105+
chain: 0,
106+
label: 'Test Address',
107+
evmKeyRingReferenceAddress: '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2',
108+
});
109+
assert.fail('Should have thrown error');
110+
} catch (error) {
111+
error.message.should.equal('evmKeyRingReferenceAddress is only supported for EVM chains');
112+
}
113+
});
114+
115+
it('should create address without reference parameters for regular addresses', async function () {
116+
const mockAddressResponse = {
117+
id: 'regular-address-id',
118+
address: '0x9876543210987654321098765432109876543210',
119+
};
120+
121+
mockBitGo.post.returns({
122+
send: sinon.stub().returns({
123+
result: sinon.stub().resolves(mockAddressResponse),
124+
}),
125+
});
126+
127+
const result = await wallet.createAddress({
128+
chain: 0,
129+
label: 'Regular Address',
130+
});
131+
132+
result.should.have.property('id', 'regular-address-id');
133+
result.should.have.property('address', '0x9876543210987654321098765432109876543210');
134+
mockBitGo.post.should.have.been.calledOnce;
135+
});
136+
});
137+
138+
describe('Non-EVM chains', function () {
139+
beforeEach(function () {
140+
mockBaseCoin.isEVM.returns(false);
141+
});
142+
143+
it('should create regular addresses for non-EVM chains', async function () {
144+
const mockAddressResponse = {
145+
id: 'btc-address-id',
146+
address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa',
147+
};
148+
149+
mockBitGo.post.returns({
150+
send: sinon.stub().returns({
151+
result: sinon.stub().resolves(mockAddressResponse),
152+
}),
153+
});
154+
155+
mockBaseCoin.keychains.returns({
156+
get: sinon.stub().resolves({ id: 'keychain-id', pub: 'public-key' }),
157+
});
158+
159+
const result = await wallet.createAddress({
160+
chain: 0,
161+
label: 'BTC Address',
162+
});
163+
164+
result.should.have.property('id', 'btc-address-id');
165+
result.should.have.property('address', '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa');
166+
mockBitGo.post.should.have.been.calledOnce;
167+
});
168+
});
169+
});

0 commit comments

Comments
 (0)