PHP WebShell

Текущая директория: /opt/BitGoJS/modules/abstract-utxo/src/recovery

Просмотр файла: backupKeyRecovery.ts

import assert from 'assert';

import _ from 'lodash';
import * as utxolib from '@bitgo/utxo-lib';
import { Dimensions } from '@bitgo/unspents';
import {
  BitGoBase,
  ErrorNoInputToRecover,
  getKrsProvider,
  getBip32Keys,
  getIsKrsRecovery,
  getIsUnsignedSweep,
  isTriple,
  krsProviders,
} from '@bitgo/sdk-core';
import { getMainnet, networks } from '@bitgo/utxo-lib';

import { AbstractUtxoCoin, MultiSigAddress } from '../abstractUtxoCoin';
import { signAndVerifyPsbt } from '../sign';

import { forCoin, RecoveryProvider } from './RecoveryProvider';
import { MempoolApi } from './mempoolApi';
import { CoingeckoApi } from './coingeckoApi';

type ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3;
type ChainCode = utxolib.bitgo.ChainCode;
type RootWalletKeys = utxolib.bitgo.RootWalletKeys;
type WalletUnspent<TNumber extends number | bigint> = utxolib.bitgo.WalletUnspent<TNumber>;
type WalletUnspentJSON = utxolib.bitgo.WalletUnspent & {
  valueString: string;
};

const { getInternalChainCode, scriptTypeForChain, outputScripts, getExternalChainCode } = utxolib.bitgo;

export interface OfflineVaultTxInfo {
  inputs: WalletUnspentJSON[];
}

export interface FormattedOfflineVaultTxInfo {
  txInfo: {
    unspents: WalletUnspentJSON[];
  };
  txHex: string;
  feeInfo: Record<string, never>;
  coin: string;
}

/**
 * This transforms the txInfo from recover into the format that offline-signing-tool expects
 * @param coinName
 * @param txInfo
 * @param txHex
 * @returns {{txHex: *, txInfo: {unspents: *}, feeInfo: {}, coin: void}}
 */
function formatForOfflineVault(
  coinName: string,
  txInfo: OfflineVaultTxInfo,
  txHex: string
): FormattedOfflineVaultTxInfo {
  return {
    txHex,
    txInfo: {
      unspents: txInfo.inputs.map((input) => {
        assert(input.valueString);
        return { ...input, valueString: input.valueString };
      }),
    },
    feeInfo: {},
    coin: coinName,
  };
}

/**
 * Get the current market price from a third party to be used for recovery
 * This function is only intended for non-bitgo recovery transactions, when it is necessary
 * to calculate the rough fee needed to pay to Keyternal. We are okay with approximating,
 * because the resulting price of this function only has less than 1 dollar influence on the
 * fee that needs to be paid to Keyternal.
 *
 * See calculateFeeAmount function:  return Math.round(feeAmountUsd / currentPrice * self.getBaseFactor());
 *
 * This end function should not be used as an accurate endpoint, since some coins' prices are missing from the provider
 */
async function getRecoveryMarketPrice(coin: AbstractUtxoCoin): Promise<number> {
  return await new CoingeckoApi().getUSDPrice(coin.getFamily());
}

/**
 * Calculates the amount (in base units) to pay a KRS provider when building a recovery transaction
 * @param coin
 * @param params
 * @param params.provider {String} the KRS provider that holds the backup key
 * @returns {*}
 */
async function calculateFeeAmount(coin: AbstractUtxoCoin, params: { provider: string }): Promise<number> {
  const krsProvider = krsProviders[params.provider];

  if (krsProvider === undefined) {
    throw new Error(`no fee structure specified for provider ${params.provider}`);
  }

  if (krsProvider.feeType === 'flatUsd') {
    const feeAmountUsd = krsProvider.feeAmount;
    const currentPrice: number = await getRecoveryMarketPrice(coin);

    return Math.round((feeAmountUsd / currentPrice) * coin.getBaseFactor());
  } else {
    // we can add more fee structures here as needed for different providers, such as percentage of recovery amount
    throw new Error('Fee structure not implemented');
  }
}

export interface RecoverParams {
  scan?: number;
  userKey: string;
  backupKey: string;
  bitgoKey: string;
  recoveryDestination: string;
  krsProvider?: string;
  ignoreAddressTypes: ScriptType2Of3[];
  walletPassphrase?: string;
  apiKey?: string;
  userKeyPath?: string;
  recoveryProvider?: RecoveryProvider;
  feeRate?: number;
}

function getFormattedAddress(coin: AbstractUtxoCoin, address: MultiSigAddress) {
  // Blockchair uses cashaddr format when querying the API for address information. Convert legacy addresses to cashaddr
  // before querying the API.
  return coin.getChain() === 'bch' || coin.getChain() === 'bcha'
    ? coin.canonicalAddress(address.address, 'cashaddr').split(':')[1]
    : address.address;
}

async function queryBlockchainUnspentsPath(
  coin: AbstractUtxoCoin,
  params: RecoverParams,
  walletKeys: RootWalletKeys,
  chain: ChainCode
): Promise<WalletUnspent<bigint>[]> {
  const scriptType = scriptTypeForChain(chain);
  const fetchPrevTx =
    !utxolib.bitgo.outputScripts.hasWitnessData(scriptType) && getMainnet(coin.network) !== networks.zcash;
  const recoveryProvider = params.recoveryProvider ?? forCoin(coin.getChain(), params.apiKey);
  const MAX_SEQUENTIAL_ADDRESSES_WITHOUT_TXS = params.scan || 20;
  let numSequentialAddressesWithoutTxs = 0;
  const prevTxCache = new Map<string, string>();

  async function getPrevTx(txid: string): Promise<string> {
    let prevTxHex = prevTxCache.get(txid);
    if (!prevTxHex) {
      prevTxHex = await recoveryProvider.getTransactionHex(txid);
      prevTxCache.set(txid, prevTxHex);
    }
    return prevTxHex;
  }

  async function gatherUnspents(addrIndex: number) {
    const walletKeysForUnspent = walletKeys.deriveForChainAndIndex(chain, addrIndex);
    const address = coin.createMultiSigAddress(scriptType, 2, walletKeysForUnspent.publicKeys);

    const formattedAddress = getFormattedAddress(coin, address);
    const addrInfo = await recoveryProvider.getAddressInfo(formattedAddress);
    // we use txCount here because it implies usage - having tx'es means the addr was generated and used
    if (addrInfo.txCount === 0) {
      numSequentialAddressesWithoutTxs++;
    } else {
      numSequentialAddressesWithoutTxs = 0;

      if (addrInfo.balance > 0) {
        console.log(`Found an address with balance: ${address.address} with balance ${addrInfo.balance}`);
        const addressUnspents = await recoveryProvider.getUnspentsForAddresses([formattedAddress]);
        const processedUnspents = await Promise.all(
          addressUnspents.map(async (u): Promise<WalletUnspent<bigint>> => {
            const { txid, vout } = utxolib.bitgo.parseOutputId(u.id);
            let val = BigInt(u.value);
            if (coin.amountType === 'bigint') {
              // blockchair returns the number with the correct precision, but in number format
              // json parse won't parse it correctly, so we requery the txid for the tx hex to decode here
              if (!Number.isSafeInteger(u.value)) {
                const txHex = await getPrevTx(txid);
                const tx = coin.createTransactionFromHex<bigint>(txHex);
                val = tx.outs[vout].value;
              }
            }
            // the api may return cashaddr's instead of legacy for BCH and BCHA
            // downstream processes's only expect legacy addresses
            u = { ...u, address: coin.canonicalAddress(u.address) };
            return {
              ...u,
              value: val,
              chain: chain,
              index: addrIndex,
              prevTx: fetchPrevTx ? Buffer.from(await getPrevTx(txid), 'hex') : undefined,
            } as WalletUnspent<bigint>;
          })
        );

        walletUnspents.push(...processedUnspents);
      }
    }

    if (numSequentialAddressesWithoutTxs >= MAX_SEQUENTIAL_ADDRESSES_WITHOUT_TXS) {
      // stop searching for addresses with unspents in them, we've found ${MAX_SEQUENTIAL_ADDRESSES_WITHOUT_TXS} in a row with none
      // we are done
      return;
    }

    return gatherUnspents(addrIndex + 1);
  }

  // get unspents for these addresses

  const walletUnspents: WalletUnspent<bigint>[] = [];
  // This will populate walletAddresses
  await gatherUnspents(0);

  if (walletUnspents.length === 0) {
    // Couldn't find any addresses with funds
    return [];
  }

  return walletUnspents;
}

async function getRecoveryFeePerBytes(
  coin: AbstractUtxoCoin,
  { defaultValue }: { defaultValue: number }
): Promise<number> {
  try {
    return await MempoolApi.forCoin(coin.getChain()).getRecoveryFeePerBytes();
  } catch (e) {
    console.dir(e);
    return defaultValue;
  }
}

export type BackupKeyRecoveryTransansaction = {
  inputs?: WalletUnspentJSON[];
  transactionHex: string;
  coin: string;
  backupKey: string;
  recoveryAmount: number;
  recoveryAmountString: string;
};

/**
 * Builds a funds recovery transaction without BitGo.
 *
 * Returns transaction hex in legacy format for unsigned sweep transaction, half signed backup recovery transaction with KRS provider (only keyternal),
 * fully signed backup recovery transaction without a KRS provider.
 *
 * Returns PSBT hex for half signed backup recovery transaction with KRS provider (excluding keyternal)
 * For PSBT hex cases, Unspents are not required in response.
 *
 * @param coin
 * @param bitgo
 * @param params
 * - userKey: [encrypted] xprv, or xpub
 * - backupKey: [encrypted] xprv, or xpub if the xprv is held by a KRS provider
 * - walletPassphrase: necessary if one of the xprvs is encrypted
 * - bitgoKey: xpub
 * - krsProvider: necessary if backup key is held by KRS
 * - recoveryDestination: target address to send recovered funds to
 * - scan: the amount of consecutive addresses without unspents to scan through before stopping
 * - ignoreAddressTypes: (optional) scripts to ignore
 *        for example: ['p2shP2wsh', 'p2wsh'] will prevent code from checking for wrapped-segwit and native-segwit chains on the public block explorers
 */
export async function backupKeyRecovery(
  coin: AbstractUtxoCoin,
  bitgo: BitGoBase,
  params: RecoverParams
): Promise<BackupKeyRecoveryTransansaction | FormattedOfflineVaultTxInfo> {
  if (_.isUndefined(params.userKey)) {
    throw new Error('missing userKey');
  }

  if (_.isUndefined(params.backupKey)) {
    throw new Error('missing backupKey');
  }

  if (
    _.isUndefined(params.recoveryDestination) ||
    !coin.isValidAddress(params.recoveryDestination, { anyFormat: true })
  ) {
    throw new Error('invalid recoveryDestination');
  }

  if (!_.isUndefined(params.scan) && (!_.isInteger(params.scan) || params.scan < 0)) {
    throw new Error('scan must be a positive integer');
  }

  if (params.feeRate !== undefined && (!Number.isFinite(params.feeRate) || params.feeRate <= 0)) {
    throw new Error('feeRate must be a positive number');
  }

  const isKrsRecovery = getIsKrsRecovery(params);
  const isUnsignedSweep = getIsUnsignedSweep(params);
  const responseTxFormat = isUnsignedSweep || !isKrsRecovery || params.krsProvider === 'keyternal' ? 'legacy' : 'psbt';

  const krsProvider = isKrsRecovery ? getKrsProvider(coin, params.krsProvider) : undefined;

  // check whether key material and password authenticate the users and return parent keys of all three keys of the wallet
  const keys = getBip32Keys(bitgo, params, { requireBitGoXpub: true });
  if (!isTriple(keys)) {
    throw new Error(`expected key triple`);
  }
  const walletKeys = new utxolib.bitgo.RootWalletKeys(keys, [
    params.userKeyPath || utxolib.bitgo.RootWalletKeys.defaultPrefix,
    utxolib.bitgo.RootWalletKeys.defaultPrefix,
    utxolib.bitgo.RootWalletKeys.defaultPrefix,
  ]);

  const unspents: WalletUnspent<bigint>[] = (
    await Promise.all(
      outputScripts.scriptTypes2Of3
        .filter(
          (addressType) => coin.supportsAddressType(addressType) && !params.ignoreAddressTypes?.includes(addressType)
        )
        .reduce(
          (queries, addressType) => [
            ...queries,
            queryBlockchainUnspentsPath(coin, params, walletKeys, getExternalChainCode(addressType)),
            queryBlockchainUnspentsPath(coin, params, walletKeys, getInternalChainCode(addressType)),
          ],
          [] as Promise<WalletUnspent<bigint>[]>[]
        )
    )
  ).flat();

  // Execute the queries and gather the unspents
  const totalInputAmount = utxolib.bitgo.unspentSum(unspents, 'bigint');
  if (totalInputAmount <= BigInt(0)) {
    throw new ErrorNoInputToRecover();
  }

  // Build the psbt
  const psbt = utxolib.bitgo.createPsbtForNetwork({ network: coin.network });
  // xpubs can become handy for many things.
  utxolib.bitgo.addXpubsToPsbt(psbt, walletKeys);
  const txInfo = {} as BackupKeyRecoveryTransansaction;
  const feePerByte: number =
    params.feeRate !== undefined ? params.feeRate : await getRecoveryFeePerBytes(coin, { defaultValue: 50 });

  // KRS recovery transactions have a 2nd output to pay the recovery fee, like paygo fees.
  const dimensions = Dimensions.fromPsbt(psbt).plus(isKrsRecovery ? Dimensions.SingleOutput.p2wsh : Dimensions.ZERO);
  const approximateFee = BigInt(dimensions.getVSize() * feePerByte);

  txInfo.inputs =
    responseTxFormat === 'legacy'
      ? unspents.map((u) => ({ ...u, value: Number(u.value), valueString: u.value.toString(), prevTx: undefined }))
      : undefined;

  unspents.forEach((unspent) => {
    utxolib.bitgo.addWalletUnspentToPsbt(psbt, unspent, walletKeys, 'user', 'backup');
  });

  let krsFee = BigInt(0);
  if (isKrsRecovery && params.krsProvider) {
    try {
      krsFee = BigInt(await calculateFeeAmount(coin, { provider: params.krsProvider }));
    } catch (err) {
      // Don't let this error block the recovery -
      console.dir(err);
    }
  }

  const recoveryAmount = totalInputAmount - approximateFee - krsFee;

  if (recoveryAmount < BigInt(0)) {
    throw new Error(`this wallet\'s balance is too low to pay the fees specified by the KRS provider. 
          Existing balance on wallet: ${totalInputAmount.toString()}. Estimated network fee for the recovery transaction
          : ${approximateFee.toString()}, KRS fee to pay: ${krsFee.toString()}. After deducting fees, your total 
          recoverable balance is ${recoveryAmount.toString()}`);
  }

  const recoveryOutputScript = utxolib.address.toOutputScript(params.recoveryDestination, coin.network);
  psbt.addOutput({ script: recoveryOutputScript, value: recoveryAmount });

  if (krsProvider && krsFee > BigInt(0)) {
    if (!krsProvider.feeAddresses) {
      throw new Error(`keyProvider must define feeAddresses`);
    }

    const krsFeeAddress = krsProvider.feeAddresses[coin.getChain()];

    if (!krsFeeAddress) {
      throw new Error('this KRS provider has not configured their fee structure yet - recovery cannot be completed');
    }

    const krsFeeOutputScript = utxolib.address.toOutputScript(krsFeeAddress, coin.network);
    psbt.addOutput({ script: krsFeeOutputScript, value: krsFee });
  }

  if (isUnsignedSweep) {
    // TODO BTC-317 - When ready to PSBTify OVC, send psbt hex and skip unspents in response.
    const txHex = psbt.getUnsignedTx().toBuffer().toString('hex');
    return formatForOfflineVault(coin.getChain(), txInfo as OfflineVaultTxInfo, txHex);
  } else {
    signAndVerifyPsbt(psbt, walletKeys.user, { isLastSignature: false });
    if (isKrsRecovery) {
      // The KRS provider keyternal solely supports P2SH, P2WSH, and P2SH-P2WSH input script types.
      // It currently uses an outdated BitGoJS SDK, which relies on a legacy transaction builder for cosigning.
      // Unfortunately, upgrading the keyternal code presents challenges,
      // which hinders the integration of the latest BitGoJS SDK with PSBT signing support.
      txInfo.transactionHex =
        params.krsProvider === 'keyternal'
          ? utxolib.bitgo.extractP2msOnlyHalfSignedTx(psbt).toBuffer().toString('hex')
          : psbt.toHex();
    } else {
      const tx = signAndVerifyPsbt(psbt, walletKeys.backup, { isLastSignature: true });
      txInfo.transactionHex = tx.toBuffer().toString('hex');
    }
  }

  if (isKrsRecovery) {
    txInfo.coin = coin.getChain();
    txInfo.backupKey = params.backupKey;
    txInfo.recoveryAmount = Number(recoveryAmount);
    txInfo.recoveryAmountString = recoveryAmount.toString();
  }

  return txInfo;
}

export interface BitGoV1Unspent {
  tx_hash: string;
  tx_output_n: number;
  value: number;
}

export interface V1SweepParams {
  walletId: string;
  walletPassphrase: string;
  unspents: BitGoV1Unspent[];
  recoveryDestination: string;
  userKey: string;
  otp: string;
}

export interface V1RecoverParams extends Omit<V1SweepParams, 'otp'> {
  backupKey: string;
}

export async function v1BackupKeyRecovery(
  coin: AbstractUtxoCoin,
  bitgo: BitGoBase,
  params: V1RecoverParams
): Promise<string> {
  if (
    _.isUndefined(params.recoveryDestination) ||
    !coin.isValidAddress(params.recoveryDestination, { anyFormat: true })
  ) {
    throw new Error('invalid recoveryDestination');
  }

  const recoveryFeePerByte = await getRecoveryFeePerBytes(coin, { defaultValue: 50 });
  const v1wallet = await bitgo.wallets().get({ id: params.walletId });
  return await v1wallet.recover({
    ...params,
    feeRate: recoveryFeePerByte,
  });
}

export async function v1Sweep(
  coin: AbstractUtxoCoin,
  bitgo: BitGoBase,
  params: V1SweepParams
): Promise<{
  tx: string;
  hash: string;
  status: string;
}> {
  if (
    _.isUndefined(params.recoveryDestination) ||
    !coin.isValidAddress(params.recoveryDestination, { anyFormat: true })
  ) {
    throw new Error('invalid recoveryDestination');
  }

  let recoveryFeePerByte = 100;
  if (bitgo.env === 'prod') {
    recoveryFeePerByte = await getRecoveryFeePerBytes(coin, { defaultValue: 100 });
  }

  const v1wallet = await bitgo.wallets().get({ id: params.walletId });
  return await v1wallet.sweep({
    ...params,
    feeRate: recoveryFeePerByte,
  });
}

Выполнить команду


Для локальной разработки. Не используйте в интернете!