PHP WebShell

Текущая директория: /opt/BitGoJS/modules/sdk-coin-icp/src

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

import assert from 'assert';
import {
  BaseBroadcastTransactionOptions,
  BaseBroadcastTransactionResult,
  BaseCoin,
  BitGoBase,
  Ecdsa,
  ECDSAUtils,
  Environments,
  KeyPair,
  MPCAlgorithm,
  MultisigType,
  multisigTypes,
  ParsedTransaction,
  ParseTransactionOptions,
  SignedTransaction,
  SigningError,
  SignTransactionOptions,
  TssVerifyAddressOptions,
  VerifyTransactionOptions,
} from '@bitgo/sdk-core';
import { coins, BaseCoin as StaticsBaseCoin } from '@bitgo/statics';
import { Principal } from '@dfinity/principal';
import axios from 'axios';
import BigNumber from 'bignumber.js';
import { createHash, Hash } from 'crypto';
import * as request from 'superagent';
import {
  ACCOUNT_BALANCE_ENDPOINT,
  CurveType,
  LEDGER_CANISTER_ID,
  Network,
  PayloadsData,
  PUBLIC_NODE_REQUEST_ENDPOINT,
  PublicNodeSubmitResponse,
  RecoveryOptions,
  ROOT_PATH,
  Signatures,
  SigningPayload,
  IcpTransactionExplanation,
  TransactionHexParams,
} from './lib/iface';
import { TransactionBuilderFactory } from './lib/transactionBuilderFactory';
import utils from './lib/utils';

/**
 * Class representing the Internet Computer (ICP) coin.
 * Extends the BaseCoin class and provides specific implementations for ICP.
 *
 * @see {@link https://internetcomputer.org/}
 * @see {@link https://internetcomputer.org/docs/current/developer-docs/defi/rosetta/icp_rosetta/data_api/}
 */
export class Icp extends BaseCoin {
  protected readonly _staticsCoin: Readonly<StaticsBaseCoin>;
  protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>) {
    super(bitgo);

    if (!staticsCoin) {
      throw new Error('missing required constructor parameter staticsCoin');
    }

    this._staticsCoin = staticsCoin;
  }

  static createInstance(bitgo: BitGoBase, staticsCoin?: Readonly<StaticsBaseCoin>): BaseCoin {
    return new Icp(bitgo, staticsCoin);
  }

  getChain(): string {
    return 'icp';
  }

  getBaseChain(): string {
    return 'icp';
  }

  getFamily(): string {
    return this._staticsCoin.family;
  }

  getFullName(): string {
    return 'Internet Computer';
  }

  getBaseFactor(): number {
    return Math.pow(10, this._staticsCoin.decimalPlaces);
  }

  async explainTransaction(params: TransactionHexParams): Promise<IcpTransactionExplanation> {
    const factory = this.getBuilderFactory();
    const txBuilder = await factory.from(params.transactionHex);
    const transaction = await txBuilder.build();
    if (params.signableHex !== undefined) {
      const generatedSignableHex = txBuilder.transaction.payloadsData.payloads[0].hex_bytes;
      if (generatedSignableHex !== params.signableHex) {
        throw new Error('generated signableHex is not equal to params.signableHex');
      }
    }
    return transaction.explainTransaction();
  }

  async verifyTransaction(params: VerifyTransactionOptions): Promise<boolean> {
    const { txParams, txPrebuild } = params;
    const txHex = txPrebuild?.txHex;
    if (!txHex) {
      throw new Error('txHex is required');
    }
    const txHexParams: TransactionHexParams = {
      transactionHex: txHex,
    };

    if (txPrebuild.txInfo && txPrebuild.txInfo !== undefined && typeof txPrebuild.txInfo === 'string') {
      txHexParams.signableHex = txPrebuild.txInfo;
    }

    const explainedTx = await this.explainTransaction(txHexParams);

    if (Array.isArray(txParams.recipients) && txParams.recipients.length > 0) {
      if (txParams.recipients.length > 1) {
        throw new Error(
          `${this.getChain()} doesn't support sending to more than 1 destination address within a single transaction. Try again, using only a single recipient.`
        );
      }
      assert(explainedTx.outputs.length === 1, 'Tx outputs does not match with expected txParams recipients');

      const output = explainedTx.outputs[0];
      const recipient = txParams.recipients[0];
      assert(
        typeof recipient.address === 'string' &&
          typeof output.address === 'string' &&
          output.address === recipient.address &&
          BigNumber(output.amount).eq(BigNumber(recipient.amount)),
        'Tx outputs does not match with expected txParams recipients'
      );
    }
    return true;
  }

  async isWalletAddress(params: TssVerifyAddressOptions): Promise<boolean> {
    return this.isValidAddress(params.address);
  }

  async parseTransaction(params: ParseTransactionOptions): Promise<ParsedTransaction> {
    return {};
  }

  /**
   * Generate a new keypair for this coin.
   * @param seed Seed from which the new keypair should be generated, otherwise a random seed is used
   */
  public generateKeyPair(seed?: Buffer): KeyPair {
    return utils.generateKeyPair(seed);
  }

  isValidAddress(address: string): boolean {
    return utils.isValidAddress(address);
  }

  async signTransaction(
    params: SignTransactionOptions & { txPrebuild: { txHex: string }; prv: string }
  ): Promise<SignedTransaction> {
    const txHex = params?.txPrebuild?.txHex;
    const privateKey = params?.prv;
    if (!txHex) {
      throw new SigningError('missing required txPrebuild parameter: params.txPrebuild.txHex');
    }
    if (!privateKey) {
      throw new SigningError('missing required prv parameter: params.prv');
    }
    const factory = this.getBuilderFactory();
    const txBuilder = await factory.from(params.txPrebuild.txHex);
    txBuilder.sign({ key: params.prv });
    txBuilder.combine();
    const serializedTx = txBuilder.transaction.toBroadcastFormat();
    return {
      txHex: serializedTx,
    };
  }

  isValidPub(key: string): boolean {
    return utils.isValidPublicKey(key);
  }

  isValidPrv(key: string): boolean {
    return utils.isValidPrivateKey(key);
  }

  /** @inheritDoc */
  supportsTss(): boolean {
    return true;
  }

  /** inherited doc */
  getDefaultMultisigType(): MultisigType {
    return multisigTypes.tss;
  }

  /** @inheritDoc */
  getMPCAlgorithm(): MPCAlgorithm {
    return 'ecdsa';
  }

  /** @inheritDoc **/
  getHashFunction(): Hash {
    return createHash('sha256');
  }

  private async getAddressFromPublicKey(hexEncodedPublicKey: string) {
    return utils.getAddressFromPublicKey(hexEncodedPublicKey);
  }

  /** @inheritDoc **/
  protected getPublicNodeUrl(): string {
    return Environments[this.bitgo.getEnv()].icpNodeUrl;
  }

  protected getRosettaNodeUrl(): string {
    return Environments[this.bitgo.getEnv()].icpRosettaNodeUrl;
  }

  /**
   * Sends a POST request to the Rosetta node with the specified payload and endpoint.
   *
   * @param payload - A JSON string representing the request payload to be sent to the Rosetta node.
   * @param endpoint - The endpoint path to append to the Rosetta node URL.
   * @returns A promise that resolves to the HTTP response from the Rosetta node.
   * @throws An error if the HTTP request fails or if the response status is not 200.
   */
  protected async getRosettaNodeResponse(payload: string, endpoint: string): Promise<request.Response> {
    const nodeUrl = this.getRosettaNodeUrl();
    const fullEndpoint = `${nodeUrl}${endpoint}`;
    const body = {
      network_identifier: {
        blockchain: this.getFullName(),
        network: Network.ID,
      },
      ...JSON.parse(payload),
    };

    try {
      const response = await request.post(fullEndpoint).set('Content-Type', 'application/json').send(body);
      if (response.status !== 200) {
        throw new Error(`Call to Rosetta node failed, got HTTP Status: ${response.status} with body: ${response.body}`);
      }
      return response;
    } catch (error) {
      throw new Error(`Unable to call rosetta node: ${error.message || error}`);
    }
  }

  /* inheritDoc */
  // this method calls the public node to broadcast the transaction and not the rosetta node
  public async broadcastTransaction(payload: BaseBroadcastTransactionOptions): Promise<BaseBroadcastTransactionResult> {
    const endpoint = this.getPublicNodeBroadcastEndpoint();

    try {
      const response = await axios.post(endpoint, payload.serializedSignedTransaction, {
        responseType: 'arraybuffer', // This ensures you get a Buffer, not a string
        headers: {
          'Content-Type': 'application/cbor',
        },
      });

      if (response.status !== 200) {
        throw new Error(`Transaction broadcast failed with status: ${response.status} - ${response.statusText}`);
      }

      const decodedResponse = utils.cborDecode(response.data) as PublicNodeSubmitResponse;

      if (decodedResponse.status === 'replied') {
        const txnId = this.extractTransactionId(decodedResponse);
        return { txId: txnId };
      } else {
        throw new Error(`Unexpected response status from node: ${decodedResponse.status}`);
      }
    } catch (error) {
      throw new Error(`Transaction broadcast error: ${error?.message || JSON.stringify(error)}`);
    }
  }

  private getPublicNodeBroadcastEndpoint(): string {
    const nodeUrl = this.getPublicNodeUrl();
    const principal = Principal.fromUint8Array(LEDGER_CANISTER_ID);
    const canisterIdHex = principal.toText();
    const endpoint = `${nodeUrl}${PUBLIC_NODE_REQUEST_ENDPOINT}${canisterIdHex}/call`;
    return endpoint;
  }

  // TODO: Implement the real logic to extract the transaction ID, Ticket: https://bitgoinc.atlassian.net/browse/WIN-5075
  private extractTransactionId(decodedResponse: PublicNodeSubmitResponse): string {
    return '4c10cf22a768a20e7eebc86e49c031d0e22895a39c6355b5f7455b2acad59c1e';
  }

  /**
   * Helper to fetch account balance
   * @param senderAddress - The address of the account to fetch the balance for
   * @returns The balance of the account as a string
   * @throws If the account is not found or there is an error fetching the balance
   */
  protected async getAccountBalance(address: string): Promise<string> {
    try {
      const payload = {
        account_identifier: {
          address: address,
        },
      };
      const response = await this.getRosettaNodeResponse(JSON.stringify(payload), ACCOUNT_BALANCE_ENDPOINT);
      const coinName = this._staticsCoin.name.toUpperCase();
      const balanceEntry = response.body.balances.find((b) => b.currency?.symbol === coinName);
      if (!balanceEntry) {
        throw new Error(`No balance found for ICP account ${address}.`);
      }
      const balance = balanceEntry.value;
      return balance;
    } catch (error) {
      throw new Error(`Unable to fetch account balance: ${error.message || error}`);
    }
  }

  private getBuilderFactory(): TransactionBuilderFactory {
    return new TransactionBuilderFactory(coins.get(this.getBaseChain()));
  }

  /**
   * Generates an array of signatures for the provided payloads using MPC
   *
   * @param payloadsData - The data containing the payloads to be signed.
   * @param senderPublicKey - The public key of the sender in hexadecimal format.
   * @param userKeyShare - The user's key share as a Buffer.
   * @param backupKeyShare - The backup key share as a Buffer.
   * @param commonKeyChain - The common key chain identifier used for MPC signing.
   * @returns A promise that resolves to an array of `Signatures` objects, each containing the signing payload,
   *          signature type, public key, and the generated signature in hexadecimal format.
   */
  async signatures(
    payloadsData: PayloadsData,
    senderPublicKey: string,
    userKeyShare: Buffer<ArrayBufferLike>,
    backupKeyShare: Buffer<ArrayBufferLike>,
    commonKeyChain: string
  ): Promise<Signatures[]> {
    try {
      const payload = payloadsData.payloads[0] as SigningPayload;
      const message = Buffer.from(payload.hex_bytes, 'hex');
      const messageHash = createHash('sha256').update(message).digest();
      const signature = await ECDSAUtils.signRecoveryMpcV2(messageHash, userKeyShare, backupKeyShare, commonKeyChain);
      const signaturePayload: Signatures = {
        signing_payload: payload,
        signature_type: payload.signature_type,
        public_key: {
          hex_bytes: senderPublicKey,
          curve_type: CurveType.SECP256K1,
        },
        hex_bytes: signature.r + signature.s,
      };

      return [signaturePayload];
    } catch (error) {
      throw new Error(`Error generating signatures: ${error.message || error}`);
    }
  }

  /**
   * Builds a funds recovery transaction without BitGo
   * @param params
   */
  async recover(params: RecoveryOptions): Promise<string> {
    if (!params.recoveryDestination || !this.isValidAddress(params.recoveryDestination)) {
      throw new Error('invalid recoveryDestination');
    }

    if (!params.userKey) {
      throw new Error('missing userKey');
    }

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

    if (!params.walletPassphrase) {
      throw new Error('missing wallet passphrase');
    }

    const userKey = params.userKey.replace(/\s/g, '');
    const backupKey = params.backupKey.replace(/\s/g, '');

    const { userKeyShare, backupKeyShare, commonKeyChain } = await ECDSAUtils.getMpcV2RecoveryKeyShares(
      userKey,
      backupKey,
      params.walletPassphrase
    );
    const MPC = new Ecdsa();
    const publicKey = MPC.deriveUnhardened(commonKeyChain, ROOT_PATH).slice(0, 66);

    if (!publicKey || !backupKeyShare) {
      throw new Error('Missing publicKey or backupKeyShare');
    }

    const senderAddress = await this.getAddressFromPublicKey(publicKey);

    const balance = new BigNumber(await this.getAccountBalance(senderAddress));
    const feeData = new BigNumber(utils.feeData());
    const actualBalance = balance.plus(feeData); // gas amount returned from gasData is negative so we add it
    if (actualBalance.isLessThanOrEqualTo(0)) {
      throw new Error('Did not have enough funds to recover');
    }

    const factory = this.getBuilderFactory();
    const txBuilder = factory.getTransferBuilder();
    txBuilder.sender(senderAddress, publicKey as string);
    txBuilder.receiverId(params.recoveryDestination);
    txBuilder.amount(actualBalance.toString());
    if (params.memo !== undefined && utils.validateMemo(params.memo)) {
      txBuilder.memo(Number(params.memo));
    }
    await txBuilder.build();
    if (txBuilder.transaction.payloadsData.payloads.length === 0) {
      throw new Error('Missing payloads to generate signatures');
    }
    const signatures = await this.signatures(
      txBuilder.transaction.payloadsData,
      publicKey,
      userKeyShare,
      backupKeyShare,
      commonKeyChain
    );
    if (!signatures || signatures.length === 0) {
      throw new Error('Failed to generate signatures');
    }
    txBuilder.transaction.addSignature(signatures);
    txBuilder.combine();
    const broadcastableTxn = txBuilder.transaction.toBroadcastFormat();
    const result = await this.broadcastTransaction({ serializedSignedTransaction: broadcastableTxn });
    if (!result.txId) {
      throw new Error('Transaction failed to broadcast');
    }
    return result.txId;
  }
}

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


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