Source

modules/WalletManager.ts

import { Web3Provider } from "@ethersproject/providers";
import { ethers } from "ethers";
import Web3 from "web3";
import { CommonAPI, UserAPI } from "../core/apis";
import {
  RegisteredUserData,
  UserApiInput,
  UserDataResponse,
  UserType,
  UserWalletApi,
} from "../types";
import { EnvTypes } from "../typesBundle";
import { ETH_REQUEST_ACCOUNTS } from "../utils/Constants";
import { SIGN_MESSAGE } from "./CommonModule";
import { APIResponseType } from "../types/APIResponseType";

const StarkwareLib = require("@starkware-industries/starkware-crypto-utils");
const keyDerivation = StarkwareLib.keyDerivation;

const METAMASK_MESSAGE_SIGNATURE = `Welcome to Myria!\n\nSelect 'Sign' to create and sign in to your Myria account.\n\nThis request will not trigger a blockchain transaction or cost any gas fees.\n\n`;

/**
 * Create WalletManager instance object
 * @class WalletManager
 * @param {EnvTypes} env Environment types enum params (Ex: EnvTypes.DEV / EnvTypes.STAGING / EnvTypes.PREPROD / EnvTypes.PROD)
 */
export class WalletManager {
  private rawProvider: any;
  private web3Provider: Web3Provider;
  private userApi: UserAPI;
  private web3: Web3;
  private commonApi: CommonAPI;

  public accounts: string[];

  public currentAccount: string;

  constructor(env: EnvTypes) {
    if (typeof Web3.givenProvider === undefined) {
      throw new Error(
        "Metamask is required to install. Please install the metamask"
      );
    }

    this.userApi = new UserAPI(env);
    this.commonApi = new CommonAPI(env);
    this.rawProvider = Web3.givenProvider;
    this.web3 = new Web3(this.rawProvider);
    this.web3Provider = new ethers.providers.Web3Provider(this.rawProvider);
    this.accounts = [];
    this.currentAccount = "";
  }

  /**
   * @private 
   * @description Allow to generate the signature for registration process with server time
   * @param {string} serverTime milliseconds as string
   * @returns {string} a string as the message for metamask's signing
   */
  private generateSignatureAccount(serverTime: string): string {
    return `${METAMASK_MESSAGE_SIGNATURE}${JSON.stringify({
      created_on: serverTime,
    })}`;
  }

  /**
   * @private
   * @description Perform the register account through account services
   * @param walletAddress 
   * @returns {UserWalletApi} Return user's wallet API response
   */
  private async registerAccount(
    walletAddress: string,
    userType?: string,
    referrerId?: string
  ): Promise<APIResponseType<UserWalletApi>> {
    if (!walletAddress) {
      throw new Error("Wallet address is required");
    }

    if(userType && !referrerId) {
      throw new Error('ReferrerId is required!');
    }

    const serverTime = await this.commonApi.getTimeFromMyriaverse();
    const message = this.generateSignatureAccount(serverTime.data.time);
    const signature = await this.web3.eth.personal.sign(
      message,
      walletAddress,
      ""
    );

    const registerData: UserWalletApi = {
      wallet_id: this.currentAccount,
      signature,
      message,
      userType,
      referrerId
    };

    const registerResponse = await this.commonApi.registerUser(registerData);
    return registerResponse;
  }

  /**
   * @private
   * @description Register user in L2 system
   * @param
   * userType: Partner/Customer
   * referrerID: starkKey/projectID-gameID
   * @returns User data information in L2
   */
  private async registerL2User(userType?: UserType, referrerId?: string): Promise<UserDataResponse | undefined> {

    if (userType && !referrerId) {
      throw new Error('ReferrerId is required!');
    }

    const msgHash = StarkwareLib.pedersen([
      "UserRegistration:",
      this.currentAccount,
    ]);

    const walletSignature = await this.web3.eth.personal.sign(
      SIGN_MESSAGE,
      this.currentAccount,
      ""
    );
    const privateStarkKeyInternal = keyDerivation.getPrivateKeyFromEthSignature(walletSignature);
    const keyPair = StarkwareLib.ec.keyFromPrivate(
      privateStarkKeyInternal,
      "hex"
    );
    const starkKey = keyDerivation.privateToStarkKey(privateStarkKeyInternal);
    const pubKey = StarkwareLib.ec.keyFromPublic(
      keyPair.getPublic(true, "hex"),
      "hex"
    );

    const pureStarkSignature = StarkwareLib.sign(keyPair, msgHash);

    const verify = StarkwareLib.verify(pubKey, msgHash, pureStarkSignature);
    if (!verify) {
      throw new Error(
        "Stark signature generate error - please recheck the data"
      );
    }

    const starkSignature = {
      r: `0x${pureStarkSignature.r.toJSON()}`,
      s: `0x${pureStarkSignature.s.toJSON()}`,
    };

    const payload: UserApiInput = {
      ethAddress: this.currentAccount,
      signature: starkSignature,
      starkKey: `0x${starkKey}`,
      userType,
      referrerId
    };

    const registerResult = await this.userApi.registerUser(payload);
    return registerResult.data;
  }

  /**
   * @description Perform the connection with current connected metamask account with browser's web session
   * @returns {RegisteredUserData} Return the new users (wallet address, details info for wallet) 
   * @throws {string} Exception: Need to select and connect to metamask accounts
   * @example <caption>Sample code</caption> 
   * 
    // Sample code on staging:
    const walletManager = new WalletManager(EnvTypes.STAGING);
    const data = await walletManager.connect();
    console.log('Data ->', data);

    // Sample code on Production:
    const walletManager = new WalletManager(EnvTypes.PRODUCTION);
    const data = await walletManager.connect();
    console.log('Data ->', data);
   */
  public async connect(): Promise<RegisteredUserData> {
    this.accounts = await this.web3Provider.send(ETH_REQUEST_ACCOUNTS, []);
    this.currentAccount = this.accounts[0];

    if (this.accounts.length === 0) {
      throw new Error("Need to select and connect to metamask accounts");
    }
    let codeInfo;
    let checkUserExistResponse;

    try {
      checkUserExistResponse = await this.userApi.getUserByWalletAddress(
        this.currentAccount
      );
      codeInfo = "USER_REGISTERED";
    } catch (err: any) {
      if (err.status === 404) {
        codeInfo = "USER_NOT_REGISTERED";
      } else {
        codeInfo = "GET_USER_INFO_ERROR";
      }
    }

    const result: RegisteredUserData = {
      walletAddress: this.currentAccount,
      starkKey: checkUserExistResponse?.data.starkKey || "",
      codeInfo,
    };

    return result;
  }

  /**
   * @description The function required the wallet, and it performs full registration and normal login to get the user info data
   * @param {string} walletAddress Required metamask wallet address of user
   * @param {UserType=} userType Type of user for B2B (PARTNER) and B2C (CUSTOMER)
   * @param {string=} referrerId Referrer users to onboard to myria system.
   * In case the type is partner, then the referrerID is PARTNER_GAME_NAME_ID or PROJECT_ID 
   * If type is customer, then the referrerID is stark key of referred's user
   * @throws {string} Exception: Wallet address is required!
   * @throws {string} Exception: ReferrerId is required!
   * @throws {string} Exception: Wallet registration failed: ${INTERNAL_SERVER_ERROR}
   * @throws {string} Exception: Can't register the user with error: ${INTERNAL_SERVER_ERROR}
   * @example <caption>Sample code</caption> 
   * 
    // Sample code on staging:
    const walletManager = new WalletManager(EnvTypes.STAGING);
    const data = await walletManager.registerAndLoginWithWalletAddress(
      '0xfb.....',       // wallet address
      UserType.PARTNER,  // User type as partner 
      '110', // Testnet (Staging) Project ID for the partner game
    );
    console.log('Testnet Data ->', data);

    // Sample code on Production:
    const walletManager = new WalletManager(EnvTypes.PRODUCTION);
    const data = await walletManager.registerAndLoginWithWalletAddress(
      '0xfb.....',       // wallet address
      UserType.PARTNER,  // User type as partner 
      '10', // Production Project ID for the partner game
    );
    console.log('Production Data ->', data);

   * @returns {UserDataResponse|undefined} User data response (such as stark key, wallet address, registered signature)
   */
  public async registerAndLoginWithWalletAddress(walletAddress: string, userType?: UserType, referrerId?: string): Promise<UserDataResponse | undefined> {

    if(!walletAddress) {
      throw new Error('Wallet address is required!');
    }

    if(userType && !referrerId) {
      throw new Error('ReferrerId is required!');
    }

    try {
      const loginResult = await this.userApi.getUserByWalletAddress(
        walletAddress
      );
      return loginResult?.data;
    } catch (err: any) {
      if (err.status === 404) {

        const registerRes = await this.registerAccount(this.currentAccount, userType?.toString(), referrerId);
        
        if (registerRes.status === "success") {  
          const registerUserData = await this.registerL2User();
          return registerUserData;
        }
        else {
          throw new Error(`Wallet registration failed: ${registerRes.data}`);
        }
      } else {
        throw new Error(`Can't register the user with error: ${err}`);
      }
    }
  }

  /**
   * @description Perform end to end registration process with metamask connection , connect wallet action and register user in Myria's system
   * @param {UserType=} userType Type of user (PARTNER / CUSTOMER)
   * @param {string=} referrerId Game_ID / Project_ID / References Stark Key of another users if userType is customer
   * @example <caption>Sample code</caption> 
   * 
    // Sample code on staging:
    const walletManager = new WalletManager(EnvTypes.STAGING);
    const registerUserData = await walletManager.connectAndLogin(
      UserType.PARTNER,
      '110', // Testnet (Staging) Project ID for the partner game
    );
    console.log('Testnet Data ->', registerUserData);

    // Sample code on Production:
    const walletManager = new WalletManager(EnvTypes.PRODUCTION);
    const registerUserData = await walletManager.connectAndLogin(
      UserType.PARTNER,
      '10', // Production Project ID for the partner game
    );
    console.log('Production Data ->', registerUserData);

   * @returns {UserDataResponse|undefined} The details user data response for registration progress (including signature, stark key, wallet address)
   * @throws {string} Exception: ReferrerId is required!
   * @throws {string} Exception: Wallet registration failed: ${INTERNAL_SERVER_ERROR}
   * @throws {string} Exception: Register user failed: ${INTERNAL_SERVER_ERROR}
   * @throws {string} Exception: Cannot get user information with error: ${INTERNAL_SERVER_ERROR}
   */
  public async connectAndLogin(userType?: UserType, referrerId?: string): Promise<UserDataResponse | undefined> {
    this.accounts = await this.web3Provider.send(ETH_REQUEST_ACCOUNTS, []);
    this.currentAccount = this.accounts[0];

    if (userType && !referrerId) {
      throw new Error('ReferrerId is required!');
    }

    try {
      const loginResult = await this.userApi.getUserByWalletAddress(
        this.currentAccount
      );
      return loginResult.data;
    } catch (err: any) {
      if (err.status === 404) {
        try {
          const registerRes = await this.registerAccount(this.currentAccount, userType, referrerId);
          if (registerRes.status === "success") {
            const registerResult = await this.registerL2User(userType, referrerId);
            return registerResult;
          } else {
            throw new Error(`Wallet registration failed: ${registerRes.data}`);
          }
        } catch (error: any) {
          throw new Error(`Register user failed: ${error}`);
        }
      } else {
        throw new Error(`Cannot get user information with error: ${err}`);
      }
    }
  }

/**
   * @description Perform the retrieve user wallet by the Stark Key
   * @param {string} starkKey Stark Key of user in L2 system of Myria
   * @example <caption>Sample code</caption> 
   * 
    // Sample code on staging:

    const walletManager = new WalletManager(EnvTypes.STAGING);

    const starkKey = '0x.....';
    const userWalletData = await walletManager.getUserWalletByStarkKey(starkKey);
    console.log('Testnet Data ->', userWalletData);

    // Sample code on Production:
    const walletManager = new WalletManager(EnvTypes.PRODUCTION);

    const starkKey = '0x.....';
    const userWalletData = await walletManager.getUserWalletByStarkKey(starkKey);
    console.log('Production Data ->', userWalletData);

   * @returns {UserDataResponse | undefined} The details user data response for registration progress (including signature, stark key, wallet address)
   * @throws {string} Exception: Stark Key is required!
   * @throws {string} Http Status Code 404: User 0x... is not registered
   * @throws {string} Http Status Code 500: Get user data failed - unexpected with internal server error
   * @throws {string} Http Status Code 500: Internal Server Error with ${Exception}
   */
  public async getUserWalletByStarkKey(starkKey: string): Promise<UserDataResponse | undefined> {
    if (!starkKey) {
      throw new Error("Stark Key is required")
    }

    let res: UserDataResponse;

    try {
      const registerUserResponse = await this.userApi.getUserByWalletAddress(starkKey);
      if (registerUserResponse?.status === 'success' && registerUserResponse?.data) {
        res = registerUserResponse?.data;
      } else {
        throw new Error('Get user data failed - unexpected with internal server error')
      }
    } catch (err: any) {
      throw new Error('Internal Server Error with ' + err);
    }
    return res;
  }

  /**
   * @description Perform the retrieve full user information by the Wallet address
   * @param {string} ethAddress The ether wallet address of user (such as Metamask wallet address)
   * @example <caption>Sample code</caption> 
   * 
    // Sample code on staging:

    const walletManager = new WalletManager(EnvTypes.STAGING);

    const ethWalletAddress = '0x.....';
    const userWalletData = await walletManager.getUserInfoByWalletAddress(ethWalletAddress);
    console.log('Testnet Data ->', userWalletData);

    // Sample code on Production:
    const walletManager = new WalletManager(EnvTypes.PRODUCTION);

    const ethWalletAddress = '0x.....';
    const userWalletData = await walletManager.getUserInfoByWalletAddress(ethWalletAddress);
    console.log('Production Data ->', userWalletData);

   * @returns {UserDataResponse | undefined} The details user data response for registration progress (including signature, stark key, wallet address)
   * @throws {string} Exception: Eth address is required!
   * @throws {string} Http Status Code 404: User 0x... is not registered
   * @throws {string} Http Status Code 500: Get user data failed - unexpected with internal server error
   * @throws {string} Http Status Code 500: Internal Server Error with ${Exception}
   */
  public async getUserInfoByWalletAddress(ethAddress: string): Promise<UserDataResponse | undefined> {

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

    let res: UserDataResponse;

    try {
      const registerUserResponse = await this.userApi.getUserByWalletAddress(ethAddress);
      if (registerUserResponse?.status === 'success' && registerUserResponse?.data) {
        res = registerUserResponse?.data;
      } else {
        throw new Error('Get user data failed - unexpected with internal server error')
      }
    } catch (err: any) {
      throw new Error('Internal Server Error with ' + err);
    }
    return res;
  }

}