NEAR Blockchain
Video/Text

Part 1: Building the Fungible Token Contract

Lesson 7 Chapter 2 Module 2

Our project needs a fungible token - so first thing we are going to do is implement the NEAR Fungible Token Standard according to specification inside of a main.ts file that will be compiled and eventually deployed to our contract account.  The main.ts file will also include some utility functions and non-spec functions that extend the fungible token standard. That main.ts file will also be importing bits and pieces from three other supporting files:

  • ft-models.ts which holds all our data and storage structures; 
  • ft-errors.ts which assigns human readable error messages to constants; and
  • ft-types.ts which defines some additional types that we use in our implementation.

Let's start by creating the supporting files that main will need to reference.  Note that you can call these whatever you like, but naming convention I use is ft (fungible token) followed by a name describing the contents of the file.

ft-types.ts

Create a file under the assembly directory named types.ts.  Because Assemblyscript is based on Typescript all our variables get typed.  Thus, if we're using types that aren't part of the default set available, we need to define them.  The types.ts file should contain the following:

import { u128 } from 'near-sdk-as'

export type AccountId = string
export type AllowanceKey = string // format is Owner:Escrow
export type Amount = u128

Let's talk about these four lines.

The first line beginning with import makes the unsigned integer type u128 available from the near-sdk-as.  It is later used to export a type named Amount.  Thus anything we type as Amount is limited to a max value of 2**128 -1.  It's a huge number (340,282,366,920,938,463,463,374,607,431,768,211,455 to be exact).  That number approximates something close to the total number of cells in the human body for added context.

JSON has a limitation of max integer value of 2**53 so the token standard serializes arguments and results as Base-10 strings.  JSON serialization means to convert an object into a string and deserialization is the reverse of that - pulling the object (in this case integer) back out of the string when needed.

If we check the near-sdk-as documentation, we can track down that u128 gets imported into the sdk from as-bignum giving us some important additional functionality:

  • ability to check for zero with u128.Zero
  • safe math functions with u128.add and u128.sub (all the other math functions are also avail).  This is typically handled with a SafeMath.sol library in Ethereum development to prevent things like overflow situations that can result in security issues.
  • u128.from(10) - same as u128.from<10>(number) - returns u128 base 10 byte number (believe it's the deserialization step)

The export keyword makes these types available in other files (no different than export used in context of a react component for example).  Without export, we would not be able to use these types in our main.ts file later on.

The rest is self-explanatory - we create types with names as indicated that are of type string.  Note that we could forgo that here and simply use the string type later in main.ts, but this adds some clarity to the code.  

The // is a comment in case you are really new to coding - it's a way to explain what is happening in the code.  Anything following the // is ignored by the compiler and is just there to make things more readable/easier to understand.  You should use lots of relevant comments in any code you write.

ft-models.ts

Next up is the models.ts file - create it under the assembly directory.  Recall that the models.ts file will hold all our data types and storage structures.  Like the types we defined, we'll be importing them into main.ts to be used.

Your ft-models.ts file should contain the following:

import { u128, PersistentMap } from 'near-sdk-as'

// Data Types and Storage
export const allowanceRegistry = new PersistentMap<string, u128>(‘a’)
export const balanceRegistry = new PersistentMap<string, u128>(‘b’)
export const totalSupply = new PersistentMap<string, u128>(‘c’)

So, what's happening here...

Again, we're importing a couple things:

  • u128 - we've already seen and discussed
  • PersistentMap - acts like a map in most languages - in other words maps (or links) one thing to another.  We'll see how this data storage structure works when we put together our main.ts file; however, it's set function takes two arguments and associates the second with the first in a key, value relationship.  We can look up the key and see what value it is referring to.  They are not iterable - we have to be specific in what we're trying to find.

In this file we see the keyword const which stands for constant.  It's a variable that doesn't change.  Here we are initiating three constant map classes of the PersistentMap type:

  • allowanceRegistry - a new instance of PersistentMap with prefix of 'a'.  As storage is all in the same place, a unique prefix is always identified to prevent data collisions.  By unique, I mean that no two prefixes in storage are ever the same.  This map will store an association between the owner_id:escrow_id (key) and an amount (value).
  •  balanceRegistry - similar to allowanceRegistry - but will store an association between an owner_id (key) and their token balance (value)
  • totalSupply - will store a string 'totalSupply' and the value of the initial supply of tokens we create.  Over time, the value will increse or decrease if we decide to mint (create) or burn (destroy) tokens

ft-error-messages.ts

We want an easy way to manage error messages and ensure they clearly tell us what's happening when things go wrong.  We'll store these in our ft-error-messages.ts file created under the assembly directory.

It should have the following contents:

export const ERR_INVALID_AMOUNT = 'Amount must be greater than zero'
export const ERR_INVALID_ACCOUNT = 'Account not found in registry'
export const ERR_INVALID_ACCOUNT_ID = 'Account Id is not valid'
export const ERR_INVALID_ESCROW_ACCOUNT = 'Escrow account not found in registry'
export const ERR_INSUFFICIENT_BALANCE = 'Account does not have enough balance for this transaction'
export const ERR_INSUFFICIENT_ESCROW_BALANCE = 'Escrow account does not have enough allowance for this transaction'
export const ERR_TOKEN_ALREADY_MINTED = 'Token has previously been minted'
export const ERR_INCREMENT_ALLOWANCE_OWNER = 'Can not increment allowance for yourself'
export const ERR_DECREMENT_ALLOWANCE_OWNER = 'Can not decrement allowance for yourself'
export const ERR_DECREMENT_LESS_THAN_ZERO = 'Amount will decrease allowance amount below zero'
export const ERR_NOT_OWNER = 'You are not the owner, only owner can do this'
export const ERR_NOT_ENOUGH_TOKENS = 'You are trying to burn more tokens than you have'
export const ERR_NOT_AUTH_MODERATOR_ADD = 'This account is not authorized to add moderators'
export const ERR_NOT_AUTH_MODERATOR_REMOVE = 'This account is not authorized to remove moderators'

This one is pretty easy to explain - just creating a constant variable (note capitalization) for each type of error message we might expect to see and assigns a human readable string to it describing what's happening to throw the error.  Of course, they are all exportable with the export keyword so we can use them in other files.

main.ts

This file holds all the functionality of our Dapp.  While it's possible to set things up in multiple contract files and do cross contract calls - that requires more gas so if the project isn't super complex - one can put everything into one contract file.

Our main.ts will be split into five main sections:  

  • imports;
  • initiation function;
  • utility functions;
  • the fungible token standard functions; and
  • non-spec functions that extend the fungible token standard.

Section 1:  Imports

Let's import everything we're going to need.  Put this at the top of your main.ts file created in the assembly directory.

// @nearfile
import { Context, storage, logging, env, u128 } from "near-sdk-as";
import { AccountId, Amount } from './ft-types'
import { totalSupply, allowanceRegistry, balanceRegistry } from './ft-models'

import {
ERR_INVALID_AMOUNT,
ERR_INVALID_ACCOUNT,
ERR_INVALID_ACCOUNT_ID,
ERR_INVALID_ESCROW_ACCOUNT,
ERR_INSUFFICIENT_BALANCE,
ERR_INSUFFICIENT_ESCROW_BALANCE,
ERR_INCREMENT_ALLOWANCE_OWNER,
ERR_DECREMENT_ALLOWANCE_OWNER,
ERR_DECREMENT_LESS_THAN_ZERO,
ERR_TOKEN_ALREADY_MINTED,
ERR_NOT_OWNER,
ERR_NOT_ENOUGH_TOKENS
} from './ft-error-messages'

By now, I don't think I need to reiterate what the import statements are doing but I will go over a couple of the things being imported from near-sdk-as.  You should get to know how to search through the near-sdk-as as it will tell you exactly what these objects provide you.

Context 

The Context object provides context for contract execution including information about the transaction sender, blockchain height, attached deposit and so on.  It's worthwhile listing out some of what Context provides you here for your situational awareness (check the sdk for everything avail):

  • Context.sender - is a string of the account ID that sent the transaction that led to this execution (aka signer account ID) - typically the owner of the contract, but not always.
  • Context.predecessor -  is a string of the account ID of the account that signed the transaction if it was called from the transaction.  Typically another contract in the case of a cross-contract call.
  • Context.contractName - is a string of the account ID of the current contract being executed - in other words the name of your contract account (aka current account id)
  • Context.blockIndex - is a u64 of the current block index (aka height)
  • Context.storageUsage - is a u64 detailing the size of contract account storage used before the contract execution
  • Context.attachedDeposit - is a u128 providing the balance that was attached to the call taht will be immediately deposited before contract execution starts
  • Context.accountBalance - is a u128 providing balance attached to the account.  It excludes any 'attachedDeposit' that may have been attached to the transaction
  • Context.prepaidGas - is a u64 detailing the gas attached to the call and available to pay for the gas fees
  • Context.usedGas - is a u64 detailing the gas amount that was irreversibly used for contract execution (aka burnt gas) + gas attached to any promises (cannot exceed prepaidGas)

Context.sender vs Context.predecessor

Context.predecessor is used extensively in this file and the difference between Context.sender and Context.predecessor can be a bit confusing (at least it was to me).  Willem from NEAR helped clarify it for me with this note:


Predecessor was the account that made the last function call transaction, whereas sender is the first. So in a chain of promise calls, sender (which if you look at the code is actually calling the host function for signer) is the one that signed the initial transaction. So consider Bob, Alice, and Charlie. Bob calls alice, in this context Bob is both. Then in that call Alice makes a promise and calls Charlie. In this context Bob is still the signer but Alice is the predecessor.

Storage

The Storage object is a key-value store for the contract (used by PersistentMap, PersistentVector, PersistentDeque, PersistentSet).  Anytime we need to save some kind of state to the blockchain, we need to interact with the storage object.

The keys here are strings and the values can be multiple types: bool, integer, string, and data objects that are defined in the corresponding model file (in our case ft-models.ts)

All blockchain data uses this interface.

Logging

The logging object lets us write things to the logs - simple as that and super helpful when we're trying to monitor what's going on in the contract.  Some well place logging statements can help us monitor successful or failed completion of the various functions in the contract.

Env

The env object holds lots of variables and methods that describe the environment the contract is operating in.  

Asserts

The other thing you're going to see a lot of as we build out this file are the assert() statements like assert(balanceRegistry.contains(owner_id), ERR_INVALID_ACCOUNT).  If you're coming from Solidity - these serve the same function as require statements.  They test something and if true, allow the code to continue. If false, they return the second part of the statement (the error message) and abort.  The assert function comes from the AssemblyScript environment and they are very useful with respect to ensuring certain conditions are met before things happen.

Section 2 - Init Function

/**
* Init function that creates a new fungible token and initial supply
* @param name
* @param symbol
* @param precision
* @param initialSupply
*/
export function init(name: string, symbol: string, precision: u8, initialSupply: u128): void {
logging.log("initialOwner: " + Context.predecessor);
assert(storage.get("init") == null, ERR_TOKEN_ALREADY_MINTED);

//set Token Name
storage.set<string>(“tokenName”, name)

//set Token Symbol
storage.set<string>(“tokenSymbol”, symbol)

//set Precision
storage.set<u8>(“precision”, precision)

//set Total Supply
totalSupply.set(‘totalSupply’, initialSupply);

// assign total initial supply to owner’s balance
balanceRegistry.set(Context.predecessor, initialSupply);

//set contract owner
storage.set<string>(“owner”, Context.predecessor);

//set init to done
storage.set<string>(“init”, “done”)

}

In plain English - this function is called once by the token creator and soon-to-be token owner.  It creates the token by giving it a name, symbol, precision, and initial total supply.  These values get stored in the blockchain storage and a flag called init is marked as done.  As there is an assert at the start of the function looking to see if init is null - it it isn't (because the token has already been created) it aborts with the token already created error message.

Most of this is self-explanatory and note the storage.set statements that match a key (first string argument) with a value of the type identified in the <>. Recall that the types for balanceRegistry and totalSupply were set in the ft-types.ts file.

Precision refers to a number of decimals the token uses.  For example, a precision of 8 means to divide the token amount by 100000000 to get its user representation. 

So what precision should you specify?  A good way to think about it is as follows.

tokenSupply = tokensIActuallyWant * (10 ^ decimals)

So if I want one token with the ability to subdivide it with a precision of two decimal places, tokenSupply needs to be 100. (100 = 1 * 10^2) 

NEAR's smallest divisible part of a token is a yocto at 24 decimal places.  So if we were the NEAR folks and we wanted to mint 1 billion NEAR - we'd need a initial token supply of 1000000000 x 10^24 which is 1 x 10^333 (1 with 33 zeros) - (1 x 10^33 = 1000000000 * 10 ^24)

Section 3 - Utility Functions

Utility functions are not part of the Fungible Token Standard, but are helper functions that serve a utility purpose in the contract.  We're going to add two:

  • one that formats a key we use to look up allowances in allowanceRegistry; and
  • one that checks that some account Id is the owner

Put the following code after the init function in your main.ts file.

/*********************/
/* UTILITY FUNCTIONS */
/*********************/

/**
* Generate a consistent key format for looking up which 'owner_id' has given
* an 'escrow_id' some 'allowance' to transfer on their behalf
* @param owner_id
* @param escrow_id
*/

export function keyFrom(owner_id: AccountId, escrow_id: AccountId): string {
return owner_id + ":" + escrow_id
}

/**
* Returns the owner which we use in multiple places to confirm user has access to
* do whatever they are trying to do. Some things like minting, burning and so on
* should only be done by the owner
* @param owner
*/
export function isOwner(owner: AccountId): boolean {
return owner == storage.get("owner");
}

export function keyFrom...

We want a way to associate an owner_id with accounts they have designated as escrow accounts.  One way to do this is to make the key in the allowanceRegistry show that relationship by formatting it as owner_id:escrow_id.  That's what this function does.

After two assert functions that ensure the owner_id and escrow_id are valid account Ids, it concats (combines) them together with a : in the middle and returns the new string to be used as a key in allowanceRegistry or elsewhere as required.

export function isOwner...

There are some functions that we want to mark in such a way so as to ensure only the owner can access them.  This isOwner utility function compares the account Id passed into it to the owner saved in storage to see if they are the same.  If it is the owner, true is returned, otherwise false.  Before doing the comparison we do an assert to ensure the account Id passed in as the owner is a valid account Id.

We're going to take a quick break here.  The next two sections are long enough to be covered in lessons by themselves.  See you back in a bit where we will add the functions of the fungible token standard.

Pen
>