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
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.
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