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:0xabc… → "1000000"
allow:OWNER:SPENDERApproved spending limitallow:0xabc…:0xdef… → "500"
nameToken name"AsentumToken"
symbolToken symbol"ATK"
decimalsDecimal places"18"
totalSupplyCurrent total supply"1000000000"
ownerDeployer address"0xabc…"

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', ['0xAlice', '1000']);

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

  // Transfer 300 from Alice to Bob
  await token.callAs('0xAlice', 'transfer', ['0xBob', '300']);

  expect(await token.view('balanceOf', ['0xAlice'])).toBe('700');
  expect(await token.view('balanceOf', ['0xBob'])).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', ['0xAlice', '1000']);

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

  // Bob transfers 200 of Alice's tokens to Carol
  await token.callAs('0xBob', 'transferFrom', ['0xAlice', '0xCarol', '200']);
  expect(await token.view('balanceOf', ['0xCarol'])).toBe('200');
  expect(await token.view('allowance', ['0xAlice', '0xBob'])).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://testnet.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', ['0x...']);

// Write (costs gas — needs a connected wallet)
await (await token.send('transfer', ['0xRecipient...', '500'])).wait();
await (await token.send('approve', ['0xSpender...', '1000'])).wait();

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

Viewing on the explorer

Navigate to testnet.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