Asentum

Build on Asentum

Build a Token Contract

End-to-end tutorial · Estimated read time: 15 minutes

What we're building

A fully functional fungible token — the Asentum equivalent of an ERC-20. By the end of this guide you'll have a token contract deployed on the testnet with mint, transfer, approve, transferFrom, and all the standard view methods. Plain JavaScript, no compiler, no Solidity.

Prerequisites: you should have read Writing Contracts and have the SDK installed (npm i @asentum/sdk).

The contract

Here's the complete token contract. We'll break down every function below.

// AsentumToken — ARC-20 compatible fungible token.

function init(name, symbol, decimals) {
  assert(!storage.has('initialized'), 'already initialized');
  storage.set('initialized', 'true');
  storage.set('owner', msg.sender);
  storage.set('name', name || 'AsentumToken');
  storage.set('symbol', symbol || 'ATK');
  storage.set('decimals', String(decimals ?? 18));
  storage.set('totalSupply', '0');
}

function mint(to, amount) {
  assert(msg.sender === storage.get('owner'), 'only owner can mint');
  const amt = BigInt(amount);
  assert(amt > 0n, 'amount must be positive');
  const bal = BigInt(storage.get('bal:' + to) || '0');
  storage.set('bal:' + to, String(bal + amt));
  const supply = BigInt(storage.get('totalSupply'));
  storage.set('totalSupply', String(supply + amt));
  emit('Transfer', { from: '0x' + '0'.repeat(40), to, amount: String(amt) });
}

function transfer(to, amount) {
  const sender = msg.sender;
  const amt = BigInt(amount);
  assert(amt > 0n, 'amount must be positive');
  const senderBal = BigInt(storage.get('bal:' + sender) || '0');
  assert(senderBal >= amt, 'insufficient balance');
  storage.set('bal:' + sender, String(senderBal - amt));
  const toBal = BigInt(storage.get('bal:' + to) || '0');
  storage.set('bal:' + to, String(toBal + amt));
  emit('Transfer', { from: sender, to, amount: String(amt) });
  return true;
}

function approve(spender, amount) {
  storage.set('allow:' + msg.sender + ':' + spender, String(BigInt(amount)));
  emit('Approval', { owner: msg.sender, spender, amount: String(BigInt(amount)) });
  return true;
}

function transferFrom(from, to, amount) {
  const amt = BigInt(amount);
  const allowed = BigInt(storage.get('allow:' + from + ':' + msg.sender) || '0');
  assert(allowed >= amt, 'allowance exceeded');
  const fromBal = BigInt(storage.get('bal:' + from) || '0');
  assert(fromBal >= amt, 'insufficient balance');
  storage.set('bal:' + from, String(fromBal - amt));
  const toBal = BigInt(storage.get('bal:' + to) || '0');
  storage.set('bal:' + to, String(toBal + amt));
  storage.set('allow:' + from + ':' + msg.sender, String(allowed - amt));
  emit('Transfer', { from, to, amount: String(amt) });
  return true;
}

// ─── View methods (free, no gas) ───
function balanceOf(address)          { return storage.get('bal:' + address) || '0'; }
function allowance(owner, spender)   { return storage.get('allow:' + owner + ':' + spender) || '0'; }
function name()                      { return storage.get('name'); }
function symbol()                    { return storage.get('symbol'); }
function decimals()                  { return Number(storage.get('decimals')); }
function totalSupply()               { return storage.get('totalSupply'); }

Storage patterns

Asentum contracts have a flat key-value store. Token contracts use prefixed keys to namespace different types of data:

Key patternValueExample
bal:ADDRESSBalance (string bigint)bal:<address> → "1000000"
allow:OWNER:SPENDERApproved spending limitallow:<owner>:<spender> → "500"
nameToken name"AsentumToken"
symbolToken symbol"ATK"
decimalsDecimal places"18"
totalSupplyCurrent total supply"1000000000"
ownerDeployer address<deployer address>

Always use BigInt for amounts. JavaScript Numbers lose precision above 253. Token balances in wei routinely exceed that. Store as strings, compute as BigInt.

Events

Two events, same as ERC-20:

  • Transfer { from, to, amount } — emitted on every mint, transfer, and transferFrom.
  • Approval { owner, spender, amount } — emitted on every approve.

These are indexable via eth_getLogs and visible in the explorer's transaction detail.

Testing locally

import { test, expect } from 'vitest';
import { createVm } from '@asentum/vm';
import { readFileSync } from 'node:fs';

const source = readFileSync('./contracts/token.js', 'utf8');

test('mint and transfer', async () => {
  const vm = createVm();
  const token = await vm.deploy(source);

  await token.call('init', ['TestToken', 'TST', 18]);
  await token.call('mint', ['ase1alice', '1000']);

  expect(await token.view('balanceOf', ['ase1alice'])).toBe('1000');
  expect(await token.view('totalSupply')).toBe('1000');

  // Transfer 300 from Alice to Bob
  await token.callAs('ase1alice', 'transfer', ['ase1bob', '300']);

  expect(await token.view('balanceOf', ['ase1alice'])).toBe('700');
  expect(await token.view('balanceOf', ['ase1bob'])).toBe('300');
});

test('approve + transferFrom', async () => {
  const vm = createVm();
  const token = await vm.deploy(source);

  await token.call('init', ['TestToken', 'TST', 18]);
  await token.call('mint', ['ase1alice', '1000']);

  // Alice approves Bob to spend 500
  await token.callAs('ase1alice', 'approve', ['ase1bob', '500']);
  expect(await token.view('allowance', ['ase1alice', 'ase1bob'])).toBe('500');

  // Bob transfers 200 of Alice's tokens to Carol
  await token.callAs('ase1bob', 'transferFrom', ['ase1alice', 'ase1carol', '200']);
  expect(await token.view('balanceOf', ['ase1carol'])).toBe('200');
  expect(await token.view('allowance', ['ase1alice', 'ase1bob'])).toBe('300');
});

Run with pnpm vitest. Tests run in-process — no network, millisecond feedback. See Testing Contracts.

Deploying to testnet

import { AsentumClient, AsentumWallet } from '@asentum/sdk';
import { readFileSync } from 'node:fs';

const client = new AsentumClient('https://explorer.asentum.com');
const wallet = AsentumWallet.create(client);
await wallet.requestFaucet();

const source = readFileSync('./contracts/token.js', 'utf8');
const token = await wallet.deploy(source);
console.log('deployed at:', token.address);

// Initialize
await (await token.send('init', ['MyToken', 'MTK', 18])).wait();

// Mint 1,000,000 tokens to yourself
const amount = (1_000_000n * 10n ** 18n).toString();
await (await token.send('mint', [wallet.address, amount])).wait();

console.log('balance:', await token.view('balanceOf', [wallet.address]));

Interacting with your token

Once deployed, anyone can interact with it:

// Read (free — no gas, no signing)
const name   = await token.view('name');
const supply = await token.view('totalSupply');
const bal    = await token.view('balanceOf', ['ase1…']);

// Write (costs gas — needs a connected wallet)
await (await token.send('transfer', ['ase1…recipient', '500'])).wait();
await (await token.send('approve', ['ase1…spender', '1000'])).wait();

You can also interact from the IDE — paste your contract address, connect a wallet, and call any method from the browser.

Viewing on the explorer

Navigate to explorer.asentum.com/address/YOUR_CONTRACT_ADDRESS. You'll see:

  • The full JavaScript source, syntax-highlighted.
  • A Read panel — call name(), balanceOf(), etc. for free.
  • A Write panel — send transactions via your connected wallet.
  • Every transfer shows as a Transfer event in the transaction detail. No bytecode decoding needed — it's plain JSON.

Verification is a single BLAKE3 hash compare — see Verifying Contracts.

Next in the series