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";exportdefaultclassSimpleTokenimplementsContract { activeOn =850000;private _alreadyMinted =false;private _balances =newMap<string,bigint>();mint({ metadata, args, eventLogger }:ContractParams) {constschema=z.tuple([zUtils.bigint()]);const [supply] =argsParsing(schema, args,"mint");// safety check to make sure you can only mint onceif (this._alreadyMinted) thrownewExecutionError("mint: already minted");// mint the tokensthis._balances.set(metadata.sender, supply);// make sure you can only mint oncethis._alreadyMinted =true;eventLogger.log({ type:"MINT", message:`${metadata.sender} minted ${supply}`, }); }transfer({ metadata, args, eventLogger }:ContractParams) {constschema=z.tuple([z.string(),zUtils.bigint()]);const [to,value] =argsParsing(schema, args,"transfer");// must not send more than you haveconstfromBalance=this._balances.get(metadata.sender) ??0n;if (fromBalance < value) {thrownewExecutionError("transfer: not enough balance"); }// update balancesthis._balances.set(metadata.sender, fromBalance - value);consttoBeforeBalance=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 {constschema=z.tuple([z.string()]);const [from] =argsParsing(schema, args,"balance");returnthis._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.
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 onceif (this._alreadyMinted) thrownewExecutionError("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 tokensthis._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 oncethis._alreadyMinted =true;
Don't forget to store the minted value, otherwise the earlier check is always false and we can mint more than once.
same as for the mint, we validate the arguments and name them
// must not send more than you haveconstfromBalance=this._balances.get(metadata.sender) ??0n;if (fromBalance < value) {thrownewExecutionError("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