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.
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
Events
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:
- After
init("Valdium USD", "vUSD", 6, "1000000000000")by address A,balanceOf(A) === "1000000000000"andtotalSupply() === "1000000000000". - A second call to
initMUST throw with message'already initialised'. transfer(B, "100")by A emits exactly oneTransfer({ from: A, to: B, value: "100" })and updates both balances.transfer(0x0, "1")MUST throw.transfer(B, "9999999...")by an account with smaller balance MUST throw with message'insufficient balance'.approve(B, "500")by A emitsApproval({ owner: A, spender: B, value: "500" })andallowance(A, B) === "500".transferFrom(A, C, "200")called by B (with allowance 500) emits both an Approval event withvalue: "300"and a Transfer event, and leavesallowance(A, B) === "300".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 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