From 1ef6e5f966dbc7c534525e191e069bffc3af5041 Mon Sep 17 00:00:00 2001 From: Pranish Nepal Date: Thu, 23 Apr 2026 16:24:36 -0400 Subject: [PATCH] feat: Add support for EVM keyring wallets in generateWallet API This commit introduces support for generating EVM keyring wallets in the `generateWallet` API. Ticket: WAL-479 --- .../api/master/generateWallet.test.ts | 178 +++++++++++++----- .../handlers/handleGenerateWallet.ts | 28 ++- .../routers/generateWalletRoute.ts | 7 + 3 files changed, 165 insertions(+), 48 deletions(-) diff --git a/src/__tests__/api/master/generateWallet.test.ts b/src/__tests__/api/master/generateWallet.test.ts index 2beb5d23..cc47cf73 100644 --- a/src/__tests__/api/master/generateWallet.test.ts +++ b/src/__tests__/api/master/generateWallet.test.ts @@ -20,6 +20,49 @@ import { BitGoRequest } from '../../../types/request'; * in how the constants are fetched. */ +function mockWalletResponse(id: string, coinName: string, overrides: Record = {}) { + return { + id, + users: [{ user: 'user-id', permissions: ['admin', 'spend', 'view'] }], + coin: coinName, + label: 'test_wallet', + m: 2, + n: 3, + keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], + keySignatures: {}, + enterprise: 'test_enterprise', + organization: 'org-id', + bitgoOrg: 'BitGo Inc', + tags: [id, 'test_enterprise'], + disableTransactionNotifications: false, + freeze: {}, + deleted: false, + approvalsRequired: 1, + isCold: false, + coinSpecific: {}, + admin: {}, + allowBackupKeySigning: false, + clientFlags: [], + recoverable: false, + startDate: '2025-01-01T00:00:00.000Z', + hasLargeNumberOfAddresses: false, + config: {}, + balanceString: '0', + confirmedBalanceString: '0', + spendableBalanceString: '0', + receiveAddress: { + id: 'addr-id', + address: '0xexampleaddress', + chain: 0, + index: 0, + coin: coinName, + wallet: id, + coinSpecific: {}, + }, + ...overrides, + }; +} + describe('POST /api/v1/:coin/advancedwallet/generate', () => { let agent: request.SuperAgentTest; const advancedWalletManagerUrl = 'http://advancedwalletmanager.invalid'; @@ -136,54 +179,24 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { type: 'advanced', }) .matchHeader('any', () => true) - .reply(200, { - id: 'new-wallet-id', - users: [ - { - user: 'user-id', - permissions: ['admin', 'spend', 'view'], + .reply( + 200, + mockWalletResponse('new-wallet-id', coin, { + isCold: true, + pendingApprovals: [], + receiveAddress: { + id: 'addr-id', + address: 'tb1qexampleaddress000000000000000000000', + chain: 20, + index: 0, + coin: coin, + wallet: 'new-wallet-id', + coinSpecific: {}, }, - ], - coin: coin, - label: 'test_wallet', - m: 2, - n: 3, - keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'], - keySignatures: {}, - enterprise: 'test_enterprise', - organization: 'org-id', - bitgoOrg: 'BitGo Inc', - tags: ['new-wallet-id', 'test_enterprise'], - disableTransactionNotifications: false, - freeze: {}, - deleted: false, - approvalsRequired: 1, - isCold: true, - coinSpecific: {}, - admin: {}, - pendingApprovals: [], - allowBackupKeySigning: false, - clientFlags: [], - recoverable: false, - startDate: '2025-01-01T00:00:00.000Z', - hasLargeNumberOfAddresses: false, - config: {}, - balanceString: '0', - confirmedBalanceString: '0', - spendableBalanceString: '0', - receiveAddress: { - id: 'addr-id', - address: 'tb1qexampleaddress000000000000000000000', - chain: 20, - index: 0, - coin: coin, - wallet: 'new-wallet-id', - coinSpecific: {}, - }, - // optional-ish fields used in assertions - multisigType: 'onchain', - type: 'advanced', - }); + multisigType: 'onchain', + type: 'advanced', + }), + ); const response = await agent .post(`/api/v1/${coin}/advancedwallet/generate`) @@ -1283,4 +1296,75 @@ describe('POST /api/v1/:coin/advancedwallet/generate', () => { response.status.should.equal(400); response.body.details.should.equal('MPC wallet generation is not supported for coin tbtc'); }); + + it('should skip calls to AWM and use existing keychains when evmKeyRingReferenceWalletId is provided', async () => { + /** GET mocks for Key Retrieval */ + const userKeyNock = nock(bitgoApiUrl) + .get(`/api/v2/${ecdsaCoin}/key/user-key-id`) + .reply(200, { id: 'user-key-id', source: 'user', type: 'independent' }); + + const backupKeyNock = nock(bitgoApiUrl) + .get(`/api/v2/${ecdsaCoin}/key/backup-key-id`) + .reply(200, { id: 'backup-key-id', source: 'backup', type: 'independent' }); + + const bitgoKeyNock = nock(bitgoApiUrl).get(`/api/v2/${ecdsaCoin}/key/bitgo-key-id`).reply(200, { + id: 'bitgo-key-id', + source: 'bitgo', + type: 'independent', + isBitGo: true, + isTrust: false, + hsmType: 'institutional', + }); + + /** POST mock for the actual wallet creation */ + const walletAddNock = nock(bitgoApiUrl) + .post(`/api/v2/${ecdsaCoin}/wallet/add`, { + label: 'test_wallet', + evmKeyRingReferenceWalletId: '59cd72485007a239fb00282ed480da1f', + }) + .matchHeader('any', () => true) + .reply( + 200, + mockWalletResponse('new-keyring-wallet-id', ecdsaCoin, { + multisigType: 'tss', + type: 'advanced', + }), + ); + + const response = await agent + .post(`/api/v1/${ecdsaCoin}/advancedwallet/generate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + label: 'test_wallet', + enterprise: 'test_enterprise', + multisigType: 'tss', + evmKeyRingReferenceWalletId: '59cd72485007a239fb00282ed480da1f', + }); + + response.status.should.equal(200); + response.body.wallet.id.should.equal('new-keyring-wallet-id'); + + /** AWM was never called — if it had been, nock would've thrown since we never mocked POST AWM calls */ + walletAddNock.done(); + userKeyNock.done(); + backupKeyNock.done(); + bitgoKeyNock.done(); + }); + + it('should fail when evmKeyRingReferenceWalletId is provided for a non-EVM coin', async () => { + const response = await agent + .post(`/api/v1/${coin}/advancedwallet/generate`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + label: 'test_wallet', + enterprise: 'test_enterprise', + multisigType: 'onchain', + evmKeyRingReferenceWalletId: '59cd72485007a239fb00282ed480da1f', + }); + + response.status.should.equal(400); + response.body.details.should.containEql( + 'EVM keyring wallet generation is not supported for coin tbtc', + ); + }); }); diff --git a/src/masterBitgoExpress/handlers/handleGenerateWallet.ts b/src/masterBitgoExpress/handlers/handleGenerateWallet.ts index 4c41a1a0..8824827b 100644 --- a/src/masterBitgoExpress/handlers/handleGenerateWallet.ts +++ b/src/masterBitgoExpress/handlers/handleGenerateWallet.ts @@ -21,7 +21,11 @@ import { BadRequestError } from '../../shared/errors'; export async function handleGenerateWallet( req: MasterApiSpecRouteRequest<'v1.wallet.generate', 'post'>, ) { - const { multisigType } = req.decoded; + const { multisigType, evmKeyRingReferenceWalletId } = req.decoded; + + if (evmKeyRingReferenceWalletId) { + return handleGenerateEvmKeyRingWallet(req); + } if (multisigType === 'tss') { return handleGenerateMpcWallet(req); @@ -212,3 +216,25 @@ async function handleGenerateMpcWallet( return { ...result, wallet: result.wallet.toJSON() }; } + +/** + * This function generates an EVM keyring wallet by reusing keys from a reference wallet. + */ +async function handleGenerateEvmKeyRingWallet( + req: MasterApiSpecRouteRequest<'v1.wallet.generate', 'post'>, +) { + const bitgo = req.bitgo; + const baseCoin = await coinFactory.getCoin(req.decoded.coin, bitgo); + if (!baseCoin.isEVM()) { + throw new BadRequestError( + `EVM keyring wallet generation is not supported for coin ${req.decoded.coin}`, + ); + } + + const result = await baseCoin.wallets().generateWallet(req.decoded); + + return { + ...result, + wallet: result.wallet.toJSON(), + }; +} diff --git a/src/masterBitgoExpress/routers/generateWalletRoute.ts b/src/masterBitgoExpress/routers/generateWalletRoute.ts index 161b4f60..6898b1f5 100644 --- a/src/masterBitgoExpress/routers/generateWalletRoute.ts +++ b/src/masterBitgoExpress/routers/generateWalletRoute.ts @@ -329,6 +329,13 @@ const GenerateWalletRequest = { * @maximum 3 */ walletVersion: optional(t.number), + + /** + * Reference wallet ID for EVM keyring wallets + * @example "59cd72485007a239fb00282ed480da1f" + * @pattern ^[0-9a-f]{32}$ + */ + evmKeyRingReferenceWalletId: optional(t.string), }; /**