Source

contracts/erc20/erc20contract.ts

import { AuthorizationError, Condition } from "@decaf-ts/core";
import { Context, Transaction } from "fabric-contract-api";
import { add, sub } from "../../shared/math";
import {
  AllowanceError,
  BalanceError,
  NotInitializedError,
} from "../../shared/errors";
import { FabricContractAdapter } from "../ContractAdapter";
import { Allowance, ERC20Token, ERC20Wallet } from "./models";
import { Owner } from "../../shared/decorators";
import { FabricContractRepository } from "../FabricContractRepository";
import type { FabricContractContext } from "../ContractContext";
import {
  BaseError,
  InternalError,
  NotFoundError,
  ValidationError,
} from "@decaf-ts/db-decorators";
import { FabricCrudContract } from "../crud/crud-contract";
import { FabricContractRepositoryObservableHandler } from "../FabricContractRepositoryObservableHandler";
import { ERC20Events } from "../../shared/erc20/erc20-constants";

/**
 * @description ERC20 token contract base for Hyperledger Fabric
 * @summary Implements ERC20-like token logic using repositories and adapters, providing standard token operations such as balance queries, transfers, approvals, minting and burning.
 * @param {string} name - The contract name used to scope token identity
 * @note https://eips.ethereum.org/EIPS/eip-20
 * @return {void}
 * @class FabricERC20Contract
 * @example
 * class MyTokenContract extends FabricERC20Contract {
 *   constructor() { super('MyToken'); }
 * }
 * // The contract exposes methods like Transfer, Approve, Mint, Burn, etc.
 * @mermaid
 * sequenceDiagram
 *   participant Client
 *   participant Contract
 *   participant WalletRepo
 *   participant TokenRepo
 *   participant Ledger
 *   Client->>Contract: Transfer(ctx, to, value)
 *   Contract->>WalletRepo: read(from)
 *   Contract->>WalletRepo: read(to)
 *   Contract->>Ledger: putState(updated balances)
 *   Contract-->>Client: success
 */
export abstract class FabricERC20Contract extends FabricCrudContract<ERC20Wallet> {
  private walletRepository: FabricContractRepository<ERC20Wallet>;

  private tokenRepository: FabricContractRepository<ERC20Token>;

  private allowanceRepository: FabricContractRepository<Allowance>;

  protected constructor(name: string) {
    super(name, ERC20Wallet);

    FabricERC20Contract.adapter =
      FabricERC20Contract.adapter || new FabricContractAdapter();

    this.walletRepository = FabricContractRepository.forModel(
      ERC20Wallet,
      FabricERC20Contract.adapter.alias
    );

    this.tokenRepository = FabricContractRepository.forModel(
      ERC20Token,
      FabricERC20Contract.adapter.alias
    );

    this.allowanceRepository = FabricContractRepository.forModel(
      Allowance,
      FabricERC20Contract.adapter.alias
    );
  }

  @Transaction(false)
  async TokenName(ctx: Context): Promise<string> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(ctx);

    const select = await this.tokenRepository.select();
    const token = (await select.execute(ctx))[0];

    return token.name;
  }

  /**
   * Return the symbol of the token. E.g. “HIX”.
   *
   * @param {Context} ctx the transaction context
   * @returns {String} Returns the symbol of the token
   */
  @Transaction(false)
  async Symbol(ctx: Context): Promise<string> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(ctx);

    const select = await this.tokenRepository.select();
    const token = (await select.execute(ctx))[0];

    return token.symbol;
  }

  /**
   * Return the number of decimals the token uses
   * e.g. 8, means to divide the token amount by 100000000 to get its user representation.
   *
   * @param {Context} ctx the transaction context
   * @returns {Number} Returns the number of decimals
   */
  @Transaction(false)
  async Decimals(ctx: Context): Promise<number> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(ctx);

    const select = await this.tokenRepository.select();
    const token = (await select.execute(ctx))[0];

    return token.decimals;
  }

  /**
   * Return the total token supply.
   *
   * @param {Context} ctx the transaction context
   * @returns {Number} Returns the total token supply
   */
  @Transaction(false)
  async TotalSupply(ctx: Context): Promise<number> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(ctx);

    const select = await this.walletRepository.select();
    const wallets = await select.execute(ctx);

    if (wallets.length == 0) {
      throw new NotFoundError(`The token ${this.getName()} does not exist`);
    }

    let total = 0;

    wallets.forEach((wallet) => {
      total += wallet.balance;
    });

    return total;
  }

  /**
   * BalanceOf returns the balance of the given account.
   *
   * @param {Context} ctx the transaction context
   * @param {String} owner The owner from which the balance will be retrieved
   * @returns {Number} Returns the account balance
   */
  @Transaction(false)
  async BalanceOf(ctx: Context, owner: string): Promise<number> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(ctx);

    const wallet = await this.walletRepository.read(owner, ctx);

    return wallet.balance;
  }

  /**
   * @summary Transfer transfers tokens from client account to recipient account.
   * @description recipient account must be a valid clientID as returned by the ClientAccountID() function.
   *
   * @param {Context} ctx the transaction context
   * @param {String} to The recipient
   * @param {number} value The amount of token to be transferred
   *
   * @returns {Boolean} Return whether the transfer was successful or not
   */
  @Transaction()
  async Transfer(
    context: Context,
    to: string,
    value: number
  ): Promise<boolean> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(context);
    const { ctx } = await this.logCtx([context], this.Transfer);

    const from = ctx.identity.getID();

    const transferResp = await this._transfer(from, to, value, ctx);
    if (!transferResp) {
      throw new InternalError("Failed to transfer");
    }

    return true;
  }

  /**
   * Transfer `value` amount of tokens from `from` to `to`.
   *
   * @param {Context} ctx the transaction context
   * @param {String} from The sender
   * @param {String} to The recipient
   * @param {number} value The amount of token to be transferred
   * @returns {Boolean} Return whether the transfer was successful or not
   */
  @Transaction()
  async TransferFrom(
    context: Context,
    from: string,
    to: string,
    value: number
  ): Promise<boolean> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(context);
    const { ctx } = await this.logCtx([context], this.BurnFrom);

    // Retrieve the allowance of the spender

    const spender = ctx.identity.getID();

    const allowance = await this._getAllowance(from, spender, ctx);
    if (!allowance || allowance.value < 0) {
      throw new AllowanceError(
        `spender ${spender} has no allowance from ${from}`
      );
    }

    const currentAllowance = allowance.value;

    // Check if the transferred value is less than the allowance
    if (currentAllowance < value) {
      throw new BalanceError(
        "The spender does not have enough allowance to spend."
      );
    }

    // Decrease the allowance
    const updatedAllowance = sub(currentAllowance, value);
    const newAllowance = Object.assign({}, allowance, {
      value: updatedAllowance,
    });

    await this.allowanceRepository.update(newAllowance, ctx);

    //Realize the transfer
    const transferResp = await this._transfer(from, to, value, ctx);
    if (!transferResp) {
      throw new InternalError("Failed to transfer");
    }

    return true;
  }

  async _transfer(
    from: string,
    to: string,
    value: number,
    ctx: FabricContractContext
  ) {
    const log = ctx.logger;

    if (from === to) {
      throw new AuthorizationError(
        "cannot transfer to and from same client account"
      );
    }

    if (value < 0) {
      // transfer of 0 is allowed in ERC20, so just validate against negative amounts
      throw new BalanceError("transfer amount cannot be negative");
    }

    // Retrieve the current balance of the sender

    const fromWallet = await this.walletRepository.read(from, ctx);

    const fromBalance = fromWallet.balance;

    // Check if the sender has enough tokens to spend.
    if (fromBalance < value) {
      throw new BalanceError(`client account ${from} has insufficient funds.`);
    }

    // Retrieve the current balance of the recepient

    let toWallet: ERC20Wallet;
    let newToWallet: boolean = false;
    try {
      toWallet = await this.walletRepository.read(to, ctx);
    } catch (e: unknown) {
      if (e instanceof BaseError) {
        if (e.code === 404) {
          // Create a new wallet for the minter
          toWallet = new ERC20Wallet({
            id: to,
            balance: 0,
            token: await this.TokenName(ctx as any),
          });
          newToWallet = true;
        } else {
          throw new InternalError(e.message);
        }
      } else {
        throw new InternalError(e as string);
      }
    }

    const toBalance = toWallet.balance;

    // Update the balance
    const fromUpdatedBalance = sub(fromBalance, value);
    const toUpdatedBalance = add(toBalance, value);

    const updatedFromWallet = Object.assign({}, fromWallet, {
      balance: fromUpdatedBalance,
    });

    await this.walletRepository.update(updatedFromWallet, ctx);

    const updatedToWallet = Object.assign({}, toWallet, {
      balance: toUpdatedBalance,
    });

    if (newToWallet) {
      await this.walletRepository.create(updatedToWallet, ctx);
    } else {
      await this.walletRepository.update(updatedToWallet, ctx);
    }

    // Emit the Transfer event
    const transferEvent = { from, to, value: value };

    this.repo
      .refresh(
        ERC20Token as any,
        ERC20Events.TRANSFER,
        "",
        transferEvent,
        ctx as unknown as FabricContractContext
      )
      .catch((e) => log.error(`Failed to notify transfer: ${e}`));

    return true;
  }

  /**
   * Allows `spender` to spend `value` amount of tokens from the owner. New Approve calls override the previous allowance.
   * @note https://eips.ethereum.org/EIPS/eip-20
   *
   * @param {Context} ctx the transaction context
   * @param {String} spender The spender
   * @param {number} value The amount of tokens to be approved for transfer
   * @returns {Boolean} Return whether the approval was successful or not
   */
  @Transaction()
  async Approve(
    context: Context,
    spender: string,
    value: number
  ): Promise<boolean> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(context);
    const { ctx, ctxArgs } = await this.logCtx([context], this.Approve);

    const owner = ctx.identity.getID();

    let allowance = await this._getAllowance(owner, spender, ctx);

    const ownerWallet = await this.walletRepository.read(owner, ...ctxArgs);

    if (ownerWallet.balance < value) {
      throw new BalanceError(`client account ${owner} has insufficient funds.`);
    }

    if (allowance) {
      // Overwrite the allowance
      allowance.value = value;
      await this.allowanceRepository.update(allowance, ...ctxArgs);
    } else {
      allowance = new Allowance({
        owner: owner,
        spender: spender,
        value: value,
      });

      await this.allowanceRepository.create(allowance, ...ctxArgs);
    }

    // Emit the Approval event
    const approvalEvent = { owner, spender, value: value };
    this.repo.refresh(
      ERC20Token as any,
      ERC20Events.APPROVAL,
      "",
      approvalEvent,
      ctx as unknown as FabricContractContext
    );

    return true;
  }

  /**
   * Returns the amount of tokens which ` ` is allowed to withdraw from `owner`.
   *
   * @param {Context} ctx the transaction context
   * @param {String} owner The owner of tokens
   * @param {String} spender The spender who are able to transfer the tokens
   * @returns {number} Return the amount of remaining tokens allowed to spent
   */
  @Transaction(false)
  async Allowance(
    context: Context,
    owner: string,
    spender: string
  ): Promise<number> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(context);
    const { ctx } = await this.logCtx([context], this.Allowance);

    const allowance = await this._getAllowance(owner, spender, ctx);

    if (!allowance) {
      throw new AllowanceError(
        `spender ${spender} has no allowance from ${owner}`
      );
    }
    return allowance.value;
  }

  async _getAllowance(
    owner: string,
    spender: string,
    ctx: FabricContractContext
  ): Promise<Allowance> {
    const allowanceCondition = Condition.and(
      Condition.attribute<Allowance>("owner").eq(owner),
      Condition.attribute<Allowance>("spender").eq(spender)
    );

    const allowance = await this.allowanceRepository
      .select()
      .where(allowanceCondition)
      .execute(ctx);
    return allowance?.[0];
  }

  // ================== Extended Functions ==========================

  /**
   * Set optional infomation for a token.
   *
   * @param {Context} ctx the transaction context
   * @param {String} name The name of the token
   * @param {String} symbol The symbol of the token
   * @param {String} decimals The decimals of the token
   * @param {String} totalSupply The totalSupply of the token
   */
  @Transaction()
  async Initialize(context: Context, token: ERC20Token) {
    const { ctx } = await this.logCtx([context], this.Initialize);
    // Check contract options are not already set, client is not authorized to change them once intitialized
    const tokens = await this.tokenRepository.select().execute(ctx);
    if (tokens.length > 0) {
      throw new AuthorizationError(
        "contract options are already set, client is not authorized to change them"
      );
    }

    token.owner = ctx.identity.getID();

    await this.tokenRepository.create(token, ctx);

    return true;
  }

  // Checks that contract options have been already initialized
  @Transaction(false)
  async CheckInitialized(context: Context) {
    const { ctx } = await this.logCtx([context], this.CheckInitialized);
    const tokens = await this.tokenRepository.select().execute(ctx);
    if (tokens.length == 0) {
      throw new NotInitializedError(
        "contract options need to be set before calling any function, call Initialize() to initialize contract"
      );
    }
  }

  /**
   * Mint creates new tokens and adds them to minter's account balance
   *
   * @param {Context} ctx the transaction context
   * @param {number} amount amount of tokens to be minted
   * @returns {Object} The balance
   */
  @Owner()
  @Transaction()
  async Mint(context: Context, amount: number): Promise<void> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(context);

    const { ctx } = await this.logCtx([context], this.Mint);

    // Get ID of submitting client identity
    const minter = ctx.identity.getID();

    if (amount <= 0) {
      throw new ValidationError("mint amount must be a positive integer");
    }

    let minterWallet: ERC20Wallet;
    try {
      minterWallet = await this.walletRepository.read(minter, ctx);

      const currentBalance = minterWallet.balance;

      const updatedBalance = add(currentBalance, amount);

      const updatedminter = Object.assign({}, minterWallet, {
        balance: updatedBalance,
      });

      await this.walletRepository.update(updatedminter, ctx);
    } catch (e: unknown) {
      if (e instanceof BaseError) {
        if (e.code === 404) {
          // Create a new wallet for the minter
          const newWallet = new ERC20Wallet({
            id: minter,
            balance: amount,
            token: await this.TokenName(context),
          });
          await this.walletRepository.create(newWallet, ctx);
        } else {
          throw new InternalError(e.message);
        }
      } else {
        throw new InternalError(e as string);
      }
    }

    // Emit the Transfer event
    const transferEvent = { from: "0x0", to: minter, value: amount };
    const eventHandler =
      this.repo.ObserverHandler() as FabricContractRepositoryObservableHandler;
    eventHandler.updateObservers(
      ERC20Token,
      ERC20Events.TRANSFER,
      "",
      transferEvent,
      ctx as unknown as FabricContractContext
    );
  }

  /**
   * Burn redeem tokens from minter's account balance
   *
   * @param {Context} ctx the transaction context
   * @param {number} amount amount of tokens to be burned
   * @returns {Object} The balance
   */
  @Owner()
  @Transaction()
  async Burn(context: Context, amount: number): Promise<void> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(context);

    const { log, ctx } = await this.logCtx([context], this.Burn);

    const minter = ctx.identity.getID();

    const minterWallet = await this.walletRepository.read(minter, ctx);

    const currentBalance = minterWallet.balance;

    if (currentBalance < amount) {
      throw new BalanceError(`Minter has insufficient funds.`);
    }

    const updatedBalance = sub(currentBalance, amount);

    const updatedminter = Object.assign({}, minterWallet, {
      balance: updatedBalance,
    });

    await this.walletRepository.update(updatedminter, ctx);

    log.info(`${amount} tokens were burned`);

    // Emit the Transfer event
    const transferEvent = { from: minter, to: "0x0", value: amount };
    const eventHandler =
      this.repo.ObserverHandler() as FabricContractRepositoryObservableHandler;
    eventHandler.updateObservers(
      ERC20Token,
      ERC20Events.TRANSFER,
      "",
      transferEvent,
      ctx as unknown as FabricContractContext
    );
  }

  /**
   * BurnFrom redeem tokens from account allowence and balance
   *
   * @param {Context} ctx the transaction context
   * @param {number} account account from where tokens will be burned
   * @param {number} amount amount of tokens to be burned
   * @returns {Object} The balance
   */
  @Owner()
  @Transaction()
  async BurnFrom(
    context: Context,
    account: string,
    amount: number
  ): Promise<void> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(context);

    const { log, ctx } = await this.logCtx([context], this.BurnFrom);

    const accountWallet = await this.walletRepository.read(account, ctx);

    const currentBalance = accountWallet.balance;

    if (currentBalance < amount) {
      throw new BalanceError(`${account} has insufficient funds.`);
    }

    const updatedBalance = sub(currentBalance, amount);

    const updatedaccount = Object.assign({}, accountWallet, {
      balance: updatedBalance,
    });

    await this.walletRepository.update(updatedaccount, ctx);

    log.info(`${amount} tokens were burned from ${account}`);

    // Emit the Transfer event
    const transferEvent = { from: account, to: "0x0", value: amount };
    const eventHandler =
      this.repo.ObserverHandler() as FabricContractRepositoryObservableHandler;
    eventHandler.updateObservers(
      ERC20Token,
      ERC20Events.TRANSFER,
      "",
      transferEvent,
      ctx as unknown as FabricContractContext
    );
  }

  /**
   * ClientAccountBalance returns the balance of the requesting client's account.
   *
   * @param {Context} ctx the transaction context
   * @returns {Number} Returns the account balance
   */
  @Transaction(false)
  async ClientAccountBalance(ctx: Context): Promise<number> {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(ctx);

    // Get ID of submitting client identity
    const clientAccountID = ctx.clientIdentity.getID();

    const clientWallet = await this.walletRepository.read(clientAccountID, ctx);

    if (!clientWallet) {
      throw new BalanceError(`The account ${clientAccountID} does not exist`);
    }

    return clientWallet.balance;
  }

  // ClientAccountID returns the id of the requesting client's account.
  // In this implementation, the client account ID is the clientId itself.
  // Users can use this function to get their own account id, which they can then give to others as the payment address
  @Transaction(false)
  async ClientAccountID(context: Context) {
    // Check contract options are already set first to execute the function
    await this.CheckInitialized(context);
    const { ctx } = await this.logCtx([context], this.ClientAccountID);

    // Get ID of submitting client identity
    const clientAccountID = ctx.identity.getID();
    return clientAccountID;
  }
}