Source

modules/WithdrawalModule.ts

import BN from "bn.js";
import { IMyriaClient, MyriaClient, WithdrawalContract } from "..";
import {
  AssetAPI,
  CommonAPI,
  WithdrawalAPI,
  WithdrawalMarketpAPI,
} from "../core/apis";
import { ContractFactory } from "../core/ContractFactory";
import { stripHexPrefix } from "../core/helpers";
import { AssetDictParams, CreateVaultERC721ByEthKeyParams, GenericERC20Network, TransactionData, TxResult } from "../types";
import { SendOptions, TokenType } from "../types/CommonTypes";
import {
  WithdrawAndMintParams,
  WithdrawERC721Params,
  WithdrawNftCompleteParams,
  WithdrawNftCompleteResponse,
  WithdrawNftOffChainParams,
  WithdrawNftOffChainResponse,
  WithdrawOffchainParams,
  WithdrawOffchainParamsV2,
  WithdrawOnchainParams,
  WithdrawOffchainPayloadV2,
  WithdrawNftOffChainRequestAPI,
  FullWithdrawalInput,
  FullWithdrawalPayload,
  WithdrawNftCompleteRequestAPI,
  WithdrawOffchainParamsV2RequestAPI,
  WithdrawOffchainParamsV2Hash,
  UserWithdrawalHashType,
} from "../types/WithdrawType";
import { DEFAULT_QUANTUM } from "../utils/Constants";
import { DELAY_IN_RETRY, retryPromise, RETRY_DEFAULT } from "../utils/RetryUtils";
import { APIResponseType } from "./../types/APIResponseType";
import { CommonModule } from "./CommonModule";

/**
 * Create WithdrawalModule instance object
 * @class WithdrawalModule
 * @param {IMyriaClient} IMyriaClient Interface of Myria Client
 */
export class WithdrawalModule {
  private withdrawalContract: WithdrawalContract;
  public client: MyriaClient;
  private withdrawalAPI: WithdrawalAPI;
  private withdrawalMarketpAPI: WithdrawalMarketpAPI;
  private commonAPI: CommonAPI;
  private assetAPI: AssetAPI;
  private commonModule: CommonModule;

  constructor(client: IMyriaClient) {
    this.client = new MyriaClient(client);
    this.withdrawalContract = this.getWithdrawalContract(this.client);
    this.withdrawalAPI = new WithdrawalAPI(this.client.env);
    this.withdrawalMarketpAPI = new WithdrawalMarketpAPI(this.client.env);
    this.commonAPI = new CommonAPI(this.client.env);
    this.assetAPI = new AssetAPI(this.client.env);
    this.commonModule = CommonModule.getInstance(client);
  }

  public getWithdrawalContract(client: MyriaClient): WithdrawalContract {
    const contractFactory = new ContractFactory(client);
    return contractFactory.getWithdrawContract();
  }

  public getCustomWithdrawContract(customERC20Network: GenericERC20Network): WithdrawalContract {
    const contractFactory = new ContractFactory(this.client);
    return contractFactory.getCustomWithdrawContract(customERC20Network);
  }


  public async withdrawalOffchain(
    withdrawalParams: WithdrawOffchainParams,
  ): Promise<any> {

    if (!withdrawalParams.ethAddress) {
      throw new Error("Eth address is required");
    }

    if (!withdrawalParams.amount) {
      throw new Error("Amount is required.");
    }


    if (!withdrawalParams.starkKey) {
      throw new Error("StarkKey is required.");
    }

    if (!withdrawalParams.quantum) {
      withdrawalParams.quantum = DEFAULT_QUANTUM; // default quantum is 10^10
    }

    // Get vault ID
    let vaultData;
    const quantizedAmount = (new BN(withdrawalParams.amount)).div(new BN(withdrawalParams.quantum)).toString();
    let nonce = withdrawalParams.nonce;
    if (!nonce) {
      nonce = await this.client.web3.eth.getTransactionCount(withdrawalParams.ethAddress);
    }

    console.log("nonce is ", nonce);

    if (!withdrawalParams?.vaultId) {
      try {
        const vaultResponse = await this.commonAPI.createVault({
          starkKey: withdrawalParams.starkKey,
          tokenType: withdrawalParams?.tokenType,
          tokenAddress: withdrawalParams?.tokenAddress || undefined,
          quantum: withdrawalParams.quantum, // TODO might update the quantum later to respect with the input
        });
        if (vaultResponse?.status === "success") {
          vaultData = String(vaultResponse.data.vaultId);
        } else {
          throw new Error(
            "Fetching vaultID failure - check BE server or validation request for calling"
          );
        }
      } catch (ex: any) {
        throw new Error(ex);
      }
    } else {
      vaultData = String(withdrawalParams.vaultId);
    }

    const assetDict: AssetDictParams = {
      type: withdrawalParams.tokenType,
      data: {
        quantum: withdrawalParams.quantum,
        tokenAddress: withdrawalParams.tokenAddress
      }
    }

    const assetId = this.commonModule.generateAssetId(assetDict);

    const withdrawalHashPayload: UserWithdrawalHashType = {
      vaultId: vaultData,
      assetId: stripHexPrefix(assetId),
      quantizedAmount: quantizedAmount,
      nonce: nonce,
      ethAddress: withdrawalParams.ethAddress
    };

    console.log(
      "[Core-SDK] withdrawal hash payload ->",
      withdrawalHashPayload
    );

    const signature = await this.commonModule.generateSignatureForWithdrawal(
      withdrawalHashPayload,
    );

    if (!signature) {
      throw new Error("Stark signature generation error ");
    }

    // Make withdraw offchain request
    let withdrawOffchainResult;
    try {
      if (
        withdrawalParams?.tokenType === TokenType.MINTABLE_ERC721 ||
        withdrawalParams?.tokenType === TokenType.ERC721
      ) {
        withdrawOffchainResult = await this.withdrawalAPI.makeWithdrawalTransaction(
          vaultData,
          withdrawalParams?.starkKey?.toLowerCase(),
          withdrawalParams?.amount,
          assetId,
          signature,
          nonce
        );
      } else {
        withdrawOffchainResult = await this.withdrawalAPI.makeWithdrawalTransaction(
          vaultData,
          withdrawalParams?.starkKey?.toLowerCase(),
          quantizedAmount,
          assetId,
          signature,
          nonce
        );
      }

      console.log(
        `Withdrawal offchain response -> ${JSON.stringify(
          withdrawOffchainResult
        )}`
      );
    } catch (ex) {
      console.log(`Exception for withdraw offchain -> ${JSON.stringify(ex)}`);
      throw new Error(
        `Withdrawal failure on server with exception -> ${JSON.stringify(ex)}`
      );
    }

    return withdrawOffchainResult;
  }

  /**
   * @description The withdraw offchain function in V2
   * @param {WithdrawOffchainParamsV2} withdrawalParams Withdraw off-chain params input
   * @throws {string} Exception: Sender starkKey is required!
   * @throws {string} Exception: Receiver eth address is required!
   * @throws {string} Exception: Quantum is required!
   * @throws {string} Exception: Amount is required!
   * @throws {string} Exception: Token Type is required.
   * @throws {string} Exception: Token Address is required!
   * @throws {string} Exception: Token ID is required!
   * @returns {TransactionData} Transactions data which indicate the transaction results, transaction details information
   * (such as transactionID, transactionStatus...)
   * @example <caption>Sample code on Testnet (Staging) env</caption>
    const mClient: IMyriaClient = {
      networkId: Network.GOERLI,
      provider: web3Instance.currentProvider,
      web3: web3Instance,
      env: EnvTypes.STAGING,
    };

    const senderStarkKey = '0xfb....'; // Sample of sender stark public key
    const senderEthAddress = '0x....'; // Sender wallet address
    const QUANTUM_CONSTANT = 10000000000; // Quantum 10^10
    const weiAmount = 1000000000000000000; // 1 ETH we can use this page to convert the ETH to wei: https://eth-converter.com/

    const withdrawParamsV2: WithdrawOffchainParamsV2 = {
          senderPublicKey: senderStarkKey,
          senderEthAddress: senderEthAddress,
          receiverPublicKey: senderEthAddress,
          amount: String(weiAmount),
          tokenType: TokenType.ETH, // tokenType is ETH if users want to withdraw ETH
          quantum: QUANTUM_CONSTANT.toString(),
        };
    responseWithdraw = await withdrawModule.withdrawalOffchainV2(
      withdrawParamsV2,
    );

    console.log('Transaction result -> ', result);
   */
  public async withdrawalOffchainV2(
    withdrawalParams: WithdrawOffchainParamsV2
  ): Promise<TransactionData | undefined> {
    if (!withdrawalParams.senderPublicKey) {
      throw new Error("Sender starkKey is required!");
    }

    if (!withdrawalParams.receiverPublicKey) {
      throw new Error("Receiver eth address is required!");
    }

    if (!withdrawalParams.quantum) {
      throw new Error("Quantum is required!");
    }

    if(!withdrawalParams.amount) {
      throw new Error("Amount is required!");
    }

    if(!withdrawalParams.tokenType) {
      throw new Error("Token Type is required.");
    }

    if(withdrawalParams.tokenType === TokenType.ERC20) {
      if(!withdrawalParams.tokenAddress) {
        throw new Error("Token Address is required!");
      }
    }

    if(withdrawalParams.tokenType === TokenType.ERC721) {
      if(!withdrawalParams.tokenAddress) {
        throw new Error("Token Address is required!");
      }
      if(!withdrawalParams.tokenId) {
        throw new Error("Token ID is required!");
      }
    }

    let withdrawalOffchainResult;

    try {
      const senderVault = await this.commonAPI.createVault({
        quantum: withdrawalParams.quantum,
        starkKey: withdrawalParams.senderPublicKey,
        tokenType: withdrawalParams.tokenType,
        tokenAddress: withdrawalParams.tokenAddress
      });
      if (!senderVault || senderVault.status !== "success") {
        throw new Error(
          `Failed to get vault for sender ${withdrawalParams.senderPublicKey}, please retry`
        );
      }

      const receiverVault = await this.commonAPI.createVault(
        {
          quantum: withdrawalParams.quantum,
          starkKey: withdrawalParams.receiverPublicKey,
          tokenType: withdrawalParams.tokenType,
          tokenAddress: withdrawalParams.tokenAddress
        }
      );
      if (!receiverVault || receiverVault.status !== "success") {
        throw new Error(
          `Failed to get vault for receiver ${withdrawalParams.receiverPublicKey}, please retry`
        );
      }

      const nonceByStarkKey = await this.commonAPI.getNonceByStarkKey(
        withdrawalParams.senderPublicKey
      );

      // Get nonce from our BE server
      const nonce = nonceByStarkKey?.data || Math.floor(Math.random() * 10000000) + 1;

      // SET EXPIRATION TO BE EXPIRE IN 12 YEARS
      const expirationTimestamp = new Date();
      expirationTimestamp.setFullYear(expirationTimestamp.getFullYear() + 12);
      const expirationTime = Math.floor(
        expirationTimestamp.getTime() / (3600 * 1000)
      );
      const assetDict: AssetDictParams = {
        type: withdrawalParams.tokenType,
        data: {
          quantum: withdrawalParams.quantum,
          tokenAddress: withdrawalParams.tokenAddress
        }
      }
  
      const assetId = this.commonModule.generateAssetId(assetDict);
      const quantizedAmount = (new BN(withdrawalParams.amount, 10)).div(new BN(withdrawalParams.quantum, 10)).toString();
      const msgBody: WithdrawOffchainParamsV2RequestAPI = {
        senderVaultId: senderVault.data?.vaultId,
        senderPublicKey: withdrawalParams.senderPublicKey,
        receiverVaultId: receiverVault?.data?.vaultId,
        receiverPublicKey: withdrawalParams.receiverPublicKey,
        nonce: nonce,
        expirationTimestamp: expirationTime,
        quantizedAmount: quantizedAmount,
        token: assetId,
        senderEthAddress: withdrawalParams.senderEthAddress,
      };

      const starkSignature = await this.commonModule.generateStarkSignatureForWithdrawal(msgBody);

      if (!starkSignature) {
        throw new Error("Error on signing!");
      }

      const requestPayload: WithdrawOffchainPayloadV2 = {
        senderVaultId: senderVault.data?.vaultId,
        senderPublicKey: withdrawalParams.senderPublicKey,
        receiverVaultId: receiverVault?.data?.vaultId,
        receiverPublicKey: withdrawalParams.receiverPublicKey,
        nonce: nonce,
        expirationTimestamp: expirationTime,
        signature: starkSignature,
        quantizedAmount: quantizedAmount,
        token: assetId,
      };
      const response = await this.withdrawalAPI.makeWithdrawalTransactionV2(
        requestPayload
      );

      if (response?.status === "success") {
        withdrawalOffchainResult = response?.data;
      } else {
        throw new Error("Withdrawal failed!");
      }
    } catch (err) {
      console.log("Error -> ", err);
    }
    return withdrawalOffchainResult;
  }

  /**
   * @description Function to check if user has registered on-chain with Starkware dedicated instance
   * @param {string} starkKey Public stark key of user 
   * @returns {TxResult | undefined} On-chain transactions results information (block confirmed, transaction hash,....)
   */
  public async checkUserRegisterOnchain(starkKey: string): Promise<TxResult | undefined> {

    if (!starkKey) {
      throw new Error('Stark key is required');
    }

    let txResult;
    try {
      txResult = await this.withdrawalContract.getEthKey(starkKey);
    } catch (ex) {
      console.log('[SDK-Exception] Error -> ', ex);
    }
    return txResult;
  }

  /**
   * @description The withdraw on-chain function to withdraw the Tokens available in the On-chain to User's Wallet
   * @typedef {Object} WithdrawOnchainParams The payload for requesting withdraw on-chain actions (assetType and starkKey)
   * @param {WithdrawOnchainParams} withdrawalParams The withdraw on-chain params where it include both of the StarkKey and AssetType 
   * @param {SendOptions} options The native options for withdraw on-chain options 
   * @returns {Promise<TxResult>} The promise with transaction results 
   */
  public async withdrawalOnchain(
    withdrawalParams: WithdrawOnchainParams,
    options?: SendOptions
  ): Promise<TxResult> {
    let txResult;
    try {
      txResult = await this.withdrawalContract.withdrawal(
        withdrawalParams?.starkKey,
        withdrawalParams?.assetType,
        options
      );
      console.log(`Withdrawal onchain response -> ${JSON.stringify(txResult)}`);
    } catch (ex) {
      console.log(
        `Exception for withdraw onchain -> ${JSON.stringify(txResult)}`
      );
      throw new Error(
        `Withdraw onchain failure with error ${JSON.stringify(ex)}`
      );
    }

    return txResult;
  }

  public async fullWithdrawal(payload: FullWithdrawalInput): Promise<any> {
    if (!payload.ethAddress) {
      throw new Error("User address is required!");
    }

    if (!payload.starkKey) {
      throw new Error("User starkKey is required!");
    }

    if (!payload.vaultId) {
      throw new Error("VaultId is required!");
    }

    if (!payload.nonce) {
      throw new Error("Nonce is required!");
    }

    let fullWithdrawalResult;

    try {
      const starkSignature = await this.commonModule.generateStarkSignatureForFullWithdrawal(
        payload.vaultId,
        payload.nonce,
        payload.ethAddress
      );

      if (!starkSignature) {
        throw new Error("Sigining current request failed!");
      } else {
        const requestPayload: FullWithdrawalPayload = {
          vaultId: payload.vaultId,
          nonce: payload.nonce,
          starkKey: payload.starkKey,
          signature: starkSignature
        }

        const fullWithdrawalResponse = await this.withdrawalAPI.fullWithdrawal(requestPayload);

        if (fullWithdrawalResponse?.status === "success") {
          fullWithdrawalResult = fullWithdrawalResponse?.data;
        } else {
          throw new Error("Full withdrawal failed!");
        }
      }
    } catch (err) {
      throw new Error("Full withdrawal failed!");
    }

    return fullWithdrawalResult;
  }

  public async withdrawalNFT(
    withdrawalParams: WithdrawERC721Params,
    options?: SendOptions
  ): Promise<TxResult> {
    let txResult;

    try {
      txResult = await this.withdrawalContract.withdrawalNft(
        withdrawalParams?.ownerKey,
        withdrawalParams?.assetType,
        withdrawalParams?.tokenId,
        options
      );
      console.log(`Withdraw nft onchain response -> ${txResult}`);
    } catch (ex) {
      console.log(
        `Exception for withdraw nft onchain -> ${JSON.stringify(txResult)}`
      );
      throw new Error(
        `Withdraw nft onchain failure with error ${JSON.stringify(ex)}`
      );
    }

    return txResult;
  }

  /**
   * @description Withdraw and mint the assets NFTs (ERC_721) in the on-chain and send the NFTs/tokens to user
   * @param {WithdrawAndMintParams} withdrawalParams Withdraw and mint params options
   * @param {SendOptions?} options The native options for transaction in Web3
   * @returns {Promise<TxResult>} Transaction results in the on-chain after withdraw and mint action
   */
  public async withdrawAndMint(
    withdrawalParams: WithdrawAndMintParams,
    options?: SendOptions
  ): Promise<TxResult> {
    let txResult;
    try {
      txResult = await this.withdrawalContract.withdrawAndMint(
        withdrawalParams.walletAddress,
        withdrawalParams.assetType,
        withdrawalParams?.mintingBlob,
        options
      );
      console.log(`Withdrawal onchain response -> ${JSON.stringify(txResult)}`);
    } catch (ex) {
      console.log(
        `Exception for withdraw onchain -> ${JSON.stringify(txResult)}`
      );
      throw new Error(
        `Withdraw onchain failure with error ${JSON.stringify(ex)}`
      );
    }

    return txResult;
  }

  /**
   * @description The function is to get the available fund for user to be withdraw in the L1 and it requests to StarkEx smart contract
   * @param {string} ownerKey The owner key of users (Wallet Address / Stark Key) where it locates the fund in the on-chain
   * @param {string} assetId The asset ID (hex string) to be represent for the tokens that we'd to check with the available balance for withdraw
   * @returns {Promise<TxResult>} The transaction result data which is on-chain data object
   */
  public async getWithdrawalBalance(
    ownerKey: string,
    assetId: string,
    options?: SendOptions
  ): Promise<TxResult> {
    return this.withdrawalContract.getWithdrawalBalance(
      ownerKey,
      assetId,
      options
    );
  }

  /**
   * @description The withdraw nft off-chain actions to bring the NFTs to user's wallet (Metamask/Trust Wallet)
   * @param {WithdrawNftOffChainParams} payload Withdraw NFTs Off-chain params 
   * @returns {APIResponseType<WithdrawNftOffChainResponse> | undefined} The withdraw NFT off-chain response including transaction details data
   */
  public async withdrawNftOffChain(
    payload: WithdrawNftOffChainParams
  ): Promise<APIResponseType<WithdrawNftOffChainResponse> | undefined> {
    let result: any;
    if (!payload.id) {
      throw new Error("Id is required");
    }
    if (!payload.tokenId) {
      throw new Error("TokenId is required");
    }
    if (!payload.tokenAddress) {
      throw new Error("Token address is required");
    }
    if (!payload.senderPublicKey) {
      throw new Error("StarkKey is required");
    }
    if (!payload.receiverPublicKey) {
      throw new Error("Receiver public key is required");
    }
    if (!payload.assetId) {
      throw new Error("AssetId is required");
    }
    if (!Number.isInteger(Number(payload.quantizedAmount))) {
      throw new Error("QuantizedAmount the required");
    }
    if (!Number.isInteger(Number(payload.senderVaultId))) {
      throw new Error("Missing the vaultId");
    }

    const vaultForERC721: CreateVaultERC721ByEthKeyParams = {
      tokenId: payload.tokenId,
      depositEthAddress: payload.receiverPublicKey,
      tokenAddress: payload.tokenAddress,
      starkKey: payload.receiverPublicKey,
    };

    const assetVaultsByEthKey = await this.assetAPI.createERC721VaultByEthAddress(vaultForERC721);
    const receiverVaultId = assetVaultsByEthKey?.data?.vaultId;

    const nonceByStarkKey = await this.commonAPI.getNonceByStarkKey(
      payload.senderPublicKey
    );
    const nonceData = nonceByStarkKey?.data || Math.floor(Math.random() * 100000000) + 1;
    // const nonceData = Math.floor(Math.random() * 80000000);

    // SET EXPIRATION TO BE EXPIRE IN 12 YEARS
    const expirationTimestamp = new Date();
    expirationTimestamp.setDate(expirationTimestamp.getFullYear() + 12);
    const expirationTime = Math.floor(
      expirationTimestamp.getTime() / (3600 * 1000)
    );

    // Generate signature
    const msgBody: WithdrawOffchainParamsV2Hash = {
      senderVaultId: payload.senderVaultId,
      senderPublicKey: payload.senderPublicKey,
      receiverVaultId: receiverVaultId,
      receiverPublicKey: payload.receiverPublicKey,
      nonce: nonceData,
      expirationTimestamp: expirationTime,
      quantizedAmount: payload.quantizedAmount,
      token: payload.assetId,
      senderEthAddress: payload.receiverPublicKey,
    };

    const starkSignature = await this.commonModule.generateStarkSignatureForWithdrawal(msgBody);

    if (!starkSignature) {
      throw new Error("Error on signing!");
    }

    const requestPayload: WithdrawNftOffChainRequestAPI = {
      id: payload.id,
      senderVaultId: payload.senderVaultId,
      senderPublicKey: payload.senderPublicKey,
      receiverVaultId: receiverVaultId,
      receiverPublicKey: payload.receiverPublicKey,
      token: payload.assetId,
      quantizedAmount: payload.quantizedAmount,
      nonce: nonceData,
      expirationTimestamp: expirationTime,
      signature: starkSignature,
    };

    try {
      const withdrawRes = await this.withdrawalMarketpAPI.requestWithdrawNftOffChain(
        requestPayload
      );
      if (withdrawRes?.status === "success") {
        result = withdrawRes?.data;
      } else {
        throw new Error("Withdraw nft onchain failure");
      }
    } catch (error) {
      throw new Error(
        `Withdraw nft onchain failure with error ${JSON.stringify(error)}`
      );
    }
    return result;
  }

  /**
   * @description As long as the NFTs has been completed with the on-chain withdraw and user receive the NFTs/tokens into their wallet (Metamask,etc...)
   * This is one last step for tracking and notify to Myria service that this NFTs has been completed on withdraw process and
   * the token is no longer existed in Myria system 
   * @param {WithdrawNftCompleteParams} payload The payload for requesting the NFTs withdraw completed in Myria system
   * @returns {APIResponseType<WithdrawNftCompleteResponse> | undefined} The withdraw nft completion response from Myria services
   */
  public async withdrawNftComplete(
    payload: WithdrawNftCompleteParams
  ): Promise<APIResponseType<WithdrawNftCompleteResponse> | undefined> {
    let result: any;

    if (!payload.assetId) {
      throw new Error("Asset id is required");
    }

    if (!payload.starkKey) {
      throw new Error("StarkKey is required");
    }

    if (!payload.transactionHash) {
      throw new Error("Transaction hash is required");
    }
    try {
      const assetVaultDetails = await this.assetAPI.getAssetVaultDetails({
        starkKey: payload.starkKey,
        assetId: payload.assetId,
      });

      if (assetVaultDetails?.data && assetVaultDetails?.status === "success") {
        const payloadWithDrawComplete: WithdrawNftCompleteRequestAPI = {
          vaultId: assetVaultDetails.data?.vaultId,
          starkKey: payload.starkKey,
          assetId: assetVaultDetails.data?.assetId,
          transactionHash: payload.transactionHash
        };
        const withdrawCompleteRes = await retryPromise(this.withdrawalMarketpAPI.requestWithdrawNftComplete(payloadWithDrawComplete), RETRY_DEFAULT, DELAY_IN_RETRY);
        if (withdrawCompleteRes?.status === "success") {
          result = withdrawCompleteRes?.data;
        } else {
          throw new Error("Withdraw Complete failure");
        }
      }
    } catch (error) {
      throw new Error(
        `WithdrawNft Complete failure with error ${JSON.stringify(error)}`
      );
    }
    return result;
  }
}