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
178 changes: 131 additions & 47 deletions src/__tests__/api/master/generateWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,49 @@ import { BitGoRequest } from '../../../types/request';
* in how the constants are fetched.
*/

function mockWalletResponse(id: string, coinName: string, overrides: Record<string, unknown> = {}) {
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';
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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',
);
});
});
28 changes: 27 additions & 1 deletion src/masterBitgoExpress/handlers/handleGenerateWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment on lines +26 to +28
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be run after coin validation. if someone passes it with a non-EVM coin, the handler calls baseCoin.wallets().generateWallet() with an unsupported param and relies on the downstream API to reject it. should validate the coin is EVM-compatible and return 400 early imo

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, added an early validation!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could directly do smthn like
if(baseCoin.isEVM() && evmKeyRingReferenceWalletId) {
result = await baseCoin.wallets().generateWallet(req.decoded);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


you could directly do smthn like
if(baseCoin.isEVM() && evmKeyRingReferenceWalletId) {
result = await baseCoin.wallets().generateWallet(req.decoded);
}

But this way, if they pass a non-EVM compatible coin, we won't error with a 400; we'd need to maintain another layer for if valid, if !valid logic. So, i feel like the helper function is useful here


if (multisigType === 'tss') {
return handleGenerateMpcWallet(req);
Expand Down Expand Up @@ -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'>,
) {
Comment on lines +220 to +225
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this just wraps the generateWallet method and is repetetive of the main handler, we can remove this

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(),
};
}
7 changes: 7 additions & 0 deletions src/masterBitgoExpress/routers/generateWalletRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};

/**
Expand Down
Loading