ARC-20: Fungible Token Standard.Draft

A minimal interface for fungible tokens on ValdiumChain. Familiar to anyone who has touched ERC-20, adapted to the ValdiumChain 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 ValdiumChain. 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

VLD is the native token of ValdiumChain, 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 ValdiumChain with no cognitive shift, while adapting the concrete shape to the ValdiumChain 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.

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

Methods

name() → string
Returns the token name, e.g. "Valdium USD". Set once at deploy time, never changes.
symbol() → string
Returns the token symbol, e.g. "vUSD". Set once at deploy time, never changes.
decimals() → string
Returns the number of decimal places used for display. Most tokens use "18" to match VLD; stablecoins commonly use "6".
totalSupply() → string
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.

Rationale

Why decimal strings, not numbers. JavaScript native numbers cannot represent integers above 2⁵³ without loss of precision. Token balances commonly exceed that. Encoding amounts as decimal strings at the ABI boundary lets both ends reconstruct the exact bigint without ambiguity.

Why an explicit init method. ValdiumChain 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 error on a second call, so a token cannot be silently re-initialised by an attacker.

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.

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.

Reference implementation

The following implementation is dual-licensed (Apache 2.0 / MIT) and passes the test cases below.

// ARC-20 reference implementation
// Valdium Request for Comment 20: Fungible Token Standard

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';

({
  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;
  },

  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'; },

  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';
  },

  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;
  },

  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:

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 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.

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.

Reentrancy. ARC-20 itself does not call into untrusted contracts during transfer. Implementations SHOULD avoid adding such hooks without explicit user-facing opt-in. The ValdiumChain VM's cross-contract E() primitive provides a reentrancy guard at the call-stack level.

Backwards compatibility

ARC-20 is the first fungible-token standard on ValdiumChain; 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. · ARC-21 · All standards