Example Simple Token

Using the knowledge from Developing your own contract let us showcase a really simple token contract and walk you through it.

First lets define which functionality our simple token has

  • mint

  • transfer

  • balance

mint and transfer should be mutations; and balance should be a query to check it via an API call anytime you want

Security considerations

Now is a good moment to think about security regarding the 3 functions

mint only possible to mint once

transfer you must not be able to transfer more than you have

balance what do we return if the user has not yet interacted with the contract, has no balance

This is what a final contract could look like, making use of most APIs. Lets go over it

import { Contract, ContractParams } from "./types/contract.ts";
import { zUtils } from "./utils/zod.ts";
import { z } from "zod";
import { argsParsing } from "./utils/args-parsing.ts";
import { ExecutionError } from "./types/execution-error.ts";

export default class SimpleToken implements Contract {
  activeOn = 850000;

  private _alreadyMinted = false;
  private _balances = new Map<string, bigint>();

  mint({ metadata, args, eventLogger }: ContractParams) {
    const schema = z.tuple([zUtils.bigint()]);
    const [supply] = argsParsing(schema, args, "mint");

    // safety check to make sure you can only mint once
    if (this._alreadyMinted) throw new ExecutionError("mint: already minted");
    
    // mint the tokens
    this._balances.set(metadata.sender, supply);
    
    // make sure you can only mint once
    this._alreadyMinted = true;

    eventLogger.log({
      type: "MINT",
      message: `${metadata.sender} minted ${supply}`,
    });
  }

  transfer({ metadata, args, eventLogger }: ContractParams) {
    const schema = z.tuple([z.string(), zUtils.bigint()]);
    const [to, value] = argsParsing(schema, args, "transfer");

    // must not send more than you have
    const fromBalance = this._balances.get(metadata.sender) ?? 0n;
    if (fromBalance < value) {
      throw new ExecutionError("transfer: not enough balance");
    }

    // update balances
    this._balances.set(metadata.sender, fromBalance - value);
    const toBeforeBalance = this._balances.get(to) ?? 0n;
    this._balances.set(to, toBeforeBalance + value);

    eventLogger.log({
      type: "TRANSFER",
      message: `transferred from ${
        metadata.sender
      } to ${to} value ${value.toString()}`,
    });
  }

  balance({ args }: ContractParams): bigint {
    const schema = z.tuple([z.string()]);
    const [from] = argsParsing(schema, args, "balance");
    return this._balances.get(from) ?? 0n;
  }
}

We follow the Contract architecture by having mutations and queries. activeOn: 850000 makes the contract active at block 850000, this must be in the future.

Lets start with the properties

Properties

private _alreadyMinted = false;
private _balances = new Map<string, bigint>();

_alreadyMinted keeps track if we have already minted, to make sure we can only do it once

_balances maps the wallet / contract to a balance. We use bigint for this to handle big numbers

now mutations

Mint

mint({ metadata, args, eventLogger }: ContractParams) {

for minting we use metadata, args and eventLogger from the parameters we get injected.

const schema = z.tuple([zUtils.bigint()]);
const [supply] = argsParsing(schema, args, "mint");

this is basically a must, validate the arguments, otherwise you could run into strange problems later on. We prefer using zod for this and built the argsParsing utility to help you do this

// safety check to make sure you can only mint once
if (this._alreadyMinted) throw new ExecutionError("mint: already minted");

This is the security consideration from above, we check the state of the contract to make sure we didn't mint yet

// mint the tokens
this._balances.set(metadata.sender, supply);

only if it is not yet minted, we use the validated arguments and save the amount to the the balances map.

// make sure you can only mint once
this._alreadyMinted = true;

Don't forget to store the minted value, otherwise the earlier check is always false and we can mint more than once.

eventLogger.log({
    type: "MINT",
    message: `${metadata.sender} minted ${supply}`,
});

Now for good practice, log the mint event to be visible later to other users checking the transaction

That's it for the minting part. Really simple

Transfer

const schema = z.tuple([z.string(), zUtils.bigint()]);
const [to, value] = argsParsing(schema, args, "transfer");

same as for the mint, we validate the arguments and name them

// must not send more than you have
const fromBalance = this._balances.get(metadata.sender) ?? 0n;
if (fromBalance < value) {
  throw new ExecutionError("transfer: not enough balance");
}

this is the security consideration from above, we must not transfer more than we have. Therefore we check our balances map for the sender. Fallback is just 0. If the balance is smaller than the value we are trying to send, we throw an ExecutionError

// update balances
this._balances.set(metadata.sender, fromBalance - value);
const toBeforeBalance = this._balances.get(to) ?? 0n;
this._balances.set(to, toBeforeBalance + value);

after this check is successful, it is only a matter of updating the balances for sender and receiver

eventLogger.log({
  type: "TRANSFER",
  message: `transferred from ${
    metadata.sender
  } to ${to} value ${value.toString()}`,
});

For good measure, we log again what happened

Simple as this! 👍

Only thing left is the balance query

Balance

balance({ args }: ContractParams): bigint {
    const schema = z.tuple([z.string()]);
    const [from] = argsParsing(schema, args, "balance");
    return this._balances.get(from) ?? 0n;
}

here we only need the args to read the balance from the balance map and return it

Last updated