Standards/ARC-20

ARC-20: Fungible Token Standard.

A minimal interface for fungible tokens on AsentumChain. Familiar to anyone who has touched ERC-20, adapted to the AsentumChain JavaScript VM.

NumberARC-20
TitleFungible Token Standard
StatusDraft
TypeStandards Track
CategoryARC (Application)
Created2026-05-16
Requires(none)

Abstract

This document specifies the standard interface for fungible tokens on AsentumChain. It defines a small set of view methods (name, symbol, decimals, totalSupply, balanceOf, allowance) and four write methods (transfer, approve, transferFrom, plus a deploy-time init) that any wallet, exchange, indexer, or dapp can rely on without negotiating per-token shape.

Motivation

ASE is the native token of AsentumChain, but the chain needs to support arbitrary fungible tokens too. Stablecoins. Bridged ERC-20s. DAO governance tokens. Reward points. Without a shared standard, every wallet and every explorer has to special-case every token, and dapp authors cannot assume their token will work with the next integration.

ERC-20 solved this problem for Ethereum a decade ago, and the design survived contact with reality. ARC-20 keeps the same surface so that any engineer who has shipped on Ethereum can ship on AsentumChain with no cognitive shift, while adapting the concrete shape to the AsentumChain JavaScript VM (string-based numeric ABI, event payloads as objects, JS-native method dispatch).

Specification

A compliant contract MUST expose the methods and emit the events described below. The keywords MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119.

Methods

All numeric arguments and return values are decimal strings unless stated otherwise. This matches the AsentumChain RPC convention where uint256 values cross the JSON boundary as base-10 strings to avoid precision loss in JavaScript number representation.

name()

Returns the token name, e.g. "Asentum USD". Set once at deploy time, never changes.

symbol()

Returns the token symbol, e.g. "aUSD". Set once at deploy time, never changes.

decimals()

Returns the number of decimal places used for display. Stored as a decimal string. Most tokens use "18" to match ASE; stablecoins commonly use "6".

totalSupply()

Returns the total number of token units currently in circulation, as a decimal string. Increases on mint, decreases on burn.

balanceOf(address) -> string

Returns the balance of the given address as a decimal string. Addresses with no balance return "0", never null.

transfer(to, amount) -> bool

Moves amount from msg.sender to to. Throws on insufficient balance or transfer to the zero address. MUST emit Transfer({ from, to, value }). Returns true on success.

approve(spender, amount) -> bool

Sets the spender’s allowance over msg.sender’s tokens to amount. Overwrites any prior allowance. MUST emit Approval({ owner, spender, value }). Returns true.

allowance(owner, spender) -> string

Returns the remaining number of tokens spender can move on owner’s behalf, as a decimal string.

transferFrom(from, to, amount) -> bool

Moves amount from from to to using the allowance mechanism. Throws on insufficient allowance, insufficient balance, or transfer to the zero address. MUST decrement the allowance and emit both an Approval (with the new allowance value) and a Transfer event. Returns true.

Events

Transfer{ from, to, value }

Emitted on every successful transfer. value is a decimal string. Minting MUST emit Transfer with from = 0x0; burning MUST emit Transfer with to = 0x0.

Approval{ owner, spender, value }

Emitted on every approve and on every successful transferFrom (with the post-decrement allowance value). value is a decimal string.

Storage conventions

Implementations SHOULD use the following storage key layout so that off-chain indexers can read state directly without invoking view methods on every block:

meta:name                       → token name
meta:symbol                     → token symbol
meta:decimals                   → decimal places (decimal string)
meta:totalSupply                → circulating supply (decimal string)
balance:<addr>                  → holder balance (decimal string)
allowance:<owner>:<spender>     → allowance (decimal string)

Addresses MUST be lowercase 0x-prefixed 40-character hex strings when used as key components. Decimal string values MUST NOT contain leading zeros (except for the value "0" itself) and MUST NOT contain a sign, decimal point, or thousands separator.

Rationale

Why decimal strings, not numbers

JavaScript native numbers cannot represent integers above 253 without loss of precision. Token balances commonly exceed that. Encoding amounts as decimal strings at the ABI boundary lets both ends (chain VM and any client) reconstruct the exact bigint without ambiguity.

Why an explicit init method

AsentumChain contracts do not have a Solidity-style constructor. Deploy uploads the source, and the deployer separately calls init to set initial metadata and mint the initial supply. init MUST be idempotent in the sense that it errors on a second call, so a token cannot be silently re-initialised by an attacker who reads the deployer's tx.

Why decrement allowance before transfer in transferFrom

The allowance MUST be decremented before the transfer is executed, and an Approval event with the new value MUST be emitted in the same call. This makes off-chain indexers' allowance state strictly correct at every block boundary, and it matches the more conservative interpretation of ERC-20 that later proposals (EIP-2612, EIP-3009) standardised on.

Why Transfer events for mint and burn

Treating mint as Transfer(0x0, recipient, value) and burn as Transfer(holder, 0x0, value) means an indexer only has to listen to one event type to compute supply, balances, and holders. Explorers and wallets get unified visibility into the token's economic history.

Reference implementation

The following implementation is dual-licensed (Apache 2.0 / MIT) and passes the test cases below. It uses only the standard AsentumChain VM globals (storage, msg, chain, emit, assert) and has no external dependencies.

// ARC-20 reference implementation
// Asentum Request for Comment 20: Fungible Token Standard
//
// Storage layout:
//   meta:name                          → string
//   meta:symbol                        → string
//   meta:decimals                      → string (uint8 as decimal)
//   meta:totalSupply                   → string (uint256 as decimal)
//   balance:<addr>                     → string (uint256 as decimal)
//   allowance:<owner>:<spender>        → string (uint256 as decimal)

const NAME_KEY = 'meta:name';
const SYMBOL_KEY = 'meta:symbol';
const DECIMALS_KEY = 'meta:decimals';
const TOTAL_SUPPLY_KEY = 'meta:totalSupply';
const BALANCE_PREFIX = 'balance:';
const ALLOWANCE_PREFIX = 'allowance:';
const ZERO = '0x0000000000000000000000000000000000000000';

({
  // ---- One-shot initialiser. Call once at deploy time. -----------------

  init(name, symbol, decimals, initialSupply) {
    assert(!storage.get(NAME_KEY), 'already initialised');
    assert(typeof name === 'string' && name.length > 0, 'name required');
    assert(typeof symbol === 'string' && symbol.length > 0, 'symbol required');
    const dec = BigInt(decimals);
    assert(dec >= 0n && dec <= 38n, 'decimals out of range');
    const supply = BigInt(initialSupply);
    assert(supply >= 0n, 'initialSupply must be non-negative');

    const sender = String(msg.sender).toLowerCase();
    storage.set(NAME_KEY, name);
    storage.set(SYMBOL_KEY, symbol);
    storage.set(DECIMALS_KEY, dec.toString());
    storage.set(TOTAL_SUPPLY_KEY, supply.toString());
    storage.set(BALANCE_PREFIX + sender, supply.toString());

    emit('Transfer', { from: ZERO, to: sender, value: supply.toString() });
    return true;
  },

  // ---- Metadata views --------------------------------------------------

  name()        { return storage.get(NAME_KEY) || ''; },
  symbol()      { return storage.get(SYMBOL_KEY) || ''; },
  decimals()    { return storage.get(DECIMALS_KEY) || '0'; },
  totalSupply() { return storage.get(TOTAL_SUPPLY_KEY) || '0'; },

  // ---- Balance + allowance views --------------------------------------

  balanceOf(addr) {
    return storage.get(BALANCE_PREFIX + String(addr).toLowerCase()) || '0';
  },
  allowance(owner, spender) {
    return storage.get(
      ALLOWANCE_PREFIX + String(owner).toLowerCase() + ':' + String(spender).toLowerCase(),
    ) || '0';
  },

  // ---- Transfers -------------------------------------------------------

  transfer(to, amount) {
    const from = String(msg.sender).toLowerCase();
    const recipient = String(to).toLowerCase();
    assert(recipient !== ZERO, 'transfer to zero address');
    const amt = BigInt(amount);
    assert(amt >= 0n, 'amount must be non-negative');
    const fromBal = BigInt(storage.get(BALANCE_PREFIX + from) || '0');
    assert(fromBal >= amt, 'insufficient balance');
    const toBal = BigInt(storage.get(BALANCE_PREFIX + recipient) || '0');
    storage.set(BALANCE_PREFIX + from, (fromBal - amt).toString());
    storage.set(BALANCE_PREFIX + recipient, (toBal + amt).toString());
    emit('Transfer', { from, to: recipient, value: amt.toString() });
    return true;
  },

  // ---- Allowance flow --------------------------------------------------

  approve(spender, amount) {
    const owner = String(msg.sender).toLowerCase();
    const sp = String(spender).toLowerCase();
    const amt = BigInt(amount);
    assert(amt >= 0n, 'amount must be non-negative');
    storage.set(ALLOWANCE_PREFIX + owner + ':' + sp, amt.toString());
    emit('Approval', { owner, spender: sp, value: amt.toString() });
    return true;
  },

  transferFrom(from, to, amount) {
    const owner = String(from).toLowerCase();
    const spender = String(msg.sender).toLowerCase();
    const recipient = String(to).toLowerCase();
    assert(recipient !== ZERO, 'transfer to zero address');
    const amt = BigInt(amount);
    assert(amt >= 0n, 'amount must be non-negative');

    const allowKey = ALLOWANCE_PREFIX + owner + ':' + spender;
    const allowed = BigInt(storage.get(allowKey) || '0');
    assert(allowed >= amt, 'allowance exceeded');

    const fromBal = BigInt(storage.get(BALANCE_PREFIX + owner) || '0');
    assert(fromBal >= amt, 'insufficient balance');

    const newAllow = allowed - amt;
    storage.set(allowKey, newAllow.toString());
    emit('Approval', { owner, spender, value: newAllow.toString() });

    const toBal = BigInt(storage.get(BALANCE_PREFIX + recipient) || '0');
    storage.set(BALANCE_PREFIX + owner, (fromBal - amt).toString());
    storage.set(BALANCE_PREFIX + recipient, (toBal + amt).toString());
    emit('Transfer', { from: owner, to: recipient, value: amt.toString() });
    return true;
  },
})

Test cases

A compliant implementation MUST pass at minimum the following behavioural tests. The reference implementation above passes all of them.

  1. After init("Asentum USD", "aUSD", 6, "1000000000000") by address A,balanceOf(A) === "1000000000000" and totalSupply() === "1000000000000".
  2. A second call to init MUST throw with message 'already initialised'.
  3. transfer(B, "100") by A emits exactly one Transfer({ from: A, to: B, value: "100" }) and updates both balances.
  4. transfer(0x0, "1") MUST throw.
  5. transfer(B, "9999999...") by an account with smaller balance MUST throw with message 'insufficient balance'.
  6. approve(B, "500") by A emits Approval({ owner: A, spender: B, value: "500" }) and allowance(A, B) === "500".
  7. transferFrom(A, C, "200") called by B (with allowance 500) emits both an Approval event with value: "300" and a Transfer event, and leaves allowance(A, B) === "300".
  8. transferFrom(A, C, "400") by B against a remaining allowance of 300 MUST throw with message 'allowance exceeded'.

Security considerations

The approve race condition

ARC-20 inherits ERC-20's well-known approve race: changing a non-zero allowance to a different non-zero value lets a malicious spender, in the gap between the two transactions, spend both the old and the new allowance. Users SHOULD set the allowance to zero before setting a new non-zero value, or use the increaseAllowance / decreaseAllowance pattern in an extension contract. Wallets MAY warn the user before a non-zero re-approval.

Decimal exposure

decimals() is informational. It is the wallet and explorer's job to divide raw balances by 10**decimals for display. Contracts MUST NOT rely on decimals() for internal accounting; balances are integer amounts in the smallest unit.

Reentrancy

ARC-20 itself does not call into untrusted contracts during transfer. Implementations SHOULD avoid adding such hooks (the "ERC-777 problem") without explicit user-facing opt-in. The AsentumChain VM's cross-contract E() primitive provides a reentrancy guard at the call-stack level, but economic invariants in token logic should still be checked before any external call.

Backwards compatibility

ARC-20 is the first fungible-token standard on AsentumChain; there is no prior version to be compatible with. Tokens deployed before this spec was finalised SHOULD be migrated by deploying a fresh ARC-20 contract and minting balances to match.

ARC-20 is in Draft. Feedback welcome.