Source

main.ts

import { ActiveCrypto } from '@activeledger/activecrypto';
import { createHmac } from 'crypto';
import { createLogger, format, type Logger, transports } from 'winston';

import {
  AkashicChain,
  checkForAkashicChainError,
  L2Regex,
} from './akashicChain';
import type {
  ActiveLedgerResponse,
  ICreateDepositOrderResponse,
  IGetByOwnerAndIdentifierResponse,
  IKeyByOwnerAndIdentifier,
  IKeyCreationResponse,
  IOwnerDetailsResponse,
  IOwnerTransactionsResponse,
  IPrepareL1TxnResponse,
  IPrepareL2Withdrawal,
  IPrepareL2WithdrawalResponse,
  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 {
  restoreOtkFromKeypair,
  restoreOtkFromPhrase,
} from './otk-reconstruction/reconstruct';
import {
  type ACNodeT,
  type AkashicPayUrlT,
  type AkashicUrlT,
  type APBuilderArgs,
  type APConstructorArgs,
  type APInitialisationArgs,
  Currency,
  type HTTPResponse,
  type IBalance,
  type ICreateDepositOrder,
  type IDepositAddress,
  type IGetByOwnerAndIdentifier,
  type IGetExchangeRatesResult,
  type IGetKeysByOwnerAndIdentifier,
  type IGetTransactions,
  type IHttpClient,
  type ILookForL2AddressResult,
  type IPayoutResponse,
  type ITransaction,
  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}$/;
  private isFxBp = false;
  #otk: Otk;
  protected targetNode: ACNodeT<Env>;
  protected akashicUrl: AkashicUrlT<Env>;
  protected akashicPayUrl: AkashicPayUrlT<Env>;
  protected logger: Logger;
  protected env: Env;
  protected httpClient: IHttpClient;
  protected akashicChain: AkashicChain;
  protected apiSecret: string | undefined;

  /**
   * Construct and initialise a new {@link AkashicPay} instance.
   *
   * @param {APBuilderArgs<Environment>} args Differ depending on the environment property
   * (i.e. {@link APBuilderArgs.environment}). You must provide the
   * L2-address of an Akashic-Link account, and either the corresponding
   * private-key or recovery-phrase.
   * @constructor
   * @returns {Promise<AkashicPay<Environment>>}
   */
  static async build<Env extends Environment = Environment.Production>(
    args: APBuilderArgs<Env>
  ): Promise<AkashicPay<Env>> {
    const { environment, targetNode, apiSecret, ...initialisationArgs } = args;
    const ap = new AkashicPay<Env>({
      environment,
      targetNode,
      apiSecret,
    });
    await ap.init(initialisationArgs);
    return ap;
  }

  /**
   * 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;
    token = this.normalizeTokenInput(network, token);
    // 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 signedL2Tx = await this.akashicChain.l2Transaction(
        {
          otk: this.#otk,
          amount: decimalAmount,
          toAddress,
          coinSymbol: network,
          tokenSymbol: token,
          initiatedToNonL2,
          identifier: recipientId,
        },
        this.isFxBp
      );

      let l2Tx = signedL2Tx;
      // Check if the user is an FX BP
      if (this.isFxBp) {
        // Backend should sign the transaction
        const response = await this.prepareL2Transaction({
          signedTx: signedL2Tx,
        });
        l2Tx = response.preparedTxn;
      }
      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 ${amount} ${token} to user ${recipientId} at ${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 ${amount} ${token} to user ${recipientId} at $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
   * @param {string} referenceId optional referenceId to identify the order
   *  making the deposit
   * @returns {Promise<IDepositAddress>}
   */
  async getDepositAddress(
    network: NetworkSymbol,
    identifier: string,
    referenceId?: string
  ): Promise<IDepositAddress> {
    return await this.getDepositAddressFunc(network, identifier, referenceId);
  }

  /**
   * Get an L1-address on the specified network for a user to deposit into
   * with requested value
   * callback will match the requested value
   * @param {NetworkSymbol} network L1-network
   * @param {string} identifier userID or similar identifier of the user
   * @param {string} referenceId referenceId to identify the order
   *  making the deposit
   * @param {Currency} requestedCurrency requested currency
   * @param {string} requestedAmount requested amount
   * @param {string} receiveCurrencies optional currencies to be shown in deposit page, comma separated
   * @param {TokenSymbol} token optional token symbol
   * @returns {Promise<IDepositAddress>}
   */
  async getDepositAddressWithRequestedValue(
    network: NetworkSymbol,
    identifier: string,
    referenceId: string,
    requestedCurrency: Currency,
    requestedAmount: string,
    token?: TokenSymbol
  ): Promise<IDepositAddress> {
    return await this.getDepositAddressFunc(
      network,
      identifier,
      referenceId,
      token,
      requestedCurrency,
      requestedAmount
    );
  }

  protected async getDepositAddressFunc(
    network: NetworkSymbol,
    identifier: string,
    referenceId?: string,
    token?: TokenSymbol,
    requestedCurrency?: Currency,
    requestedAmount?: string
  ): Promise<IDepositAddress> {
    this.logger.info('getDepositAddress called', { identifier });
    const { address, unassignedLedgerId } = await this.getByOwnerAndIdentifier({
      identifier,
      coinSymbol: network,
    });

    if (address) {
      if (unassignedLedgerId) {
        const tx = await this.akashicChain.assign(
          unassignedLedgerId,
          this.#otk,
          identifier
        );
        // Assign key to the user
        const response = await this.post<
          ActiveLedgerResponse<IKeyCreationResponse>
        >(this.targetNode.node, tx);
        const assignedKey = response.data.$responses?.[0];
        if (!assignedKey) {
          this.logger.warn(
            `Failed to assign key for identifier ${identifier} on ${network}. AC: ${JSON.stringify(response.data)}`
          );
          throw new AkashicError(AkashicErrorCode.AssignmentFailed);
        }
      }
      if (referenceId) {
        const depositRequest = await this.createDepositPayloadAndOrder(
          referenceId,
          identifier,
          address,
          network,
          token,
          requestedCurrency,
          requestedAmount
        );
        return {
          address,
          identifier,
          referenceId,
          requestedAmount:
            depositRequest.requestedValue?.amount ?? requestedAmount,
          requestedCurrency:
            depositRequest.requestedValue?.currency ?? requestedCurrency,
          network: depositRequest.coinSymbol ?? network,
          token: depositRequest.tokenSymbol ?? token,
          exchangeRate: depositRequest.exchangeRate,
          amount: depositRequest.amount,
        };
      }
      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(
        `Failed to create key on ${network} for identifier ${identifier}. AC: ${JSON.stringify(response.data)}`
      );
      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 ${network} failed at differential consensus for identifier ${identifier}. Unhealthy key: ${JSON.stringify(newKey)}`
      );
      throw new AkashicError(AkashicErrorCode.UnHealthyKey);
    }

    if (referenceId) {
      const depositRequest = await this.createDepositPayloadAndOrder(
        referenceId,
        identifier,
        newKey.address,
        network,
        token,
        requestedCurrency,
        requestedAmount
      );
      return {
        address: newKey.address,
        identifier,
        referenceId,
        requestedAmount:
          depositRequest.requestedValue?.amount ?? requestedAmount,
        requestedCurrency:
          depositRequest.requestedValue?.currency ?? requestedCurrency,
        network: depositRequest.coinSymbol ?? network,
        token: depositRequest.tokenSymbol ?? token,
        exchangeRate: depositRequest.exchangeRate,
        amount: depositRequest.amount,
      };
    }

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

  async createDepositPayloadAndOrder(
    referenceId: string,
    identifier: string,
    address: string,
    network: NetworkSymbol,
    tokenSymbol?: TokenSymbol,
    requestedCurrency?: Currency,
    requestedAmount?: string
  ): Promise<ICreateDepositOrderResponse> {
    const payloadToSign = {
      identity: this.#otk.identity,
      expires: Date.now() + 60 * 1000,
      referenceId,
      identifier,
      toAddress: address,
      coinSymbol: network,
      tokenSymbol,
      ...(requestedCurrency &&
        requestedAmount && {
          requestedValue: {
            currency: requestedCurrency,
            amount: requestedAmount,
          },
        }),
    };
    return await this.createDepositOrder({
      ...payloadToSign,
      signature: this.sign(this.#otk.key.prv.pkcs8pem, payloadToSign),
    });
  }

  /**
   * Get deposit page url
   * @param {string} identifier userID or similar identifier of the user
   * @param {string} referenceId optional referenceId to identify the order
   * @param {Currency[]} receiveCurrencies optional currencies to be shown in deposit page, comma separated
   *  making the deposit
   * @returns {Promise<string>}
   */
  async getDepositUrl(
    identifier: string,
    referenceId?: string,
    receiveCurrencies?: Currency[],
    redirectUrl?: string
  ): Promise<string> {
    return await this.getDepositUrlFunc(
      identifier,
      referenceId,
      receiveCurrencies,
      redirectUrl
    );
  }

  /**
   * Get deposit page url with requested value
   * callback will match the requested value
   * @param {string} identifier userID or similar identifier of the user
   * @param {string} referenceId referenceId to identify the order
   * @param {Currency} requestedCurrency requested currency
   * @param {string} requestedAmount requested amount
   * @param {Currency[]} receiveCurrencies optional currencies to be shown in deposit page, comma separated
   * @returns {Promise<string>}
   */
  async getDepositUrlWithRequestedValue(
    identifier: string,
    referenceId: string,
    requestedCurrency: Currency,
    requestedAmount: string,
    receiveCurrencies?: Currency[],
    redirectUrl?: string
  ): Promise<string> {
    return await this.getDepositUrlFunc(
      identifier,
      referenceId,
      receiveCurrencies,
      redirectUrl,
      requestedCurrency,
      requestedAmount
    );
  }

  protected async getDepositUrlFunc(
    identifier: string,
    referenceId?: string,
    receiveCurrencies?: Currency[],
    redirectUrl?: string,
    requestedCurrency?: Currency,
    requestedAmount?: string
  ): Promise<string> {
    const [keys, supportedCurrencies] = await Promise.all([
      this.getKeysByOwnerAndIdentifier({
        identifier,
      }),
      this.getSupportedCurrencies(),
    ]);

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

    if (referenceId) {
      const payloadToSign = {
        identity: this.#otk.identity,
        expires: Date.now() + 60 * 1000,
        referenceId,
        identifier,
        ...(requestedCurrency &&
          requestedAmount && {
            requestedValue: {
              currency: requestedCurrency,
              amount: requestedAmount,
            },
          }),
      };
      await this.createDepositOrder({
        ...payloadToSign,
        signature: this.sign(this.#otk.key.prv.pkcs8pem, payloadToSign),
      });
    }

    // Build query parameters
    const params: Record<string, string> = {
      identity: this.#otk.identity,
      identifier,
    };
    if (referenceId) params.referenceId = referenceId;
    if (receiveCurrencies)
      params.receiveCurrencies = receiveCurrencies
        .map((c) => this.mapMainToTestCurrency(c))
        .join(',');
    if (redirectUrl) {
      params.redirectUrl = Buffer.from(redirectUrl).toString('base64url');
    }
    const queryString = encodeURIObject(params);
    return `${this.akashicPayUrl}/sdk/deposit?${queryString}`;
  }

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

  /**
   * Get exchange rates for all supported main-net coins in value of requested currency
   * @param {string} requestedCurrency
   * @returns {Promise<IGetExchangeRatesResult>}
   */
  async getExchangeRates(
    requestedCurrency: Currency
  ): Promise<IGetExchangeRatesResult> {
    let url = `${this.akashicUrl}${AkashicEndpoints.ExchangeRates}/${requestedCurrency}`;
    return (await this.get<IGetExchangeRatesResult>(url)).data;
  }

  /**
   * 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((t) => ({
      ...t,
      tokenSymbol: this.normalizeTokenSymbol(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: this.normalizeTokenSymbol(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: this.normalizeTokenSymbol(transaction.tokenSymbol),
      };
    }

    return undefined;
  }

  /**
   * Get the currently supported currencies in AkashicPay
   * Returns an object with the currency as the keys and a list of networks as the values
   */
  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,
      usePreSeed: true,
    };
    const query = encodeURIObject(queryParameters);
    return (
      await this.get<IGetByOwnerAndIdentifierResponse>(
        `${this.akashicUrl}${AkashicEndpoints.IdentifierLookup}?${query}`
      )
    ).data;
  }

  /**
   * Create deposit order
   * @returns {Promise<ICreateDepositOrderResponse>}
   */
  async createDepositOrder(
    createDepositOrderParams: ICreateDepositOrder
  ): Promise<ICreateDepositOrderResponse> {
    return (
      await this.post<ICreateDepositOrderResponse>(
        `${this.akashicUrl}${AkashicEndpoints.CreateDepositOrder}`,
        createDepositOrderParams
      )
    ).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);

    const logger = createLogger({
      level: 'info',
      exitOnError: false,
      format: format.json(),
      transports: [
        new transports.Console({
          format: format.simple(),
        }),
      ],
    });

    this.logger = logger;

    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;

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

  /**
   * Verify HMAC signature against body and secret, sorting keys alphabetically
   */
  public verifySignature(
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    body: any,
    signature: string
  ): boolean {
    if (!this.apiSecret) {
      throw new Error('API secret not set');
    }

    try {
      const sortedBody = this.sortKeys(body);
      const computed = createHmac('sha256', this.apiSecret)
        .update(JSON.stringify(sortedBody))
        .digest('hex');
      return computed === signature;
    } catch {
      return false;
    }
  }

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

    const {
      data: { isBp, isFxBp },
    } = await this.get<{ isBp: boolean; isFxBp: boolean }>(
      `${this.akashicUrl}${AkashicEndpoints.IsBp}?address=${args.l2Address}`
    );
    this.isFxBp = isFxBp;
    // Only BPs can use the SDK
    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 = this.logger.child({ identity: args.l2Address });
    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 ${JSON.stringify(fastestFullHealthNode)} by testing for fastest`
      );
      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 ${JSON.stringify(node)} (health: ${status})`
      );
      return node;
    }
  }

  /**
   * 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.info(JSON.stringify(payload), `POSTing to url: ${url}`);
    return await this.httpClient.post(url, payload);
  }

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

  protected async prepareL2Transaction(
    transactionData: IPrepareL2Withdrawal
  ): Promise<IPrepareL2WithdrawalResponse> {
    const response = await this.post<IPrepareL2WithdrawalResponse>(
      `${this.akashicUrl}${AkashicEndpoints.PrepareL2Txn}`,
      transactionData
    );
    return response.data;
  }
  // Sign a piece of data using the private key to be verified by the backend
  protected sign(
    otkPriv: string,
    data: string | Record<string, unknown>
  ): string {
    try {
      // Have to convert private key into correct format
      const pemPrivate =
        '-----BEGIN EC PRIVATE KEY-----\n' +
        `${otkPriv}\n` +
        '-----END EC PRIVATE KEY-----';
      let kp;
      if (otkPriv.startsWith('0x')) {
        kp = new ActiveCrypto.KeyPair('secp256k1', otkPriv);
      } else {
        kp = new ActiveCrypto.KeyPair('secp256k1', pemPrivate);
      }
      return kp.sign(data);
    } catch {
      throw new Error('Invalid private key');
    }
  }

  // Recursively sort object keys for consistent JSON serialization
  /* eslint-disable */
  private sortKeys(obj: any): any {
    if (Array.isArray(obj)) {
      return obj.map((item) => this.sortKeys(item));
    } else if (
      obj !== null &&
      typeof obj === 'object' &&
      !(obj instanceof Date)
    ) {
      return Object.keys(obj)
        .sort((a, b) => a.localeCompare(b))
        .reduce((acc, key) => {
          acc[key] = this.sortKeys(obj[key]);
          return acc;
        }, {} as any);
    }
    return obj;
  }
  /* eslint-enable */

  /**
   * Normalize token symbols (map TETHER to USDT)
   */
  private normalizeTokenSymbol(symbol?: TokenSymbol): TokenSymbol | undefined {
    if (!symbol) return undefined;
    return symbol === TokenSymbol.TETHER ? TokenSymbol.USDT : symbol;
  }

  /**
   * Normalize token (map USDT to TETHER for Tron Shasta network)
   */
  private normalizeTokenInput(
    network: NetworkSymbol,
    token?: TokenSymbol
  ): TokenSymbol | undefined {
    if (network === TronSymbol.Tron_Shasta && token === TokenSymbol.USDT) {
      return TokenSymbol.TETHER;
    }
    return token;
  }

  /**
   * Map receive currency from mainnet to testnet if in development
   */
  private mapMainToTestCurrency(currency: Currency): string {
    return this.env === Environment.Development && currency === Currency.ETH
      ? 'SEP'
      : currency;
  }
}

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