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 pattern | Value | Example |
|---|---|---|
| bal:ADDRESS | Balance (string bigint) | bal:0xabc… → "1000000" |
| allow:OWNER:SPENDER | Approved spending limit | allow:0xabc…:0xdef… → "500" |
| name | Token name | "AsentumToken" |
| symbol | Token symbol | "ATK" |
| decimals | Decimal places | "18" |
| totalSupply | Current total supply | "1000000000" |
| owner | Deployer 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
Transferevent 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