Source

main.ts

import {
  AkashicChain,
  checkForAkashicChainError,
  L2Regex,
} from './akashicChain';
import type {
  ActiveLedgerResponse,
  IGetByOwnerAndIdentifierResposne,
  IKeyCreationResponse,
  IOwnerDetailsResponse,
  IOwnerTransactionsResponse,
  IPrepareL1TxnResponse,
  ITransactionDetailsResponse,
  PrepareTxnDto,
} from './APACTypes';
import { FeeDelegationStrategy } from './APACTypes';
import {
  ACDevNodes,
  ACNodes,
  AkashicBaseUrl,
  AkashicBaseUrlDev,
  AkashicEndpoints,
  AkashicError,
  Environment,
} from './constants';
import { FetchHttpClient } from './FetchHttpClient';
import { NetworkDictionary } from './l1Network';
import type { ILogger } from './logger';
import { ConsoleLogger } from './logger';
import { generateOTK } from './otk-reconstruction/generate-otk';
import {
  restoreOtkFromKeypair,
  restoreOtkFromPhrase,
} from './otk-reconstruction/reconstruct';
import {
  type ACNodeT,
  type AkashicUrlT,
  type APBuilderArgs,
  type APConstructorArgs,
  type APInitialisationArgs,
  type HTTPResponse,
  type IBalance,
  type IDepositAddress,
  type IGetByOwnerAndIdentifier,
  type IGetTransactions,
  type IHttpClient,
  type ILookForL2AddressResult,
  type IPayoutResponse,
  type ITransaction,
  type KeyBackup,
  type NetworkSymbol,
  type Otk,
  TokenSymbol,
  TronSymbol,
} from './types';
import { convertToSmallestUnit } from './utils/currency';
import { encodeURIObject } from './utils/http';
import { prefixWithAS } from './utils/prefix';

/**
 * Class AkashicPay
 */
export class AkashicPay<Env extends Environment = Environment.Production> {
  static ACPrivateKeyRegex = /^0x[a-f\d]{64}$/;
  #otk: Otk;
  protected targetNode: ACNodeT<Env>;
  protected akashicUrl: AkashicUrlT<Env>;
  protected logger: ILogger;
  protected env: Env;
  protected httpClient: IHttpClient;
  protected akashicChain: AkashicChain;

  /**
   * Construct and initialise a new {@link AkashicPay} instance.
   *
   * @param {APBuilderArgs<Environment>} args Differ depending on the environment property
   * (i.e. {@link APBuilderArgs.environment}). In production, you must provide the
   * L2-address of an Akashic-Link account, and either the corresponding
   * private-key or recovery-phrase. In development, these arguments are optional;
   * if not provided, a new OTK will be generated during initialisation. It can
   * be retrieved with `.keyBackup` so that you may save it and supply it next time.
   * @constructor
   * @returns {Promise<AkashicPay<Environment>>}
   */
  static async build<Env extends Environment = Environment.Production>(
    args: APBuilderArgs<Env>
  ): Promise<AkashicPay<Env>> {
    const { logger, environment, targetNode, ...initialisationArgs } = args;
    const ap = new AkashicPay<Env>({
      logger,
      environment,
      targetNode,
    });
    await ap.init(initialisationArgs as APInitialisationArgs<Env>);
    return ap;
  }

  /**
   * Get the OTK (One-Time-Key) object for this instance.
   * @returns {KeyBackup} if the environment is development. This enables you to
   * easily create an OTK and re-use it in future tests or dev work.
   * @throws {Error} if the environment is production.
   * @type {KeyBackup}
   */
  get keyBackup(): Env extends Environment.Production ? never : KeyBackup {
    if (this.env === Environment.Production)
      throw new Error(AkashicError.AccessDenied);

    // @ts-ignore
    return {
      l2Address: this.#otk.identity,
      privateKey: this.#otk.key.prv.pkcs8pem,
      raw: this.#otk,
    };
  }

  /**
   * Send a crypto-transaction
   * @param {string} recipientId userID or similar identifier of the user
   *  requesting the payout
   * @param {string} to L1 or L2 address of receiver
   * @param {string} amount Amount to send
   * @param {NetworkSymbol} network L1-Network the funds belong to, e.g. `ETH`
   * @param {TokenSymbol} [token] Optional. Include if sending token, e.g. `USDT`
   * @returns {Promise<IPayoutResponse>} L2 Transaction hash of the transaction
   */
  async payout(
    recipientId: string,
    to: string,
    amount: string,
    network: NetworkSymbol,
    token?: TokenSymbol
  ): Promise<IPayoutResponse> {
    let toAddress = to;
    let initiatedToNonL2: string | undefined = undefined;
    let isL2 = false;
    // map TokenSymbol.TETHER to TokenSymbol.USDT
    if (network === TronSymbol.Tron_Shasta && token === TokenSymbol.USDT) {
      token = TokenSymbol.TETHER;
    }
    // convert to backend currency
    const decimalAmount = convertToSmallestUnit(amount, network, token);

    const { l2Address } = await this.lookForL2Address(to, network);
    if (to.match(NetworkDictionary[network].regex.address)) {
      if (l2Address) {
        // Sending L2 by L1 address
        toAddress = l2Address;
        initiatedToNonL2 = to;
        isL2 = true;
      } else {
        // Sending L1 by L1 address
        // no need to set things here as it is default
      }
    } else if (to.match(L2Regex)) {
      if (!l2Address) {
        throw new Error(AkashicError.L2AddressNotFound);
      }
      // Sending L2 by L2 address
      isL2 = true;
    } else {
      if (!l2Address) {
        throw new Error(AkashicError.L2AddressNotFound);
      }
      // Sending L2 by alias
      toAddress = l2Address;
      initiatedToNonL2 = to;
      isL2 = true;
    }

    if (isL2) {
      const l2Tx = await this.akashicChain.l2Transaction({
        otk: this.#otk,
        amount: decimalAmount,
        toAddress,
        coinSymbol: network,
        tokenSymbol: token,
        initiatedToNonL2,
        identifier: recipientId,
      });

      const acResponse = await this.post<ActiveLedgerResponse>(
        this.targetNode.node,
        l2Tx
      );

      checkForAkashicChainError(acResponse.data);

      this.logger.info(
        'Paid out %d %s to user %s at $s',
        amount,
        token,
        recipientId,
        to
      );

      return {
        l2Hash: prefixWithAS(acResponse.data.$umid),
      };
    } else {
      const payload: PrepareTxnDto = {
        toAddress: to,
        coinSymbol: network,
        amount,
        tokenSymbol: token,
        identity: this.#otk.identity,
        identifier: recipientId,
        feeDelegationStrategy: FeeDelegationStrategy.Delegate,
      };

      const { preparedTxn } = (
        await this.post<IPrepareL1TxnResponse>(
          this.akashicUrl + AkashicEndpoints.PrepareTx,
          payload
        )
      ).data;

      const signedTxn = await this.akashicChain.signPayoutTransaction(
        preparedTxn,
        this.#otk
      );

      const acResponse = await this.post<ActiveLedgerResponse>(
        this.targetNode.node,
        signedTxn
      );

      checkForAkashicChainError(acResponse.data);

      this.logger.info(
        'Paid out %d %s to user %s at $s',
        amount,
        token,
        recipientId,
        to
      );

      return {
        l2Hash: prefixWithAS(acResponse.data.$umid),
      };
    }
  }

  /**
   * Get an L1-address on the specified network for a user to deposit into
   * @param {NetworkSymbol} network L1-network
   * @param {string} identifier userID or similar identifier of the user
   *  making the deposit
   * @returns {Promise<IDepositAddress>}
   */
  async getDepositAddress(
    network: NetworkSymbol,
    identifier: string
  ): Promise<IDepositAddress> {
    const { address } = await this.getByOwnerAndIdentifier({
      identifier,
      coinSymbol: network,
    });

    if (address) {
      return {
        address,
        identifier,
      };
    }

    const tx = await this.akashicChain.keyCreateTransaction(network, this.#otk);
    const response = await this.post<
      ActiveLedgerResponse<IKeyCreationResponse>
    >(this.targetNode.node, tx);

    const newKey = response.data.$responses?.[0];
    if (!newKey) {
      this.logger.warn(
        'Key creation on %s failed for identifier %s. Responses: %o',
        network,
        identifier,
        response.data.$responses
      );
      throw new Error(AkashicError.KeyCreationFailure);
    }

    // Run differential consensus checks
    const txBody = await this.akashicChain.differentialConsensusTransaction(
      this.#otk,
      newKey,
      identifier
    );
    const diffResponse = (
      await this.post<ActiveLedgerResponse>(this.targetNode.node, txBody)
    ).data;

    // Check for confirmation of consensus call
    if (diffResponse.$responses && diffResponse.$responses[0] !== 'confirmed') {
      this.logger.warn(
        'Key creation on %s failed at differential consensus for identifier %s. Unhealthy key: %o',
        network,
        identifier,
        newKey
      );
      throw new Error(AkashicError.UnHealthyKey);
    }

    return {
      address: newKey.address,
      identifier,
    };
  }

  verifyDepositAddress(): unknown {
    throw new Error('Unimplemented');
  }

  /**
   * Check which L2-address an alias or L1-address belongs to. Or call with an
   * L2-address to verify it exists
   * @param {string} aliasOrL1OrL2Address
   * @param {NetworkSymbol} network
   * @returns {Promise<ILookForL2AddressResult>}
   */
  async lookForL2Address(
    aliasOrL1OrL2Address: string,
    network?: NetworkSymbol
  ): Promise<ILookForL2AddressResult> {
    let url = `${this.akashicUrl}${AkashicEndpoints.L2Lookup}?to=${aliasOrL1OrL2Address}`;
    if (network) {
      url += `&coinSymbol=${network}`;
    }
    return (await this.get<ILookForL2AddressResult>(url)).data;
  }

  /**
   * Get all or a subset of transactions. Optionally paginated with `page` and `limit`.
   * Optional parameters: `layer`, `status`, `startDate`, `endDate`, `hideSmallTransactions`.
   * `hideSmallTransactions` excludes values below 1 USD
   * @returns {Promise<ITransaction[]>}
   */
  async getTransfers(
    getTransactionParams: IGetTransactions
  ): Promise<ITransaction[]> {
    const queryParameters = {
      ...getTransactionParams,
      identity: this.#otk.identity,
      withSigningErrors: true,
    };
    const query = encodeURIObject(queryParameters);
    return (
      await this.get<IOwnerTransactionsResponse>(
        `${this.akashicUrl}${AkashicEndpoints.OwnerTransaction}?${query}`
      )
    ).data.transactions // map TokenSymbol.TETHER to TokenSymbol.USDT
      .map((t) => ({
        ...t,
        tokenSymbol:
          t.tokenSymbol === TokenSymbol.TETHER
            ? TokenSymbol.USDT
            : t.tokenSymbol,
      }));
  }

  /**
   * Get total balances, divided by Network and Token.
   * @returns {Promise<IBalance[]>}
   */
  async getBalance(): Promise<IBalance[]> {
    const response = (
      await this.get<IOwnerDetailsResponse>(
        `${this.akashicUrl}${AkashicEndpoints.OwnerBalance}?address=${
          this.#otk.identity
        }`
      )
    ).data;

    return response.totalBalances.map((bal) => ({
      networkSymbol: bal.coinSymbol,
      tokenSymbol:
        bal.tokenSymbol === TokenSymbol.TETHER
          ? TokenSymbol.USDT
          : bal.tokenSymbol,
      balance: bal.balance,
    }));
  }

  /**
   * Get details of an individual transaction. Returns undefined if no
   * transaction found for the queried hash
   * @param {string} l2TxHash L2 AkashicChain Hash of transaction
   * @returns {Promise<ITransaction | undefined>}
   */
  async getTransactionDetails(
    l2TxHash: string
  ): Promise<ITransaction | undefined> {
    const response = await this.get<ITransactionDetailsResponse>(
      `${this.akashicUrl}${AkashicEndpoints.TransactionsDetails}?l2Hash=${l2TxHash}`
    );
    const transaction = response.data.transaction;
    if (transaction) {
      return {
        ...transaction,
        tokenSymbol:
          transaction.tokenSymbol === TokenSymbol.TETHER
            ? TokenSymbol.USDT
            : transaction.tokenSymbol,
      };
    }

    return undefined;
  }

  /**
   * Get key by BP and identifier
   * @returns {Promise<IGetByOwnerAndIdentifierResposne>}
   */
  async getByOwnerAndIdentifier(
    getByOwnerAndIdentifierParams: IGetByOwnerAndIdentifier
  ): Promise<IGetByOwnerAndIdentifierResposne> {
    const queryParameters = {
      ...getByOwnerAndIdentifierParams,
      identity: this.#otk.identity,
    };
    const query = encodeURIObject(queryParameters);
    return (
      await this.get<IGetByOwnerAndIdentifierResposne>(
        `${this.akashicUrl}${AkashicEndpoints.IdentifierLookup}?${query}`
      )
    ).data;
  }

  /**
   * Do not call this constructor directly; instead, use the factory function
   * {@link AkashicPay.build}.
   *
   * The class itself is only exported for developers who want to extend it.
   */
  protected constructor(args: APConstructorArgs<Env>) {
    this.env = args.environment ?? (Environment.Production as Env);

    this.akashicChain = new AkashicChain(this.env);

    this.logger = args.logger ?? ConsoleLogger;

    this.httpClient = new FetchHttpClient(this.logger);

    this.akashicUrl = (
      args.environment === Environment.Development
        ? AkashicBaseUrlDev
        : AkashicBaseUrl
    ) as AkashicUrlT<Env>;

    if (args.targetNode) this.targetNode = args.targetNode;
  }

  /**
   * Complete the initialisation of the AkashicPay instance by doing the async
   * operations not possible in the constructor
   * @ignore
   */
  protected async init(args: APInitialisationArgs<Env>): Promise<void> {
    this.logger.info('Initialising AkashicPay instance');
    if (!this.targetNode) {
      this.targetNode = await this.chooseBestACNode();
    }

    if (!args.l2Address) {
      await this.setNewOTK();
    } else {
      // Check if BP if on prod
      if (this.env === Environment.Production) {
        const {
          data: { isBp },
        } = await this.get<{ isBp: boolean }>(
          `${this.akashicUrl}${AkashicEndpoints.IsBp}?address=${args.l2Address}`
        );
        if (!isBp) {
          throw new Error(AkashicError.IsNotBp);
        }
      }
      if ('privateKey' in args && args.privateKey) {
        this.setOtkFromKeyPair(args.privateKey, args.l2Address);
      } else if ('recoveryPhrase' in args && args.recoveryPhrase) {
        await this.setOtkFromRecoveryPhrase(
          args.recoveryPhrase,
          args.l2Address
        );
      } else {
        throw new Error(AkashicError.IncorrectPrivateKeyFormat);
      }
    }

    this.logger.info('AkashicPay instance initialised');
  }

  /**
   * Finds an AkashicChain node to target for requests. The SDK will attempt to
   * find the fastest node for you.
   *
   * @returns {Promise<ACNode | ACDevNode>} The URL of an AC node on the network matching your environment
   * (production or development)
   */
  protected async chooseBestACNode(): Promise<ACNodeT<Env>> {
    const nodes = this.env === Environment.Production ? ACNodes : ACDevNodes;
    // We are targeting es2017 which does not have Promise.any so we add a
    // delay if the request fails in hope that a success resolves first. This is
    // bc it is okay if a couple nodes fail as we only need a majority to be online
    const fastestNode = await Promise.race(
      Object.values(nodes).map(async (node) => {
        try {
          await this.httpClient.get(node.minigate);
          return node;
        } catch {
          return new Promise((_resolve, reject) => {
            setTimeout(reject, 10000);
          });
        }
      })
    );

    this.logger.info(
      'Set target node as %s by testing for fastest',
      fastestNode
    );
    return fastestNode as ACNodeT<Env>;
  }

  /**
   * Generates a new OTK and assigns it to `this.otk`. The OTK will live and die
   * with the lifetime of this AkashicPay instance.
   *
   * Only for us in development/testing environments.
   */
  protected async setNewOTK(): Promise<void> {
    this.logger.info(
      'Generating new OTK for development environment. Access it via `this.otk().`'
    );
    const otk = await generateOTK();
    const onboardTx = await this.akashicChain.onboardOtkTransaction(otk);

    const alResponse = await this.post<
      ActiveLedgerResponse<unknown, { id: string }>
    >(this.targetNode.node, onboardTx);

    const identity = alResponse.data.$streams.new?.[0]?.id;

    if (!identity) {
      throw new Error(AkashicError.TestNetOtkOnboardingFailed);
    }

    this.#otk = {
      ...otk,
      identity: 'AS' + identity,
    };
    this.logger.info(
      'New OTK generated and onboarded with identity: %s',
      this.#otk.identity
    );
  }

  /**
   * Sets your OTK to sign transactions on AkashicChain (AC)
   *
   * @param {string} privateKey private key from Akashic Link.
   * @param {string} l2Address L2-address of your Akashic account
   */
  protected setOtkFromKeyPair(privateKey: string, l2Address: string): void {
    // First check if it matches private-key format
    if (!AkashicPay.ACPrivateKeyRegex.test(privateKey))
      throw new Error(AkashicError.IncorrectPrivateKeyFormat);

    this.#otk = {
      ...restoreOtkFromKeypair(privateKey),
      identity: l2Address,
    };
    this.logger.debug('otk set from private key');
  }

  /**
   * Sets your OTK to sign transactions on AkashicChain (AC)
   *
   * @param {string} recoveryPhrase The recovery phrase generated when you
   * created your Akashic Link account.
   * @param {string} l2Address L2-address of your Akashic account
   */
  protected async setOtkFromRecoveryPhrase(
    recoveryPhrase: string,
    l2Address: string
  ): Promise<void> {
    this.#otk = {
      ...(await restoreOtkFromPhrase(recoveryPhrase)),
      identity: l2Address,
    };
    this.logger.debug('otk set from recovery phrase');
  }

  // HTTP POST & GET
  protected async post<T>(
    url: string,
    payload: unknown
  ): Promise<HTTPResponse<T>> {
    // payloads to AC become public information, so they're safe to log
    this.logger.trace(payload, 'POSTing to url: %s', url);
    return await this.httpClient.post(url, payload);
  }

  protected async get<T>(url: string): Promise<HTTPResponse<T>> {
    return await this.httpClient.get(url);
  }
}