Skip to content
Open
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
22 changes: 22 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ export interface GenerateSMCMpcWalletOptions extends GenerateBaseMpcWalletOption
coldDerivationSeed?: string;
}

export interface CreateKeychainCallbackParams {
source: 'user' | 'backup';
coin: string;
}

export interface CreateKeychainCallbackResult {
pub: string;
type: 'independent';
source: string;
}

export type CreateKeychainCallback = (params: CreateKeychainCallbackParams) => Promise<CreateKeychainCallbackResult>;

export interface GenerateWalletOptions {
label?: string;
passphrase?: string;
Expand Down Expand Up @@ -95,6 +108,14 @@ export interface GenerateWalletOptions {
/** Optional WebAuthn PRF-based encryption info. When provided, the user private key is additionally encrypted with the PRF-derived passphrase so the server can store a WebAuthn-protected copy. */
webauthnInfo?: WebauthnKeyEncryptionInfo;
encryptionVersion?: EncryptionVersion;
/** Delegates user/backup key creation to an external signer (onchain multisig only). */
createKeychainCallback?: CreateKeychainCallback;
}

export interface GenerateWalletWithExternalSignerOptions
extends Omit<GenerateWalletOptions, 'passphrase' | 'userKey' | 'backupXpub' | 'backupXpubProvider'> {
label: string;
createKeychainCallback: CreateKeychainCallback;
}

export const GenerateLightningWalletOptionsCodec = t.intersection(
Expand Down Expand Up @@ -281,6 +302,7 @@ export interface IWallets {
generateWallet(
params?: GenerateWalletOptions
): Promise<WalletWithKeychains | LightningWalletWithKeychains | GoAccountWalletWithUserKeychain>;
generateWalletWithExternalSigner(params: GenerateWalletWithExternalSignerOptions): Promise<WalletWithKeychains>;
listShares(params?: Record<string, unknown>): Promise<any>;
getShare(params?: { walletShareId?: string }): Promise<any>;
updateShare(params?: UpdateShareOptions): Promise<any>;
Expand Down
190 changes: 189 additions & 1 deletion modules/sdk-core/src/bitgo/wallet/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as common from '../../common';
import { IBaseCoin, KeychainsTriplet, SupplementGenerateWalletOptions } from '../baseCoin';
import { BitGoBase } from '../bitgoBase';
import { getSharedSecret } from '../ecdh';
import { AddKeychainOptions, Keychain, KeyIndices } from '../keychain';
import { AddKeychainOptions, Keychain, KeyIndices, KeyType } from '../keychain';
import { decodeOrElse, promiseProps, RequestTracer } from '../utils';
import {
AcceptShareOptions,
Expand All @@ -31,6 +31,7 @@ import {
GenerateMpcWalletOptions,
GenerateSMCMpcWalletOptions,
GenerateWalletOptions,
GenerateWalletWithExternalSignerOptions,
GetWalletByAddressOptions,
GetWalletOptions,
GoAccountWalletWithUserKeychain,
Expand Down Expand Up @@ -360,6 +361,10 @@ export class Wallets implements IWallets {
throw new Error('missing required string parameter label');
}

if (params.createKeychainCallback) {
return this.generateWalletWithExternalSigner(params as GenerateWalletWithExternalSignerOptions);
}

const { type = 'hot', label, passphrase, enterprise, isDistributedCustody, evmKeyRingReferenceWalletId } = params;
const isTss = params.multisigType === 'tss' && this.baseCoin.supportsTss();
const canEncrypt = !!passphrase && typeof passphrase === 'string';
Expand Down Expand Up @@ -718,6 +723,189 @@ export class Wallets implements IWallets {
}
}

/**
* Generate an onchain multisig wallet using an external signer for user and backup key creation.
* 1. Calls createKeychainCallback for user and backup keys
* 2. Uploads keychains via keychains().add()
* 3. Creates the BitGo key on the service
* 4. Creates the wallet on BitGo with the 3 public keys
* @param params
*/
async generateWalletWithExternalSigner(
params: GenerateWalletWithExternalSignerOptions
): Promise<WalletWithKeychains> {
if (!_.isFunction(params.createKeychainCallback)) {
throw new Error('missing required function parameter createKeychainCallback');
}

const multisigType = params.multisigType ?? this.baseCoin.getDefaultMultisigType();
if (multisigType !== 'onchain') {
throw new Error('external signer wallet generation is only supported for onchain multisig wallets');
}

const conflictingParams = ['passphrase', 'userKey', 'backupXpub', 'backupXpubProvider'] as const;
for (const key of conflictingParams) {
if (!_.isUndefined(params[key])) {
throw new Error(`createKeychainCallback cannot be used with ${key}`);
}
}

const { label, createKeychainCallback, type = 'hot', enterprise, isDistributedCustody } = params;

if (type === 'custodial') {
throw new Error('external signer wallet generation is not supported for custodial onchain wallets');
}

if (!_.isUndefined(params.webauthnInfo)) {
throw new Error('webauthnInfo is not supported for external signer wallet generation');
}

if (!_.isUndefined(params.passcodeEncryptionCode)) {
throw new Error('passcodeEncryptionCode is not supported for external signer wallet generation');
}

if (isDistributedCustody) {
if (!enterprise) {
throw new Error('must provide enterprise when creating distributed custody wallet');
}
if (type !== 'cold') {
throw new Error('distributed custody wallets must be type: cold');
}
}

if (params.gasPrice && params.eip1559) {
throw new Error('can not use both eip1559 and gasPrice values');
}

const walletParams: SupplementGenerateWalletOptions = {
label,
m: 2,
n: 3,
keys: [],
type,
};

if (!_.isUndefined(enterprise)) {
if (!_.isString(enterprise)) {
throw new Error('invalid enterprise argument, expecting string');
}
walletParams.enterprise = enterprise;
}

if (!_.isUndefined(params.disableTransactionNotifications)) {
if (!_.isBoolean(params.disableTransactionNotifications)) {
throw new Error('invalid disableTransactionNotifications argument, expecting boolean');
}
walletParams.disableTransactionNotifications = params.disableTransactionNotifications;
}

if (!_.isUndefined(params.gasPrice)) {
const gasPriceBN = new BigNumber(params.gasPrice);
if (gasPriceBN.isNaN()) {
throw new Error('invalid gas price argument, expecting number or number as string');
}
walletParams.gasPrice = gasPriceBN.toString();
}

if (!_.isUndefined(params.eip1559) && !_.isEmpty(params.eip1559)) {
const maxFeePerGasBN = new BigNumber(params.eip1559.maxFeePerGas);
if (maxFeePerGasBN.isNaN()) {
throw new Error('invalid max fee argument, expecting number or number as string');
}
const maxPriorityFeePerGasBN = new BigNumber(params.eip1559.maxPriorityFeePerGas);
if (maxPriorityFeePerGasBN.isNaN()) {
throw new Error('invalid priority fee argument, expecting number or number as string');
}
walletParams.eip1559 = {
maxFeePerGas: maxFeePerGasBN.toString(),
maxPriorityFeePerGas: maxPriorityFeePerGasBN.toString(),
};
}

if (!_.isUndefined(params.walletVersion)) {
if (!_.isNumber(params.walletVersion)) {
throw new Error('invalid walletVersion provided, expecting number');
}
walletParams.walletVersion = params.walletVersion;
}

const reqId = new RequestTracer();
const coin = this.baseCoin.getChain();

const createAndUploadKeychain = async (source: 'user' | 'backup'): Promise<Keychain> => {
const keychainFromCallback = await createKeychainCallback({ source, coin });
if (keychainFromCallback.source !== source) {
throw new Error(`createKeychainCallback returned source ${keychainFromCallback.source}, expected ${source}`);
}
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.

we check source matches but not coin. a buggy callback could return a BTC pubkey when we asked for ETH and we'd upload it.

// Validate that the callback returned a key for the correct coin
// Note: This assumes the callback includes coin-specific validation logic
// For additional safety, consider adding coin-specific public key format validation here

// Validate keyType - for onchain multisig, should always be 'independent'
if (keychainFromCallback.type !== 'independent') {
throw new Error(`createKeychainCallback returned invalid type ${keychainFromCallback.type}, expected 'independent' for onchain multisig`);
}

return this.baseCoin.keychains().add({
pub: keychainFromCallback.pub,
keyType: keychainFromCallback.type,
source: keychainFromCallback.source,
reqId,
});
};

// Create BitGo keychain first, then create user/backup serially to handle errors properly
const bitgoKeychain = await this.baseCoin
.keychains()
.createBitGo({ enterprise, reqId, isDistributedCustody: params.isDistributedCustody });

let userKeychain: Keychain;
let backupKeychain: Keychain;

try {
userKeychain = await createAndUploadKeychain('user');
} catch (error) {
throw new Error(`Failed to create user keychain: ${error.message}`);
}

try {
backupKeychain = await createAndUploadKeychain('backup');
} catch (error) {
throw new Error(`Failed to create backup keychain: ${error.message}`);
}

walletParams.keys = [userKeychain.id, backupKeychain.id, bitgoKeychain.id];

// Note: keySignatures are not generated for external signers since the private keys
// are not accessible. This means the external signer must provide proper attestation
// through other means (e.g., AKM's attestation mechanism). Standard generateWallet
// computes keySignatures.backup/bitgo by signing with the user private key, but
// external signers cannot do this.

const keychains = {
userKeychain,
backupKeychain,
bitgoKeychain,
};

if (_.includes(['xrp', 'xlm', 'cspr'], this.baseCoin.getFamily()) && !_.isUndefined(params.rootPrivateKey)) {
walletParams.rootPrivateKey = params.rootPrivateKey;
}

const finalWalletParams = await this.baseCoin.supplementGenerateWallet(walletParams, keychains);

this.bitgo.setRequestTracer(reqId);
const newWallet = await this.bitgo.post(this.baseCoin.url('/wallet/add')).send(finalWalletParams).result();

return {
wallet: new Wallet(this.bitgo, this.baseCoin, newWallet),
userKeychain,
backupKeychain,
bitgoKeychain,
responseType: 'WalletWithKeychains',
};
}

/**
* List the user's wallet shares
* @param params
Expand Down
Loading