Source

main.ts

import {
  AkashicChain,
  checkForAkashicChainError,
  L2Regex,
} from './akashicChain';
import type {
  ActiveLedgerResponse,
  IGetByOwnerAndIdentifierResponse,
  IKeyByOwnerAndIdentifier,
  IKeyCreationResponse,
  IOwnerDetailsResponse,
  IOwnerTransactionsResponse,
  IPrepareL1TxnResponse,
  ITransactionDetailsResponse,
  NodeStatus,
  PrepareTxnDto,
} from './APACTypes';
import { FeeDelegationStrategy } from './APACTypes';
import {
  ACDevNodes,
  ACNodes,
  AkashicBaseUrl,
  AkashicBaseUrlDev,
  AkashicEndpoints,
  AkashicPayBaseUrl,
  AkashicPayBaseUrlDev,
  Environment,
} from './constants';
import { AkashicError, AkashicErrorCode } from './error';
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 AkashicPayUrlT,
  type AkashicUrlT,
  type APBuilderArgs,
  type APConstructorArgs,
  type APInitialisationArgs,
  type HTTPResponse,
  type IBalance,
  type IDepositAddress,
  type IGetByOwnerAndIdentifier,
  type IGetKeysByOwnerAndIdentifier,
  type IGetTransactions,
  type IHttpClient,
  type ILookForL2AddressResult,
  type IPayoutResponse,
  type ITransaction,
  type KeyBackup,
  type NetworkSymbol,
  type Otk,
  TokenSymbol,
  TronSymbol,
} from './types';
import { promiseAny } from './utils/async';
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 readonly ACPrivateKeyRegex = /^0x[a-f\d]{64}$/;
  #otk: Otk;
  protected targetNode: ACNodeT<Env>;
  protected akashicUrl: AkashicUrlT<Env>;
  protected akashicPayUrl: AkashicPayUrlT<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 {AkashicError} if the environment is production.
   * @type {KeyBackup}
   */
  get keyBackup(): Env extends Environment.Production ? never : KeyBackup {
    if (this.env === Environment.Production)
      throw new AkashicError(AkashicErrorCode.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
   * or error if something went wrong
   */
  // eslint-disable-next-line sonarjs/cognitive-complexity
  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 (RegExp(NetworkDictionary[network].regex.address).exec(to)) {
      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 (RegExp(L2Regex).exec(to)) {
      if (!l2Address) {
        return {
          error: AkashicErrorCode.L2AddressNotFound,
        };
      }
      // Sending L2 by L2 address
      isL2 = true;
    } else {
      if (!l2Address) {
        return {
          error: AkashicErrorCode.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
      );

      const chainError = checkForAkashicChainError(acResponse.data);
      if (chainError) return { error: chainError };

      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,
      };

      let preparedTxn;
      try {
        preparedTxn = (
          await this.post<IPrepareL1TxnResponse>(
            this.akashicUrl + AkashicEndpoints.PrepareTx,
            payload
          )
        ).data.preparedTxn;
      } catch (error) {
        if (
          error instanceof Error &&
          error.message.includes('exceeds total savings')
        ) {
          return {
            error: AkashicErrorCode.SavingsExceeded,
          };
        }
        return {
          error: AkashicErrorCode.UnknownError,
        };
      }

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

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

      const chainError = checkForAkashicChainError(acResponse.data);
      if (chainError) return { error: chainError };

      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 AkashicError(AkashicErrorCode.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 AkashicError(AkashicErrorCode.UnHealthyKey);
    }

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

  /**
   * Get deposit page url
   * @param {string} identifier userID or similar identifier of the user
   *  making the deposit
   * @returns {Promise<string>}
   */
  async getDepositUrl(identifier: string): Promise<string> {
    const [keys, supportedCurrencies] = await Promise.all([
      this.getKeysByOwnerAndIdentifier({
        identifier,
      }),
      this.getSupportedCurrencies(),
    ]);

    for (const coinSymbol of new Set(Object.keys(supportedCurrencies) as NetworkSymbol[])) {
      if (!new Set(keys.map((key) => key.coinSymbol)).has(coinSymbol)) {
        await this.getDepositAddress(coinSymbol, identifier);
      }
    }

    return `${this.akashicPayUrl}/sdk/deposit?identity=${
      this.#otk.identity
    }&identifier=${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 the currently supported currencies in AkashicPay
   * Returns an object with the networks as the keys and a list of tokens as the values
   * Native coins (TRX, ETH, etc.) are not returned but always supported if the
   * network is supported
   */
  async getSupportedCurrencies(): Promise<Record<string, string[]>> {
    return (
      await this.get<Record<string, string[]>>(
        `${this.akashicUrl}${AkashicEndpoints.SupportedCurrencies}`
      )
    ).data;
  }

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

  /**
   * Get all keys by BP and identifier
   * @returns {Promise<IKeyByOwnerAndIdentifier[]>}
   */
  async getKeysByOwnerAndIdentifier(
    getKeysByOwnerAndIdentifierParams: IGetKeysByOwnerAndIdentifier
  ): Promise<IKeyByOwnerAndIdentifier[]> {
    const queryParameters = {
      ...getKeysByOwnerAndIdentifierParams,
      identity: this.#otk.identity,
    };
    const query = encodeURIObject(queryParameters);
    return (
      await this.get<IKeyByOwnerAndIdentifier[]>(
        `${this.akashicUrl}${AkashicEndpoints.AllKeysOfIdentifier}?${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 = (
      this.env === Environment.Development ? AkashicBaseUrlDev : AkashicBaseUrl
    ) as AkashicUrlT<Env>;

    this.akashicPayUrl = (
      this.env === Environment.Development
        ? AkashicPayBaseUrlDev
        : AkashicPayBaseUrl
    ) as AkashicPayUrlT<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 AkashicError(AkashicErrorCode.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 AkashicError(AkashicErrorCode.IncorrectPrivateKeyFormat);
      }
    }

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

  /**
   * Finds an AkashicChain node to target for requests. If the chain is totally
   * healthy, the SDK will return the fastest node. Otherwise, it will return
   * the node least likely to experience consensus failures as the entry node.
   *
   * @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;
    try {
      const fastestFullHealthNode = await promiseAny(
        Object.values(nodes).map(async (node) => {
          const response = (
            await this.httpClient.get<NodeStatus>(`${node.node}a/status`)
          ).data;
          if (response.status !== 4)
            // Hold out for a 4, but if not, we might settle for this below
            throw new ACHealthError(node, response.status);

          return node;
        })
      );

      this.logger.info(
        'Set healthy target node as %s by testing for fastest',
        fastestFullHealthNode
      );
      return fastestFullHealthNode as ACNodeT<Env>;
    } catch (errors) {
      if (!(errors instanceof Array)) throw errors;

      const healthErrors = errors
        .filter((e) => e instanceof ACHealthError)
        .sort((e1, e2) => e2.status - e1.status);
      if (!healthErrors.length) throw errors;

      const { node, status } = healthErrors[0];
      this.logger.warn(
        'Set least unhealthy target node $s (health: $d)',
        node,
        status
      );
      return node;
    }
  }

  /**
   * 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 AkashicError(AkashicErrorCode.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 AkashicError(AkashicErrorCode.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);
  }
}

class ACHealthError<Env extends Environment> extends Error {
  constructor(
    public node: ACNodeT<Env>,
    public status: number
  ) {
    super(`Node ${node.node} is unhealthy with status ${status}`);
  }
}