ARC-721: Non-Fungible Token Standard.
One interface for NFT collections on AsentumChain. Familiar to anyone who has shipped an ERC-721, adapted to the AsentumChain JavaScript VM and to how its logs are recorded.
| Number | ARC-721 |
| Title | Non-Fungible Token Standard |
| Status | Draft |
| Type | Standards Track |
| Category | ARC (Application) |
| Created | 2026-10-25 |
| Requires | (none) |
Abstract
This document specifies the standard interface for non-fungible tokens (NFTs) on AsentumChain: collections in which every token has its own id and exactly one owner. It defines a discovery method (standard), the required reads (name, symbol, totalSupply, ownerOf, balanceOf, exists, tokenURI, getApproved, isApprovedForAll, tokensOfOwner, tokenOfOwnerByIndex), four required writes (transfer, transferFrom, approve, setApprovalForAll), three events with exact field names, a metadata JSON format, and a set of optional extensions for minting, burning, metadata management, ownership and mint proceeds.
Motivation
Art, tickets, memberships, game items and certificates all need a token where every unit is distinct. AsentumChain has no built-in NFT type: a collection is an ordinary JavaScript contract that keeps the owner of each id in its storage. Without a shared interface every wallet, explorer and marketplace would have to learn every collection separately, and a marketplace written for one collection would not work with the next.
ERC-721 solved this on Ethereum, and ARC-721 keeps its method names and approval model so that existing knowledge carries over. Two things about AsentumChain force changes to the concrete shape. Logs have a single topic and carry their fields as JSON, so field names are what tell an indexer what an event means. And a log is recorded under the contract the transaction called, not the contract that emitted it, so an NFT moved by a marketplace would otherwise look like the marketplace's own event. ARC-721 fixes the field names and adds a collection field to deal with both.
Specification
A compliant contract MUST expose the methods marked required and emit the events described below. The keywords MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119.
Token ids
A token id is a positive whole number. Ids cross the ABI as decimal strings ("1", "2", ...) and MUST be returned and emitted in canonical form, with no sign, no leading zeros, no decimal point and no separators. Inputs MUST be accepted as a decimal string or a JavaScript number; the reference accepts 1 to 30 digits and normalises them, so "007" means token 7. Id 0 is invalid. An id MUST NOT be issued twice, even after the token is burned. Collections MAY choose any issuing order; the reference issues 1, 2, 3, ...
Addresses
Every address argument MUST accept either form a user may hold: an ase1 address (bech32, all lower case or all upper case, checksum verified) or 0x followed by 40 hex digits in any case. Anything else MUST throw. Internally, addresses are converted to lowercase 0x hex, the form the VM uses for msg.sender, so that an id minted to someone's ase1 address is recognised as theirs when they call. Every address a method returns or an event carries MUST be in that lowercase hex form. That is the wire format only: wallets, explorers and dapps MUST show addresses to users as ase1 (the SDK's toAse1 converts).
The zero address, 0x0000000000000000000000000000000000000000 on the wire, means "nobody". It marks mints and burns in Transfer, a cleared approval in Approval, and "no approval" from getApproved. No token is ever owned by it.
Discovery (required)
standard() -> stringrequiredMUST return exactly the string "ARC-721". Explorers, wallets and indexers call it with /view to decide whether a contract is an ARC-721 collection. A contract that does not return this value MUST NOT be treated as ARC-721, whatever other methods it has.
Read methods (required)
Reads MUST NOT change state and are free to call through POST /view. Counts are decimal strings; booleans are JSON booleans.
name() -> stringrequiredThe collection name, e.g. "Asentum Apes". The reference sets it once in init (1 to 64 characters) and never changes it.
symbol() -> stringrequiredThe collection symbol, e.g. "APE". The reference sets it once in init (1 to 16 characters) and never changes it.
totalSupply() -> stringrequiredThe number of tokens that exist right now, as a decimal string: tokens minted minus tokens burned. MUST return "0" before the first mint, never null.
ownerOf(tokenId) -> addressrequiredThe current owner of a token, as lowercase hex. MUST throw if the token does not exist (never minted, or burned). MUST NOT return the zero address.
balanceOf(owner) -> stringrequiredHow many tokens owner holds, as a decimal string. An address that holds none returns "0", never null. MUST throw if owner is not a valid address.
exists(tokenId) -> boolrequiredtrue while the token exists, false if it was never minted or has been burned. Lets a client check an id without catching the error ownerOf throws. MUST throw if tokenId is not a valid token id.
tokenURI(tokenId) -> stringrequiredThe location of the token’s metadata JSON. MUST throw if the token does not exist. MAY return an empty string when the collection has no metadata for the token. In the reference, a per-token URI set at mint or with setTokenURI wins; otherwise the result is baseURI followed by the decimal id; with neither, "".
getApproved(tokenId) -> addressrequiredThe address approved to move this one token, as lowercase hex, or the zero address when there is none. MUST throw if the token does not exist.
isApprovedForAll(owner, operator) -> boolrequiredtrue if operator may move and approve every token owner holds in this collection, otherwise false.
tokensOfOwner(owner, start, limit) -> { total, ids }requiredOne page of the ids owner holds. total is balanceOf(owner) as a decimal string; ids is an array of decimal-string ids at positions start, start + 1, ... of the owner’s list. start defaults to 0 and limit to 100. A limit above 100 MUST be treated as 100, so one call never returns more than 100 ids. A start at or past total returns an empty ids array, not an error. To read everything, call again with start set to the number of ids received so far until that number reaches total.
tokenOfOwnerByIndex(owner, index) -> stringrequiredThe id at position index (0-based) of the same list tokensOfOwner pages through. MUST throw if index is not below balanceOf(owner).
The order of an owner's list is not specified and MAY change whenever a token enters or leaves it. The reference removes a token by moving the last entry into its slot, which keeps removal constant-cost. Clients MUST NOT treat a position as stable across blocks.
Write methods (required)
transfer(to, tokenId) -> boolrequiredMoves a token the caller owns to to. MUST throw if the token does not exist or msg.sender is not its owner. Approved addresses and operators use transferFrom instead. Follows the move rules below and returns true.
transferFrom(from, to, tokenId) -> boolrequiredMoves a token on the owner’s behalf. MUST throw if the token does not exist, if from is not its current owner, or if msg.sender is none of: the owner, the address approved for the token, an operator of the owner. Follows the move rules below and returns true.
approve(approved, tokenId) -> boolrequiredSets the one address that may move this token, replacing any earlier one. Only the owner or one of the owner’s operators may call it. Passing an empty value, null or the zero address clears the approval. MUST throw if approved is the owner. MUST emit Approval, with approved set to the zero address when clearing. Returns true.
setApprovalForAll(operator, approved) -> boolrequiredGrants (approved true) or revokes (approved false) operator’s right to move and approve every token msg.sender holds in this collection, now and in future. The reference accepts true, "true", 1 and "1" as true and anything else as false. MUST throw if operator is msg.sender. MUST emit ApprovalForAll on every call, including a revoke of an operator that was never set. Returns true.
Every successful transfer and transferFrom follows the same move rules:
toMUST NOT be the zero address (destroying a token isburn, never a transfer) and MUST NOT be the collection's own address.- The token's single-token approval MUST be cleared. Operator approvals are not affected.
ownerOf, both owners'balanceOfand both owners' lists MUST reflect the move when the call returns.- Exactly one
Transferevent MUST be emitted for the token.
The collection owner (see Ownable below) has no special right over tokens it does not hold: it cannot move, approve or burn them.
Initialisation
Deploying a contract does not run any of its methods. A collection SHOULD expose a one-shot init that the deployer calls straight after the deploy, and it MUST throw on a second call. Its arguments are collection-specific and not part of this standard; the reference uses init(name, symbol, baseURI, maxSupply, mintPrice) and makes the caller the collection owner.
Optional extensions
How tokens come into being, and who may change settings, differs between collections (a fixed drop, an open edition, tickets minted on demand), so none of it is required. A collection that offers one of the following capabilities SHOULD use these names and semantics, so that tools can drive it without custom code. Every mint and burn, by whatever method, MUST still emit Transfer as specified below.
Mintable: mint(to, uri) -> stringoptionalCollection-owner-only mint of the next id to to, with an optional per-token URI. Returns the new id. The reference rejects attached ASE, the zero address as to, and a mint past maxSupply. Any mint, from any method, MUST emit Transfer with from set to the zero address.
Mintable: publicMint(count) -> string[], maxSupply(), mintPrice(), totalMinted(), publicMintOpen(), setPublicMint(open), setMintPrice(priceWei)optionalA paid mint open to anyone once the collection owner opens it. count is 1 to 5 in the reference and the caller MUST send exactly mintPrice x count wei. maxSupply "0" means no cap. totalMinted counts every id ever issued, including burned ones, so it is also the highest id so far.
Burnable: burn(tokenId) -> booloptionalDestroys a token. The owner, the approved address or an operator may call it. Clears the token’s approval and URI, decrements totalSupply and MUST emit Transfer with to set to the zero address. The id MUST NOT be minted again.
Metadata management: baseURI(), setBaseURI(uri), setTokenURI(tokenId, uri), freezeMetadata(), metadataFrozen()optionalLets the collection owner change where metadata lives. freezeMetadata is one way: after it, setBaseURI and setTokenURI MUST throw for good, and metadataFrozen() returns true. The reference caps every URI at 512 characters.
Ownable: owner(), transferOwnership(newOwner)optionalA single collection owner, who controls the extension methods above. It has no power over tokens it does not hold. Changes emit OwnershipTransferred { from, to }, with from set to the zero address at init.
Proceeds: withdraw(to, amountWei) -> string, proceeds()optionalPays public mint income out of the contract. Collection owner only. Both arguments are optional in the reference: to defaults to the caller and amountWei to everything collected. Returns the amount paid, in wei, and emits Withdrawn { to, amount }.
The reference also emits PublicMintSet { open }, MintPriceSet { price }, BaseURISet { baseURI }, TokenURISet { tokenId, uri } and MetadataFrozen {} for its admin actions. These are informational and not part of the standard.
Events
Each event MUST be emitted with exactly these field names. All addresses are lowercase hex. collection MUST be the emitting contract's own address (msg.contractAddress) in every event.
Transfer{ from, to, tokenId, collection }Emitted on every mint, transfer and burn, once per token. tokenId is a decimal string. A mint has the zero address as from; a burn has it as to. A transfer between two holders has neither.
Approval{ owner, approved, tokenId, collection }Emitted on every successful approve. owner is the token’s owner, which is not msg.sender when an operator approves. A cleared approval has the zero address as approved. Not emitted when a move clears the approval implicitly; see the move rules.
ApprovalForAll{ owner, operator, approved, collection }Emitted on every successful setApprovalForAll. approved is a JSON boolean, true or false, never a string.
On the chain, each emit becomes one log whose only topic is the BLAKE3 hash of the event name, with the payload as JSON data:
// One log per emit. topics[0] = BLAKE3("Transfer"); data = the JSON payload.
// A mint of token 7 by a collection, as an indexer decodes it:
{
"address": "<the contract the transaction called>",
"topics": ["<BLAKE3 hash of the event name>"],
"data": {
"from": "0x0000000000000000000000000000000000000000",
"to": "<recipient, lowercase hex>",
"tokenId": "7",
"collection": "<the collection, lowercase hex>"
}
}Rules for indexers
- A
Transferwhose data has atokenIdfield MUST be treated as an ARC-721 transfer, never as a fungible amount. - The collection MUST be taken from the
collectionfield, not from the log's address. The two differ whenever another contract called the collection withE(). - A
Transferclears that token's single-token approval, even though noApprovalevent is emitted for it. - A
collectionthat differs from the log's address is a claim made by contract code, not a fact recorded by the chain. Indexers SHOULD confirm it against the collection's state, as described under Security considerations.
Error conditions
A method that cannot do what was asked MUST throw, and a failed transaction keeps none of its changes. Methods MUST NOT signal failure by returning false. Error messages are not part of the interface, but compliant collections SHOULD use the reference wording so that tools can show users a familiar reason. The reference throws:
tokenId must be a whole numberA token id that is not 1 to 30 decimal digits (signs, decimal points, hex, empty).
tokenId must be 1 or moreToken id 0.
token <id> does not existownerOf, tokenURI, getApproved, approve, transferFrom, burn or setTokenURI on an id that was never minted or has been burned.
<argument>: not a valid ase1 or hex addressAn address that is neither 0x plus 40 hex digits nor an ase1 address with a valid checksum.
you do not own token <id>transfer by anyone other than the owner.
from is not the owner of token <id>transferFrom where from does not match the current owner.
not allowed to move token <id>transferFrom by an address that is not the owner, the approved address or an operator.
use burn to destroy a tokentransfer or transferFrom to the zero address.
cannot send a token to the collection itselftransfer or transferFrom to the collection’s own address.
only the owner or an operator can approveapprove by anyone else, including the approved address itself.
the owner is already allowedapprove with the token’s owner as approved.
you cannot approve yourselfsetApprovalForAll with msg.sender as operator.
index out of rangetokenOfOwnerByIndex with index at or past balanceOf(owner).
And in its optional extensions:
not allowed to burn token <id>burn by an address that may not move the token.
max supply reachedAny mint once totalMinted equals a non-zero maxSupply.
public mint is closedpublicMint before setPublicMint(true) or after setPublicMint(false).
count must be 1 to 5publicMint with a count outside 1 to 5.
send exactly <cost> weipublicMint where msg.value is not mintPrice x count.
metadata is frozensetBaseURI or setTokenURI after freezeMetadata.
URI is longer than 512 charactersA baseURI or token URI over the cap.
only the owner / only the owner can mintAn extension method called by anyone but the collection owner.
<id> is the canonical decimal id and <argument> is the parameter name, e.g. to, from, owner or operator.
Metadata JSON
The chain stores only a URI per token. tokenURI(id) SHOULD point to a JSON document in the format NFT tools on other chains already read:
{
"name": "Asentum Ape #7",
"description": "The seventh ape of the collection.",
"image": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/7.png",
"external_url": "https://example.com/apes/7",
"animation_url": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/7.mp4",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Eyes", "value": "Laser" }
]
}name(string, required): the name of this token.description(string, required): a sentence or two about it.image(string, required): anhttps://oripfs://link to a PNG, JPG, GIF, SVG or WebP image.attributes(array, optional): traits as{ trait_type, value }objects; value is a string or a number.external_url(string, optional): a web page about the token.animation_url(string, optional): anhttps://oripfs://link to audio, video or other media shown in place of the image where supported.
Clients MAY ignore other fields. tokenURI itself SHOULD be an ipfs:// or https:// URI. Browsers cannot open ipfs://, so clients resolve it through a public gateway: ipfs://<CID>/7 becomes https://ipfs.io/ipfs/<CID>/7, and the same applies to image and animation_url. An https host SHOULD serve the JSON with a JSON content type and allow cross-origin reads. An empty tokenURI means the token has no metadata, and clients SHOULD show a placeholder. The reference caps every URI at 512 characters: a URI is a link, never the image itself.
Rationale
Why tokenId, not value
Logs carry no type information beyond the event name, and Transfer is shared with ARC-20. Explorers read a Transfer with a value field as an ARC-20 amount and one with an amount field as native ASE. Putting a token id in either would show token 7 as a payment of 7 units. A field name used by neither, tokenId, makes the event unambiguous on its own.
Why a collection field
A log is attributed to the contract the transaction called. When a marketplace calls E(nft).transferFrom(...) inside its own buy method, the collection's Transfer is recorded under the marketplace's address. Without a field naming the collection, an indexer could not tell which collection the token belongs to, and marketplace sales, the most important transfers to index, would be the ones it got wrong.
Why standard() for discovery
ERC-165 identifies interfaces by hashing function selectors. Contracts on AsentumChain are dispatched by method name, so there is nothing to hash. A free view returning a fixed string is the cheapest reliable probe, and it is an explicit opt-in: a contract that merely happens to have an ownerOf is not mistaken for a collection.
Why holdings are enumerable on chain
Logs have a single topic, so "which tokens does this address hold" cannot be answered by a cheap filtered log query; it needs a full indexer. Wallets and small dapps should not depend on one. tokensOfOwner answers it with free /view calls. The 100-id page cap bounds the work of a single read however large a holding grows, and tokenOfOwnerByIndex keeps the ERC-721 Enumerable shape for code that already uses it.
Why transfer as well as transferFrom
ARC-20 has transfer(to, amount), and the SDK, CLI and IDE are built around it. Giving NFTs the same transfer(to, id) means a holder never has to pass their own address, and the common case reads the same in both standards.
Why transfers to the zero address are refused
A transfer to the zero address would make a token look burned while leaving it counted in totalSupply. Keeping destruction to an explicit burn means totalSupply, exists and the event history always agree. Transfers to the collection's own address are refused because the reference has no way to move a token out of it again.
Why minting is not required
A fixed art drop, an open edition, an event ticket minted at the door and a certificate issued by a registry all create tokens differently. Fixing one mint signature would exclude the others. What every tool needs is the event a mint produces, so that is what ARC-721 requires.
Reference implementation
The following implementation is the contract published in the Build an NFT Collection guide, which walks through deploying, minting and reading it. It has been tested on the VM (mints, transfers, approvals, operators, burns, max supply, paid mint, withdraw, events, metadata, paging, and moves made through E() from a marketplace contract) but not audited. It uses only the standard AsentumChain VM globals (storage, msg, emit, assert, transfer) and has no external dependencies. It implements every required method and every optional extension above. Measured on the VM, a mint costs about 113,000 gas and a public mint of 5 tokens about 412,000.
// AsentumNFT: an ARC-721 style non-fungible token for Asentum.
//
// Every token has a whole-number id (1, 2, 3, ...) and exactly one owner.
// Addresses can be passed as ase1 or hex. They are stored in the lowercase
// hex form the chain uses for msg.sender, so comparisons always match.
//
// Storage keys:
// own:ID owner of a token
// uri:ID per-token metadata URI (optional)
// appr:ID address approved to move one token
// op:OWNER:OP 'true' when OP may move every token of OWNER
// bal:OWNER number of tokens OWNER holds
// oi:OWNER:N the Nth token of OWNER (0-based), for paging
// ti:ID position of a token in its owner's list
const ZERO = '0x' + '0'.repeat(40);
const HEX_ADDR = /^0x[0-9a-f]{40}$/;
const UINT = /^[0-9]{1,30}$/;
const MAX_PAGE = 100; // most ids tokensOfOwner returns per call
const MAX_MINT = 5; // most tokens one publicMint call can mint
const MAX_URI = 512; // longest metadata URI, in characters
// ---- address handling: accept ase1 or hex, store lowercase hex ----------
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
function polymod(values) {
let chk = 1;
for (const v of values) {
const top = chk >>> 25;
chk = ((chk & 0x1ffffff) << 5) ^ v;
for (let i = 0; i < 5; i++) {
if ((top >>> i) & 1) chk ^= GEN[i];
}
}
return chk;
}
function ase1ToHex(s) {
if (s.length > 90 || s.lastIndexOf('1') !== 3 || s.slice(0, 3) !== 'ase') return null;
const values = [3, 3, 3, 0, 1, 19, 5]; // the prefix 'ase', expanded for the checksum
const data = [];
for (const c of s.slice(4)) {
const v = CHARSET.indexOf(c);
if (v < 0) return null;
data.push(v);
}
if (data.length < 7 || polymod(values.concat(data)) !== 1) return null;
let acc = 0;
let bits = 0;
let hex = '0x';
for (const v of data.slice(0, -6)) {
acc = (acc << 5) | v;
bits += 5;
if (bits >= 8) {
bits -= 8;
hex += ((acc >>> bits) & 0xff).toString(16).padStart(2, '0');
}
}
if (bits >= 5 || ((acc << (8 - bits)) & 0xff) !== 0) return null;
return hex.length === 42 ? hex : null;
}
function addr(a, label) {
const raw = String(a === undefined || a === null ? '' : a).trim();
const s = raw.toLowerCase();
if (HEX_ADDR.test(s)) return s;
// bech32 is all lower or all upper case, never mixed
if (s.startsWith('ase1') && (raw === s || raw === raw.toUpperCase())) {
const hex = ase1ToHex(s);
if (hex) return hex;
}
throw new Error((label || 'address') + ': not a valid ase1 or hex address');
}
// ---- small helpers ------------------------------------------------------
function uint(v, label) {
const s = typeof v === 'bigint' ? v.toString() : String(v === undefined || v === null ? '' : v).trim();
assert(UINT.test(s), label + ' must be a whole number');
return BigInt(s);
}
function tokenId(id) {
const n = uint(id, 'tokenId');
assert(n > 0n, 'tokenId must be 1 or more');
return n.toString();
}
function isOwner() {
return msg.sender === storage.get('owner');
}
function ownerOfId(id) {
const o = storage.get('own:' + id);
assert(o, 'token ' + id + ' does not exist');
return o;
}
function count(key) {
return BigInt(storage.get(key) || '0');
}
function canMove(spender, holder, id) {
return spender === holder
|| storage.get('appr:' + id) === spender
|| storage.get('op:' + holder + ':' + spender) === 'true';
}
function checkUri(uri) {
const s = String(uri === undefined || uri === null ? '' : uri);
assert(s.length <= MAX_URI, 'URI is longer than ' + MAX_URI + ' characters');
return s;
}
function asBool(v) {
return v === true || v === 'true' || v === 1 || v === '1';
}
// Add a token to the end of its owner's list.
function addToOwner(holder, id) {
const n = count('bal:' + holder);
storage.set('oi:' + holder + ':' + n, id);
storage.set('ti:' + id, n.toString());
storage.set('bal:' + holder, (n + 1n).toString());
}
// Remove a token from its owner's list: move the last entry into its slot.
function removeFromOwner(holder, id) {
const last = count('bal:' + holder) - 1n;
const idx = storage.get('ti:' + id);
if (idx !== last.toString()) {
const lastId = storage.get('oi:' + holder + ':' + last);
storage.set('oi:' + holder + ':' + idx, lastId);
storage.set('ti:' + lastId, idx);
}
storage.delete('oi:' + holder + ':' + last);
storage.delete('ti:' + id);
storage.set('bal:' + holder, last.toString());
}
function mintOne(to, uri) {
const minted = count('minted');
const max = count('maxSupply');
assert(max === 0n || minted < max, 'max supply reached');
const id = (minted + 1n).toString();
storage.set('minted', id);
storage.set('supply', (count('supply') + 1n).toString());
storage.set('own:' + id, to);
if (uri) storage.set('uri:' + id, uri);
addToOwner(to, id);
emit('Transfer', { from: ZERO, to, tokenId: id, collection: msg.contractAddress });
return id;
}
function move(from, to, id) {
assert(to !== ZERO, 'use burn to destroy a token');
assert(to !== msg.contractAddress, 'cannot send a token to the collection itself');
storage.delete('appr:' + id);
removeFromOwner(from, id);
storage.set('own:' + id, to);
addToOwner(to, id);
emit('Transfer', { from, to, tokenId: id, collection: msg.contractAddress });
}
({
// ---- setup ------------------------------------------------------------
// Deploying does not run init. Call it once, right after the deploy.
// maxSupply '0' means no cap. mintPrice is in wei (1 ASE = 10^18 wei).
init(name, symbol, baseURI, maxSupply, mintPrice) {
assert(!storage.has('owner'), 'already initialized');
const n = String(name || '').trim();
const s = String(symbol || '').trim();
assert(n.length > 0 && n.length <= 64, 'name must be 1 to 64 characters');
assert(s.length > 0 && s.length <= 16, 'symbol must be 1 to 16 characters');
storage.set('owner', msg.sender);
storage.set('name', n);
storage.set('symbol', s);
storage.set('baseURI', checkUri(baseURI));
storage.set('maxSupply', uint(maxSupply || '0', 'maxSupply').toString());
storage.set('mintPrice', uint(mintPrice || '0', 'mintPrice').toString());
storage.set('publicMint', 'false');
storage.set('minted', '0');
storage.set('supply', '0');
storage.set('proceeds', '0');
emit('OwnershipTransferred', { from: ZERO, to: msg.sender });
return true;
},
// ---- minting ----------------------------------------------------------
// Owner only. Mints the next id to the given address. uri is optional: leave it empty
// to use baseURI + id.
mint(to, uri) {
assert(isOwner(), 'only the owner can mint');
assert(msg.value === 0n, 'mint does not take ASE');
const dest = addr(to, 'to');
assert(dest !== ZERO, 'cannot mint to the zero address');
return mintOne(dest, checkUri(uri));
},
// Anyone, once the owner has opened it. Send exactly mintPrice x count.
publicMint(howMany) {
assert(storage.get('publicMint') === 'true', 'public mint is closed');
const n = uint(howMany === undefined || howMany === '' ? '1' : howMany, 'count');
assert(n >= 1n && n <= BigInt(MAX_MINT), 'count must be 1 to ' + MAX_MINT);
const cost = count('mintPrice') * n;
assert(msg.value === cost, 'send exactly ' + cost.toString() + ' wei');
storage.set('proceeds', (count('proceeds') + cost).toString());
const ids = [];
for (let i = 0n; i < n; i++) ids.push(mintOne(msg.sender, ''));
return ids;
},
// ---- moving tokens ----------------------------------------------------
// Send one of your own tokens.
transfer(to, id) {
const t = tokenId(id);
assert(ownerOfId(t) === msg.sender, 'you do not own token ' + t);
move(msg.sender, addr(to, 'to'), t);
return true;
},
// Move a token as its owner, its approved address, or an operator.
transferFrom(from, to, id) {
const t = tokenId(id);
const holder = ownerOfId(t);
assert(holder === addr(from, 'from'), 'from is not the owner of token ' + t);
assert(canMove(msg.sender, holder, t), 'not allowed to move token ' + t);
move(holder, addr(to, 'to'), t);
return true;
},
// Let one address move one token. Pass an empty value to clear it.
approve(approved, id) {
const t = tokenId(id);
const holder = ownerOfId(t);
assert(
msg.sender === holder || storage.get('op:' + holder + ':' + msg.sender) === 'true',
'only the owner or an operator can approve',
);
const who = approved === undefined || approved === null || approved === '' ? ZERO : addr(approved, 'approved');
assert(who !== holder, 'the owner is already allowed');
if (who === ZERO) storage.delete('appr:' + t);
else storage.set('appr:' + t, who);
emit('Approval', { owner: holder, approved: who, tokenId: t, collection: msg.contractAddress });
return true;
},
// Let an operator (a marketplace, say) move every token you hold.
setApprovalForAll(operator, approved) {
const op = addr(operator, 'operator');
assert(op !== msg.sender, 'you cannot approve yourself');
const on = asBool(approved);
const key = 'op:' + msg.sender + ':' + op;
if (on) storage.set(key, 'true');
else storage.delete(key);
emit('ApprovalForAll', { owner: msg.sender, operator: op, approved: on, collection: msg.contractAddress });
return true;
},
// Destroy a token. Its id is never reused.
burn(id) {
const t = tokenId(id);
const holder = ownerOfId(t);
assert(canMove(msg.sender, holder, t), 'not allowed to burn token ' + t);
storage.delete('appr:' + t);
storage.delete('uri:' + t);
storage.delete('own:' + t);
removeFromOwner(holder, t);
storage.set('supply', (count('supply') - 1n).toString());
emit('Transfer', { from: holder, to: ZERO, tokenId: t, collection: msg.contractAddress });
return true;
},
// ---- owner settings ---------------------------------------------------
setPublicMint(open) {
assert(isOwner(), 'only the owner');
storage.set('publicMint', asBool(open) ? 'true' : 'false');
emit('PublicMintSet', { open: asBool(open) });
return true;
},
setMintPrice(priceWei) {
assert(isOwner(), 'only the owner');
const p = uint(priceWei, 'mintPrice').toString();
storage.set('mintPrice', p);
emit('MintPriceSet', { price: p });
return true;
},
setBaseURI(baseURI) {
assert(isOwner(), 'only the owner');
assert(storage.get('frozen') !== 'true', 'metadata is frozen');
storage.set('baseURI', checkUri(baseURI));
emit('BaseURISet', { baseURI: storage.get('baseURI') });
return true;
},
setTokenURI(id, uri) {
assert(isOwner(), 'only the owner');
assert(storage.get('frozen') !== 'true', 'metadata is frozen');
const t = tokenId(id);
ownerOfId(t);
const u = checkUri(uri);
if (u) storage.set('uri:' + t, u);
else storage.delete('uri:' + t);
emit('TokenURISet', { tokenId: t, uri: u });
return true;
},
// One way: after this nobody can change any metadata URI again.
freezeMetadata() {
assert(isOwner(), 'only the owner');
storage.set('frozen', 'true');
emit('MetadataFrozen', {});
return true;
},
// Pay out mint proceeds. Both arguments are optional: to defaults to
// the owner and amountWei to everything collected so far.
withdraw(to, amountWei) {
assert(isOwner(), 'only the owner');
const dest = to ? addr(to, 'to') : msg.sender;
const available = count('proceeds');
const amt = amountWei ? uint(amountWei, 'amount') : available;
assert(amt > 0n, 'nothing to withdraw');
assert(amt <= available, 'only ' + available.toString() + ' wei available');
storage.set('proceeds', (available - amt).toString());
transfer(dest, amt);
emit('Withdrawn', { to: dest, amount: amt.toString() });
return amt.toString();
},
transferOwnership(newOwner) {
assert(isOwner(), 'only the owner');
const next = addr(newOwner, 'newOwner');
assert(next !== ZERO, 'cannot hand the collection to the zero address');
storage.set('owner', next);
emit('OwnershipTransferred', { from: msg.sender, to: next });
return true;
},
// ---- reads (free with /view) ------------------------------------------
standard() { return 'ARC-721'; },
name() { return storage.get('name'); },
symbol() { return storage.get('symbol'); },
owner() { return storage.get('owner'); },
totalSupply() { return storage.get('supply') || '0'; },
totalMinted() { return storage.get('minted') || '0'; },
maxSupply() { return storage.get('maxSupply') || '0'; },
mintPrice() { return storage.get('mintPrice') || '0'; },
publicMintOpen() { return storage.get('publicMint') === 'true'; },
baseURI() { return storage.get('baseURI') || ''; },
metadataFrozen() { return storage.get('frozen') === 'true'; },
proceeds() { return storage.get('proceeds') || '0'; },
exists(id) { return storage.has('own:' + tokenId(id)); },
ownerOf(id) { return ownerOfId(tokenId(id)); },
balanceOf(holder) { return storage.get('bal:' + addr(holder, 'owner')) || '0'; },
tokenURI(id) {
const t = tokenId(id);
ownerOfId(t);
const own = storage.get('uri:' + t);
if (own) return own;
const base = storage.get('baseURI') || '';
return base ? base + t : '';
},
getApproved(id) {
const t = tokenId(id);
ownerOfId(t);
return storage.get('appr:' + t) || ZERO;
},
isApprovedForAll(holder, operator) {
return storage.get('op:' + addr(holder, 'owner') + ':' + addr(operator, 'operator')) === 'true';
},
// The Nth token (0-based) an address holds. Order changes as tokens move.
tokenOfOwnerByIndex(holder, index) {
const h = addr(holder, 'owner');
const i = uint(index, 'index');
assert(i < count('bal:' + h), 'index out of range');
return storage.get('oi:' + h + ':' + i);
},
// A page of the token ids an address holds: at most 100 per call.
tokensOfOwner(holder, start, limit) {
const h = addr(holder, 'owner');
const total = count('bal:' + h);
const from = uint(start || '0', 'start');
let max = uint(limit || String(MAX_PAGE), 'limit');
if (max > BigInt(MAX_PAGE)) max = BigInt(MAX_PAGE);
const ids = [];
for (let i = from; i < total && i < from + max; i++) {
ids.push(storage.get('oi:' + h + ':' + i));
}
return { total: total.toString(), ids };
},
})Test cases
A compliant implementation MUST pass at minimum the following behavioural tests (items 1 to 10 cover the required interface; 11 and 12 apply to collections with the extensions). A, B and C are accounts; the reference implementation passes all of them.
standard()returns exactly"ARC-721".- After a mint of token 1 to B,
ownerOf("1")is B in lowercase hex,balanceOf(B) === "1",totalSupply() === "1", and exactly oneTransferwas emitted withfromthe zero address,toB,tokenId: "1"andcollectionthe contract address. - The same calls succeed with B given as ase1, as lowercase hex and as upper case hex, and all return the same result.
transfer(C, "1")by B emitsTransfer({ from: B, to: C, tokenId: "1", collection }); afterwardsownerOf("1")is C. The same call by B again MUST throw with'you do not own token 1'.ownerOf,tokenURIandgetApprovedon an id never minted MUST throw;existson it returns false;ownerOf("0")MUST throw.approve(A, "1")by C emitsApprovaland makesgetApproved("1")A. A can thentransferFrom(C, B, "1"), after whichgetApproved("1")is the zero address and A can no longer move the token.setApprovalForAll(A, true)by B emitsApprovalForAllwithapproved: true; A can then move any of B's tokens, and the right survives the move. AftersetApprovalForAll(A, false),transferFromby A MUST throw with'not allowed to move token <id>'.transferFrom(C, A, "1")while B owns token 1 MUST throw with'from is not the owner of token 1', even when called by B.- A transfer to the zero address MUST throw with
'use burn to destroy a token', and one to the collection's own address MUST throw. - With 150 tokens held by B,
tokensOfOwner(B, "0", "500")returnstotal: "150"and 100 ids;tokensOfOwner(B, "100", "100")returns the other 50; together they are every id B holds, each once.tokenOfOwnerByIndex(B, "150")MUST throw. burn("1")by its owner emitsTransferwithtothe zero address, decrementstotalSupply, and makesexists("1")false. The next mint MUST NOT reuse id 1.- After
freezeMetadata(),setBaseURIandsetTokenURIMUST throw with'metadata is frozen'. With a mint price of 1 ASE,publicMint("1")sending 0.5 ASE MUST throw, andpublicMint("2")sending 2 ASE returns two new ids.
Security considerations
No safe-transfer receiver check
ERC-721's safeTransferFrom asks a receiving contract to confirm it can handle NFTs. AsentumChain gives contracts no host function to tell whether an address is a contract, so ARC-721 has no such check. A token sent to a contract that has no method to move it is stuck there for good. Wallets SHOULD warn before sending a token to an address they do not recognise, and contracts that hold NFTs MUST include a way to move them out. A future revision may add a receiver check if the VM gains a way to identify contracts.
Spoofed collection fields
Any contract can emit an event with any payload, including a Transfer that names someone else's collection. A log whose address equals its collection field was called directly and can be trusted as far as that collection can. When the two differ, an indexer SHOULD confirm the claim before showing it, for example by checking that ownerOf(tokenId) on the named collection matches the new owner (or that exists is false after a burn), and SHOULD only index collections whose standard() returns "ARC-721". State read from the collection is always the authority; events are a convenience.
Logs have one topic
Event fields are not indexed topics, so no node can filter logs by owner or token id. Finding the history of one token means fetching the collection's Transfer logs and filtering the decoded data. Clients that only need current holdings SHOULD use tokensOfOwner rather than replaying history.
No on-chain randomness
Contracts have no random source, and block data is known to the block producer, so ids and traits cannot be assigned randomly on chain in a way nobody can predict. Collections that want a random reveal SHOULD shuffle the metadata off chain before uploading it and then freeze it.
Metadata mutability
Until freezeMetadata is called, the collection owner can repoint any token to different metadata, and an https host can change what a URI returns at any time. Holders should treat a collection as final only once metadataFrozen() is true and its URIs are ipfs://, which are content-addressed. Freezing protects the URIs, not their availability: IPFS files stay reachable only while someone pins them.
Rendering untrusted metadata
Metadata is written by whoever deployed the collection. Clients MUST treat it as untrusted input: fetch only https:// and ipfs:// URIs, render images in a way that cannot run scripts (an SVG can carry them), escape text fields, and never follow javascript: or similar links from external_url.
Approvals and operators
A single-token approval is cleared whenever the token moves, so an approved address cannot pull a token back after it is sold. Operator approvals are different: they cover every token the owner holds in the collection, now and later, and last until revoked. Users SHOULD approve only marketplace contracts they trust, and wallets SHOULD show a clear warning before a setApprovalForAll(operator, true). An operator can also approve others for the owner's tokens.
Bounded loops
Every required write touches a fixed number of storage keys, so its gas does not grow with the size of the collection or of a holding. The only loops in the reference are tokensOfOwner (at most 100 ids) and publicMint (at most 5 tokens). Implementations MUST NOT add methods that loop over a whole collection or a whole holding in one call: a large enough collection would push them past the gas limit and make them unusable.
Reentrancy
ARC-721 itself makes no call into another contract while moving a token. Implementations SHOULD NOT add receiver hooks or callbacks without an explicit opt-in. The AsentumChain VM's E() primitive guards against reentrancy at the call-stack level, but a collection should still update its own state before any external call, as the reference does in withdraw.
Backwards compatibility
ARC-721 is the first non-fungible token standard on AsentumChain. Collections deployed from the Build an NFT Collection guide already comply. Contracts deployed earlier in another shape, or without standard(), will not be detected by tools; contracts are immutable, so the way to migrate is to deploy a compliant collection and mint matching tokens to the current holders.
Relationship to ERC-721
Method names, argument order and the approval model (single-token approvals cleared on transfer, operators for all tokens, operators may approve) follow ERC-721, so marketplace logic ports directly. The differences:
ERC-165 supportsInterfacestandard() returning "ARC-721". The VM has no function selectors to hash, so a name check is the natural probe.
safeTransferFrom and onERC721ReceivedNot part of ARC-721. Contracts cannot yet tell whether an address is a contract, so there is no receiver check to make. See Security considerations.
transferFrom onlytransfer(to, tokenId) as well, matching ARC-20, so a holder never needs to repeat their own address.
Indexed event topicsOne topic per log (the event name hash) and the fields as JSON data. Indexers filter by event and decode.
Transfer(from, to, tokenId)Transfer { from, to, tokenId, collection }. The extra collection field identifies the emitter when a log is attributed to another contract.
uint256 ids and balancesDecimal strings, like every number in AsentumChain storage and RPC.
ERC721EnumerabletokenOfOwnerByIndex is kept, and tokensOfOwner adds paging. tokenByIndex (enumerate the whole collection) is not part of ARC-721, because it needs a global index every transfer has to maintain.
Transfer to the contract itselfRejected with an error. ERC-721 allows it.
ARC-721 is in Draft. Feedback welcome.