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