diff --git a/mocks/wallet-recovery/eth-recovery-sign-half.json b/mocks/wallet-recovery/eth-recovery-sign-half.json new file mode 100644 index 0000000..4d27a48 --- /dev/null +++ b/mocks/wallet-recovery/eth-recovery-sign-half.json @@ -0,0 +1,15 @@ +{ + "halfSigned": { + "recipients": [ + { + "address": "0xe7d07af8e3e7472ea8391a3372ab98d04ac4df20", + "amount": "1000000000000000000" + } + ], + "expireTime": 1750182870, + "contractSequenceId": 1, + "operationHash": "0x92d3a28bd75dfa559c60e679b98fddfcb7dcaeb579c25cab3f9442b25fd270e2", + "signature": "0x62c594b62ce2fc9f1d2e82a105668ed53528eb02635b8ad73206fe75ed26b26923450ed54b87980296c362fe03c7bc8e156d1ab38bfe9a682ba585e7d92d88e31b", + "backupKeyNonce": 0 + } +} diff --git a/mocks/wallet-recovery/musig-eth-recovery-sign-full.json b/mocks/wallet-recovery/musig-eth-recovery-sign-full.json new file mode 100644 index 0000000..b9bc5e6 --- /dev/null +++ b/mocks/wallet-recovery/musig-eth-recovery-sign-full.json @@ -0,0 +1,3 @@ +{ + "txHex": "f901c380808094223fe2adcc8f28d8a46f72f7f355117d2727554d80b9016439125215000000000000000000000000e7d07af8e3e7472ea8391a3372ab98d04ac4df200000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000006851b0bf000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041917bcebd0b1f43a25b72b161dbd4db539c282af9f3856fc60f701471f0df22e22b5428593f5affc1a88944a5c5255c6c2d0df87a0668864d551a5409ec9f82ca1c000000000000000000000000000000000000000000000000000000000000001ca0c1e0750ac2c3c1cc98997dd1a14f96f1ba65929503bbdbd96ffa00fdeea2e51fa05cca504d869dcf2935c46dc5a733c0c3d8483ce2b452cae2bf89b7417d7f80b9" +} diff --git a/mocks/wallet-recovery/musig-eth-recovery-unsigned.json b/mocks/wallet-recovery/musig-eth-recovery-unsigned.json new file mode 100644 index 0000000..a74ddd4 --- /dev/null +++ b/mocks/wallet-recovery/musig-eth-recovery-unsigned.json @@ -0,0 +1,24 @@ +{ + "tx": "f9012b808504a817c8008307a12094223fe2adcc8f28d8a46f72f7f355117d2727554d80b9010439125215000000000000000000000000e7d07af8e3e7472ea8391a3372ab98d04ac4df200000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000006851a693000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000808080", + "userKey": "xpub661MyMwAqRbcFigezGWEYSbCPVuaUmvnp1u7iEpH9YsKU6uYQtPANvudjgAo82QRHXsUieMqKeB1xEj89VUKU1ugtmyAZ3xzNEbHPexxgKK", + "backupKey": "xpub661MyMwAqRbcGbCirzmQsUJT2eidt9tFLw2m77w6FiKco6TKu49CP3GkHF88xGCpvqkP93SYMAarfyWAn8UWevQtNT6pDo8xH7xmf6GqK6e", + "coin": "hteth", + "gasPrice": "20000000000", + "gasLimit": "500000", + "recipients": [ + { + "address": "0xe7d07af8e3e7472ea8391a3372ab98d04ac4df20", + "amount": "1000000000000000000" + } + ], + "walletContractAddress": "0x223fe2adcc8f28d8a46f72f7f355117d2727554d", + "amount": "1000000000000000000", + "backupKeyNonce": 0, + "recipient": { + "address": "0xe7d07af8e3e7472ea8391a3372ab98d04ac4df20", + "amount": "1000000000000000000" + }, + "expireTime": 1750181523, + "contractSequenceId": 1, + "nextContractSequenceId": 1 +} diff --git a/src/api/enclaved/recoveryMultisigTransaction.ts b/src/api/enclaved/recoveryMultisigTransaction.ts new file mode 100644 index 0000000..bd6c3f3 --- /dev/null +++ b/src/api/enclaved/recoveryMultisigTransaction.ts @@ -0,0 +1,68 @@ +import { SignFinalOptions } from '@bitgo/abstract-eth'; +import { MethodNotImplementedError } from 'bitgo'; +import { EnclavedApiSpecRouteRequest } from '../../enclavedBitgoExpress/routers/enclavedApiSpec'; +import logger from '../../logger'; +import { isEthLikeCoin } from '../../shared/coinUtils'; +import { retrieveKmsKey } from './utils'; + +export async function recoveryMultisigTransaction( + req: EnclavedApiSpecRouteRequest<'v1.multisig.recovery', 'post'>, +): Promise { + const { userPub, backupPub, unsignedSweepPrebuildTx, walletContractAddress } = req.body; + + //fetch prv and check that pub are valid + const userPrv = await retrieveKmsKey({ pub: userPub, source: 'user', cfg: req.config }); + const backupPrv = await retrieveKmsKey({ pub: backupPub, source: 'backup', cfg: req.config }); + + if (!userPrv || !backupPrv) { + const errorMsg = `Error while recovery wallet, missing prv keys for user or backup on pub keys user=${userPub}, backup=${backupPub}`; + logger.error(errorMsg); + throw new Error(errorMsg); + } + + const bitgo = req.bitgo; + const coin = bitgo.coin(req.decoded.coin); + + // The signed transaction format depends on the coin type so we do this check as a guard + // If you check the type of coin before and after the "if", you may see "BaseCoin" vs "AbstractEthLikeCoin" + if (coin.isEVM()) { + // Every recovery method on every coin family varies one from another so we need to ensure with a guard. + if (isEthLikeCoin(coin)) { + try { + const halfSignedTx = await coin.signTransaction({ + isLastSignature: false, + prv: userPrv, + txPrebuild: { ...unsignedSweepPrebuildTx } as unknown as SignFinalOptions, + walletContractAddress, + }); + + const { halfSigned } = halfSignedTx as any; + const fullSignedTx = await coin.signTransaction({ + isLastSignature: true, + prv: backupPrv, + txPrebuild: { + ...halfSignedTx, + txHex: halfSigned.signatures, + halfSigned, + recipients: halfSigned.recipients ?? [], + } as unknown as SignFinalOptions, + walletContractAddress, + signingKeyNonce: halfSigned.signingKeyNonce ?? 0, + backupKeyNonce: halfSigned.backupKeyNonce ?? 0, + recipients: halfSigned.recipients ?? [], + }); + + return fullSignedTx; + } catch (error) { + logger.error('error while recovering wallet transaction:', error); + throw error; + } + } else { + const errorMsg = 'Unsupported coin type for recovery: ' + req.decoded.coin; + logger.error(errorMsg); + throw new Error(errorMsg); + } + } else { + throw new MethodNotImplementedError('Unsupported coin type for recovery: ' + coin); + } +} diff --git a/src/api/enclaved/utils.ts b/src/api/enclaved/utils.ts new file mode 100644 index 0000000..ac4d1ae --- /dev/null +++ b/src/api/enclaved/utils.ts @@ -0,0 +1,26 @@ +// TODO: this function is duplicated in multisigTransactioSign.ts but as hardcoded. Replace that code later with this call (to avoid merge conflicts/duplication) +import { KmsClient } from '../../kms/kmsClient'; +import { EnclavedConfig } from '../../types'; +export async function retrieveKmsKey({ + pub, + source, + cfg, +}: { + pub: string; + source: string; + cfg: EnclavedConfig; +}): Promise { + const kms = new KmsClient(cfg); + // Retrieve the private key from KMS + let prv: string; + try { + const res = await kms.getKey({ pub, source }); + prv = res.prv; + return prv; + } catch (error: any) { + throw { + status: error.status || 500, + message: error.message || 'Failed to retrieve key from KMS', + }; + } +} diff --git a/src/enclavedBitgoExpress/routers/enclavedApiSpec.ts b/src/enclavedBitgoExpress/routers/enclavedApiSpec.ts index 663c78e..11ee96a 100644 --- a/src/enclavedBitgoExpress/routers/enclavedApiSpec.ts +++ b/src/enclavedBitgoExpress/routers/enclavedApiSpec.ts @@ -1,23 +1,24 @@ -import * as t from 'io-ts'; import { apiSpec, - httpRoute, + Method as HttpMethod, httpRequest, HttpResponse, - Method as HttpMethod, + httpRoute, } from '@api-ts/io-ts-http'; +import { Response } from '@api-ts/response'; import { createRouter, - type WrappedRouter, TypedRequestHandler, + type WrappedRouter, } from '@api-ts/typed-express-router'; -import { Response } from '@api-ts/response'; import express from 'express'; -import { BitGoRequest } from '../../types/request'; -import { EnclavedConfig } from '../../types'; +import * as t from 'io-ts'; import { postIndependentKey } from '../../api/enclaved/postIndependentKey'; +import { recoveryMultisigTransaction } from '../../api/enclaved/recoveryMultisigTransaction'; import { signMultisigTransaction } from '../../api/enclaved/signMultisigTransaction'; import { prepareBitGo, responseHandler } from '../../shared/middleware'; +import { EnclavedConfig } from '../../types'; +import { BitGoRequest } from '../../types/request'; // Request type for /key/independent endpoint const IndependentKeyRequest = { @@ -39,7 +40,7 @@ const IndependentKeyResponse: HttpResponse = { const SignMultisigRequest = { source: t.string, pub: t.string, - txPrebuild: t.any, // TransactionPrebuild type from BitGo + txPrebuild: t.any, }; // Response type for /multisig/sign endpoint @@ -52,6 +53,32 @@ const SignMultisigResponse: HttpResponse = { }), }; +// Request type for /multisig/recovery endpoint +const RecoveryMultisigRequest = { + userPub: t.string, + backupPub: t.string, + apiKey: t.string, + // TODO: best typing for this, they come from sdk TS types + unsignedSweepPrebuildTx: t.any, + coinSpecificParams: t.union([ + t.undefined, + t.partial({ + bitgoPub: t.union([t.undefined, t.string]), + ignoreAddressTypes: t.union([t.undefined, t.array(t.string)]), + }), + ]), +}; + +// Response type for /multisig/recovery endpoint +const RecoveryMultisigResponse: HttpResponse = { + // TODO: Define proper response type for recovery multisig transaction + 200: t.any, // the full signed tx + 500: t.type({ + error: t.string, + details: t.string, + }), +}; + // API Specification export const EnclavedAPiSpec = apiSpec({ 'v1.multisig.sign': { @@ -68,6 +95,20 @@ export const EnclavedAPiSpec = apiSpec({ description: 'Sign a multisig transaction', }), }, + 'v1.multisig.recovery': { + post: httpRoute({ + method: 'POST', + path: '/{coin}/multisig/recovery', + request: httpRequest({ + params: { + coin: t.string, + }, + body: RecoveryMultisigRequest, + }), + response: RecoveryMultisigResponse, + description: 'Recover a multisig transaction', + }), + }, 'v1.key.independent': { post: httpRoute({ method: 'POST', @@ -121,5 +162,13 @@ export function createKeyGenRouter(config: EnclavedConfig): WrappedRouter(async (req) => { + const typedReq = req as EnclavedApiSpecRouteRequest<'v1.multisig.recovery', 'post'>; + const result = await recoveryMultisigTransaction(typedReq); + return Response.ok(result); + }), + ]); + return router; } diff --git a/src/masterBitgoExpress/enclavedExpressClient.ts b/src/masterBitgoExpress/enclavedExpressClient.ts index 0b95695..64d2331 100644 --- a/src/masterBitgoExpress/enclavedExpressClient.ts +++ b/src/masterBitgoExpress/enclavedExpressClient.ts @@ -1,9 +1,9 @@ -import superagent from 'superagent'; -import https from 'https'; -import debug from 'debug'; -import { MasterExpressConfig } from '../types'; -import { TlsMode } from '../types'; +import { OfflineVaultTxInfo, RecoveryInfo, UnsignedSweepTxMPCv2 } from '@bitgo/sdk-coin-eth'; import { SignedTransaction, TransactionPrebuild } from '@bitgo/sdk-core'; +import debug from 'debug'; +import https from 'https'; +import superagent from 'superagent'; +import { MasterExpressConfig, TlsMode } from '../types'; const debugLogger = debug('bitgo:express:enclavedExpressClient'); @@ -29,6 +29,18 @@ interface SignMultisigOptions { pub: string; } +interface RecoveryMultisigOptions { + userPub: string; + backupPub: string; + unsignedSweepPrebuildTx: RecoveryInfo | OfflineVaultTxInfo | UnsignedSweepTxMPCv2; + apiKey: string; + walletContractAddress: string; + coinSpecificParams?: { + bitgoPub?: string; + ignoreAddressTypes?: string[]; + }; +} + export class EnclavedExpressClient { private readonly baseUrl: string; private readonly enclavedExpressCert: string; @@ -134,6 +146,27 @@ export class EnclavedExpressClient { throw err; } } + + /** + * Recover a multisig transaction + */ + async recoveryMultisig(params: RecoveryMultisigOptions): Promise { + if (!this.coin) { + throw new Error('Coin must be specified to recover a multisig'); + } + + try { + const res = await this.configureRequest( + superagent.post(`${this.baseUrl}/api/${this.coin}/multisig/recovery`).type('json'), + ).send(params); + + return res.body; + } catch (error) { + const err = error as Error; + debugLogger('Failed to recover multisig: %s', err.message); + throw err; + } + } } /** diff --git a/src/masterBitgoExpress/recoveryWallet.ts b/src/masterBitgoExpress/recoveryWallet.ts new file mode 100644 index 0000000..3052c8f --- /dev/null +++ b/src/masterBitgoExpress/recoveryWallet.ts @@ -0,0 +1,53 @@ +import { MethodNotImplementedError } from 'bitgo'; +import { isEthLikeCoin } from '../shared/coinUtils'; +import { MasterApiSpecRouteRequest } from './routers/masterApiSpec'; + +export async function handleRecoveryWalletOnPrem( + req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, +) { + const bitgo = req.bitgo; + const coin = req.decoded.coin; + const enclavedExpressClient = req.enclavedExpressClient; + + const { + userPub, + backupPub, + walletContractAddress, + recoveryDestinationAddress, + coinSpecificParams, + apiKey, + } = req.decoded; + + //construct a common payload for the recovery that it's repeated in any kind of recovery + const commonRecoveryParams = { + userKey: userPub, + backupKey: backupPub, + walletContractAddress, + recoveryDestination: recoveryDestinationAddress, + apiKey, + }; + + const sdkCoin = bitgo.coin(coin); + + if (isEthLikeCoin(sdkCoin)) { + try { + const unsignedSweepPrebuildTx = await sdkCoin.recover({ + ...commonRecoveryParams, + }); + const fullSignedRecoveryTx = await enclavedExpressClient.recoveryMultisig({ + userPub, + backupPub, + apiKey, + unsignedSweepPrebuildTx, + coinSpecificParams, + walletContractAddress, + }); + + return fullSignedRecoveryTx; + } catch (err) { + throw err; + } + } else { + throw new MethodNotImplementedError('Recovery wallet is not supported for this coin: ' + coin); + } +} diff --git a/src/masterBitgoExpress/routers/masterApiSpec.ts b/src/masterBitgoExpress/routers/masterApiSpec.ts index 4c5d5fc..db8b248 100644 --- a/src/masterBitgoExpress/routers/masterApiSpec.ts +++ b/src/masterBitgoExpress/routers/masterApiSpec.ts @@ -1,24 +1,25 @@ -import * as t from 'io-ts'; import { apiSpec, - httpRoute, + Method as HttpMethod, httpRequest, HttpResponse, - Method as HttpMethod, + httpRoute, } from '@api-ts/io-ts-http'; +import { Response } from '@api-ts/response'; import { createRouter, TypedRequestHandler, type WrappedRouter, } from '@api-ts/typed-express-router'; -import { Response } from '@api-ts/response'; import express from 'express'; -import { BitGoRequest } from '../../types/request'; +import * as t from 'io-ts'; import { MasterExpressConfig } from '../../initConfig'; -import { handleGenerateWalletOnPrem } from '../generateWallet'; import { prepareBitGo, responseHandler } from '../../shared/middleware'; +import { BitGoRequest } from '../../types/request'; +import { handleGenerateWalletOnPrem } from '../generateWallet'; import { handleSendMany } from '../handleSendMany'; import { validateMasterExpressConfig } from '../middleware'; +import { handleRecoveryWalletOnPrem } from '../recoveryWallet'; // Middleware functions export function parseBody(req: express.Request, res: express.Response, next: express.NextFunction) { @@ -86,6 +87,32 @@ export const SendManyResponse: HttpResponse = { }), }; +// Response type for /recovery endpoint +const RecoveryWalletResponse: HttpResponse = { + // TODO: Get type from public types repo + 200: t.any, + 500: t.type({ + error: t.string, + details: t.string, + }), +}; + +// Request type for /recovery endpoint +const RecoveryWalletRequest = { + userPub: t.string, + backupPub: t.string, + walletContractAddress: t.string, + recoveryDestinationAddress: t.string, + apiKey: t.string, + coinSpecificParams: t.union([ + t.undefined, + t.partial({ + bitgoPub: t.union([t.undefined, t.string]), + ignoreAddressTypes: t.union([t.undefined, t.array(t.string)]), + }), + ]), +}; + // API Specification export const MasterApiSpec = apiSpec({ 'v1.wallet.generate': { @@ -117,6 +144,20 @@ export const MasterApiSpec = apiSpec({ description: 'Send many transactions', }), }, + 'v1.wallet.recovery': { + post: httpRoute({ + method: 'POST', + path: '/api/{coin}/wallet/recovery', + request: httpRequest({ + params: { + coin: t.string, + }, + body: RecoveryWalletRequest, + }), + response: RecoveryWalletResponse, + description: 'Recover an existing wallet', + }), + }, }); export type MasterApiSpec = typeof MasterApiSpec; @@ -161,5 +202,13 @@ export function createMasterApiRouter( }), ]); + router.post('v1.wallet.recovery', [ + responseHandler(async (req: express.Request) => { + const typedReq = req as GenericMasterApiSpecRouteRequest; + const result = await handleRecoveryWalletOnPrem(typedReq); + return Response.ok(result); + }), + ]); + return router; } diff --git a/src/shared/coinUtils.ts b/src/shared/coinUtils.ts new file mode 100644 index 0000000..8a37a60 --- /dev/null +++ b/src/shared/coinUtils.ts @@ -0,0 +1,56 @@ +import { AbstractEthLikeNewCoins } from '@bitgo/abstract-eth'; +import { CoinFamily } from '@bitgo/statics'; +import { BaseCoin } from 'bitgo'; +import { AbstractUtxoCoin, Eos, Stx, Xtz } from 'bitgo/dist/types/src/v2/coins'; + +export function isEthLikeCoin(coin: BaseCoin): coin is AbstractEthLikeNewCoins { + const isEthPure = isFamily(coin, CoinFamily.ETH); + + const isEthLike = + isFamily(coin, CoinFamily.ETHW) || // ethw has its own family. as the others + isFamily(coin, CoinFamily.RBTC) || + isFamily(coin, CoinFamily.ETC) || + isFamily(coin, CoinFamily.AVAXC) || + isFamily(coin, CoinFamily.POLYGON) || + isFamily(coin, CoinFamily.ARBETH) || + isFamily(coin, CoinFamily.OPETH) || + isFamily(coin, CoinFamily.BSC) || + isFamily(coin, CoinFamily.BASEETH) || + isFamily(coin, CoinFamily.COREDAO) || + isFamily(coin, CoinFamily.OAS) || + isFamily(coin, CoinFamily.FLR) || + isFamily(coin, CoinFamily.SGB) || + isFamily(coin, CoinFamily.WEMIX) || + isFamily(coin, CoinFamily.XDC); + + return isEthPure || isEthLike; +} + +export function isUtxoCoin(coin: BaseCoin): coin is AbstractUtxoCoin { + const isBtc = isFamily(coin, CoinFamily.BTC); + + const isBtcLike = + isFamily(coin, CoinFamily.LTC) || + isFamily(coin, CoinFamily.BCH) || + isFamily(coin, CoinFamily.ZEC) || + isFamily(coin, CoinFamily.DASH) || + isFamily(coin, CoinFamily.BTG); + + return isBtc || isBtcLike; +} + +export function isEosCoin(coin: BaseCoin): coin is Eos { + return isFamily(coin, CoinFamily.EOS); +} + +export function isStxCoin(coin: BaseCoin): coin is Stx { + return isFamily(coin, CoinFamily.STX); +} + +export function isXtzCoin(coin: BaseCoin): coin is Xtz { + return isFamily(coin, CoinFamily.XTZ); +} + +function isFamily(coin: BaseCoin, family: CoinFamily) { + return Boolean(coin && coin.getFamily() === family); +}