Build on Asentum
Build an NFT Collection
End-to-end tutorial · Estimated read time: 20 minutes
What an NFT is on Asentum
An NFT (non-fungible token) is a token where every unit is unique. Each one has a whole-number id and exactly one owner, and it usually points to a metadata file that describes it: a name, a description and an image. Art, tickets, memberships, game items and certificates are all NFTs.
On Asentum an NFT collection is an ordinary JavaScript contract. There is no special token type in the chain: the contract keeps the owner of every id in its storage and exposes methods to mint, move and read them. This guide gives you a complete contract that implements ARC-721, the Asentum non-fungible token standard and the counterpart of Ethereum's ERC-721: ownerOf, balanceOf, tokenURI, approve, setApprovalForAll, transferFrom and the same three events. It is the reference implementation of the standard; read the ARC-721 specification for the exact rules every compatible collection follows.
On top of the standard methods it has an owner-only mint, an optional paid public mint with a max supply, burning, paged lookups of what an address holds, owner withdrawal of mint proceeds, and a switch that freezes metadata for good.
Prerequisites: read Writing Contracts and ideally Build a Token Contract first. You need a wallet with some test ASE for gas (see Get Test ASE).
To skip the copy and paste, open the finished contract straight in the IDE, ready to deploy:
The contract
Here is the complete contract. Save it as nft.js. It is about 360 lines, most of them comments and input checks. The sections below explain how it works. The IDE's NFT template contains the same contract.
// 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 };
},
})A few design choices worth knowing:
- Addresses in, one form stored. Every address argument may be an ase1 address or hex. The contract checks the ase1 checksum itself and stores the lowercase hex form, because that is what
msg.senderis. That way a token minted to someone's ase1 address is always recognised as theirs when they call. It also means raw/viewcalls work with ase1 arguments. - Ids are strings. Ids start at 1 and count up. They are kept and returned as decimal strings (
"1","2"), like every number in Asentum storage. You can pass1or"1". - No loops over the whole collection. Every write touches a fixed number of keys. The only loops are
publicMint(at most 5 tokens) andtokensOfOwner(at most 100 ids per page), so gas stays flat as the collection grows. - The collection owner has no power over holders. The owner can mint and change settings, but cannot move or burn tokens that belong to someone else.
Methods at a glance
| Method | Who | What it does |
|---|---|---|
| init(name, symbol, baseURI, maxSupply, mintPrice) | Once, right after deploy | Sets the collection up. The caller becomes the owner. maxSupply "0" means no cap; mintPrice is in wei. |
| mint(to, uri) | Owner | Mints the next id to an address. Leave uri empty to use baseURI + id. Returns the id. |
| publicMint(count) | Anyone, when open | Mints 1 to 5 tokens to the caller. Send exactly mintPrice x count. Returns the ids. |
| transfer(to, tokenId) | Token owner | Sends one of your tokens. |
| transferFrom(from, to, tokenId) | Owner, approved or operator | Moves a token on the owner’s behalf. |
| approve(approved, tokenId) | Owner or operator | Lets one address move one token. Empty value clears it. Moving the token clears it too. |
| setApprovalForAll(operator, approved) | Anyone | Lets an operator, such as a marketplace contract, move all your tokens. Pass false to revoke. |
| burn(tokenId) | Owner, approved or operator | Destroys a token. Its id is never reused. |
| setPublicMint(open) | Owner | Opens or closes publicMint. Closed after init. |
| setMintPrice(priceWei) | Owner | Changes the public mint price. |
| setBaseURI(baseURI) / setTokenURI(tokenId, uri) | Owner | Changes metadata locations until freezeMetadata is called. |
| freezeMetadata() | Owner | Locks every URI for good. |
| withdraw(to, amountWei) | Owner | Pays out public mint proceeds. Both optional: defaults are the owner and the full amount. |
| transferOwnership(newOwner) | Owner | Hands the owner role to another address. |
| ownerOf, balanceOf, tokenURI, getApproved, isApprovedForAll | Read | The standard reads. ownerOf and tokenURI throw for an id that does not exist. |
| tokensOfOwner(owner, start, limit) | Read | A page of up to 100 ids an address holds, plus the total. |
| tokenOfOwnerByIndex(owner, index) | Read | One id from the same list, by position. |
| name, symbol, standard, owner, totalSupply, totalMinted, maxSupply, mintPrice, publicMintOpen, baseURI, metadataFrozen, proceeds, exists(tokenId) | Read | Collection settings and counters. standard() returns "ARC-721". |
Every method that fails throws with a plain message, such as you do not own token 4 or max supply reached, and a failed transaction keeps none of its changes.
How tokens are stored
Contract storage is a flat key-value store of strings, so the contract uses prefixed keys:
| Key pattern | Value | Example |
|---|---|---|
| own:ID | Owner of a token | own:7 |
| uri:ID | Per-token metadata URI (optional) | uri:7 → "ipfs://bafy.../7.json" |
| appr:ID | Address approved for one token | appr:7 |
| op:OWNER:OPERATOR | "true" while an operator is approved | op:<owner>:<market> |
| bal:OWNER | How many tokens an address holds | bal:<owner> → "3" |
| oi:OWNER:N / ti:ID | Each owner’s token list, for paging | oi:<owner>:0 → "7" |
| minted / supply | Ids issued so far / tokens still alive | "10" / "9" |
| name, symbol, owner, baseURI, maxSupply, mintPrice, publicMint, proceeds, frozen | Collection settings | "Asentum Apes" |
The oi: and ti: keys keep a numbered list of each owner's tokens. When a token leaves an owner, the last entry of their list moves into its slot, so removal costs the same whatever the list length. The catch is that the order of tokensOfOwner changes as tokens move: use it to list holdings, not as a stable ranking.
Gas, measured on the VM: a mint is about 113,000 gas, publicMint of 5 tokens about 412,000 (well inside the CLI's default limit of 1,000,000 for calls), and the deploy about 3,000,000, because deploy gas is 200 per byte of source. Comments count as source, so you can strip them before deploying if you want a smaller deploy.
Events
The three events of the standard, with the exact fields ARC-721 requires:
Transfer { from, to, tokenId, collection }: every mint, transfer and burn. A mint has the zero address asfrom; a burn has it asto.Approval { owner, approved, tokenId, collection }: everyapprove. A cleared approval has the zero address asapproved.ApprovalForAll { owner, operator, approved, collection }: everysetApprovalForAll, withapprovedas true or false.
The contract also emits OwnershipTransferred, PublicMintSet, MintPriceSet, BaseURISet, TokenURISet, MetadataFrozen and Withdrawn for its admin actions.
Two details are deliberate. The token id is in a tokenId field, not value or amount: indexers read a Transfer with value as a fungible token amount and one with amount as native ASE, so reusing those names would show a token id as a sum of money. And each event carries collection, the contract's own address, because a log is recorded under the contract the transaction called. When a marketplace contract moves your NFT, the log sits under the marketplace, and collection tells an indexer which collection the token belongs to.
Events are in the receipt and queryable with eth_getLogs. Each log has one topic, the BLAKE3 hash of the event name, and the JSON payload as data. Fields are not separate topics, so to find the transfers of one token, filter by the Transfer topic and the collection address, then decode the data. Addresses in event data are in hex: show them as ase1 with the SDK's toAse1.
Metadata and hosting
The chain stores only a URI per token. The picture and description live in a JSON file at that URI, in the format NFT tools across chains already understand:
{
"name": "Asentum Ape #1",
"description": "The first ape of the collection.",
"image": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1.png",
"attributes": [
{ "trait_type": "Background", "value": "Blue" },
{ "trait_type": "Eyes", "value": "Laser" }
]
}- name: the name of this token.
- description: a sentence or two about it.
- image: a link to the image (PNG, JPG, GIF, SVG or WebP).
- attributes (optional): traits as
trait_type/valuepairs.
One folder for the whole collection
tokenURI(id) returns the token's own URI if one was set at mint, otherwise baseURI + id. So the usual setup is a folder of metadata files named 1, 2, 3 and so on, with no file extension, and baseURI set to the folder with a trailing slash. Token 7 then reads <folder>/7.
IPFS or https
- IPFS is content-addressed: the folder's CID is a hash of its contents, so nobody can swap the files later. Upload the images first, put their
ipfs://links in the JSON files, upload the JSON folder, and useipfs://<folder CID>/asbaseURI. Use a pinning service so the files stay online. Browsers cannot openipfs://directly, so a frontend turns it into a gateway link such ashttps://ipfs.io/ipfs/<CID>/7. - https works too, from any web server or storage bucket. It is easier to update, which also means holders have to trust you not to change it. Serve the files as JSON and allow cross-origin requests so web apps can read them.
Once the files are final, call freezeMetadata(). After that neither setBaseURI nor setTokenURI works again, and anyone can check with metadataFrozen(). URIs are capped at 512 characters: store a link, never the image itself.
Testing locally
Run the contract in memory with the VM package before you deploy. This uses the same setup as Testing Contracts:
import { test, expect } from 'vitest';
import { Vm } from '@asentum/vm';
import { readFileSync } from 'node:fs';
const source = readFileSync('./contracts/nft.js', 'utf8');
class MemoryStorage {
data = new Map();
get(k) { return this.data.get(k); }
set(k, v) { this.data.set(k, v); }
delete(k) { return this.data.delete(k); }
has(k) { return this.data.has(k); }
keys(prefix = '') { return [...this.data.keys()].filter((k) => k.startsWith(prefix)).sort(); }
}
// Inside the VM, addresses are lowercase hex strings.
const OWNER = '0x' + 'a1'.repeat(20);
const BOB = '0x' + 'b2'.repeat(20);
const CAROL = '0x' + 'c3'.repeat(20);
function setup() {
const vm = new Vm();
const storage = new MemoryStorage();
const context = (sender, value = 0n) => ({
sender, contractAddress: '0x' + 'ee'.repeat(20), value,
blockNumber: 1n, blockTimestamp: 1_760_000_000_000n, chainId: 1423n,
});
// Deploy gas is 200 per byte of source, so give the deploy room.
vm.deploy({ source, storage, context: context(OWNER), gasLimit: 20_000_000n });
return (sender, value) => (method, args = []) =>
vm.call({ source, storage, context: context(sender, value), gasLimit: 1_000_000n }, method, args);
}
test('mint, transfer, approve', () => {
const as = setup();
as(OWNER)('init', ['Test Apes', 'TAPE', 'ipfs://bafyfolder/', '100', '0']);
expect(as(OWNER)('mint', [BOB, '']).returnValue).toBe('1');
expect(as(OWNER)('tokenURI', ['1']).returnValue).toBe('ipfs://bafyfolder/1');
const r = as(BOB)('transfer', [CAROL, '1']);
expect(r.events[0]).toEqual({
name: 'Transfer',
data: { from: BOB, to: CAROL, tokenId: '1', collection: '0x' + 'ee'.repeat(20) },
});
expect(as(OWNER)('ownerOf', ['1']).returnValue).toBe(CAROL);
expect(() => as(BOB)('transfer', [BOB, '1'])).toThrow('you do not own token 1');
});
test('paid public mint takes the exact price', () => {
const as = setup();
const price = 10n ** 18n; // 1 ASE
as(OWNER)('init', ['Test Apes', 'TAPE', '', '0', price.toString()]);
as(OWNER)('setPublicMint', [true]);
expect(() => as(BOB, price / 2n)('publicMint', ['1'])).toThrow('send exactly');
expect(as(BOB, 2n * price)('publicMint', ['2']).returnValue).toEqual(['1', '2']);
});msg.value is the second argument to as() here, so you can test the paid mint without real ASE. The in-memory storage does not undo the writes of a call that throws, which the chain does; start a fresh setup() after a test that expects a failure if you rely on state afterwards.
Deploying
Deploying stores the code and does not run init. Call init straight after, from the same account: whoever calls it first becomes the owner.
From the IDE
- Open ide.asentum.com, create a file
nft.jsand paste the contract. - Connect a wallet and click Deploy. Approve the transaction in your wallet. The IDE shows the contract's ase1 address when the deploy lands.
- In the side panel, fill in
init: a name, a symbol, yourbaseURI, amaxSupply(0for no cap) and amintPricein wei (0if you will not sell). Call it. - Call
mintwith an ase1 address and an emptyuri, then readownerOfwith1. The IDE shows returned addresses as ase1.
From the CLI
# 1 ASE = 1000000000000000000 wei. "0" as maxSupply means no cap.
asentum deploy nft.js --from <account> \
--init-args '["Asentum Apes","APE","ipfs://<folder CID>/","1000","1000000000000000000"]'The CLI signs the deploy, waits for it, then sends init with the arguments from --init-args and prints the contract's ase1 address. See Deploy Your First Contract and the CLI reference. With the SDK, wallet.deploy(source) followed by send('init', [...]) does the same, as in the token tutorial.
Minting
There are two ways to mint. The owner can mint any time with mint(to, uri), for airdrops, giveaways or one-of-one pieces. The public can mint with publicMint(count) once the owner opens it, paying mintPrice per token. A public mint must send exactly the price times the count; anything else is refused and costs only gas. Both stop at maxSupply.
# Owner mint: the next id goes to the recipient. "" = use baseURI + id.
asentum call ase1…collection mint --from <account> --args '["ase1…recipient",""]'
# Or give one token its own metadata file
asentum call ase1…collection mint --from <account> \
--args '["ase1…recipient","ipfs://<CID>/special.json"]'
# Open the public mint, then anyone can mint by paying the price.
# --value is in ASE: 2 tokens at 1 ASE each is --value 2.
asentum call ase1…collection setPublicMint --from <account> --args '[true]'
asentum call ase1…collection publicMint --from <buyer> --args '["2"]' --value 2
# Collect the proceeds (to yourself, everything)
asentum call ase1…collection withdraw --from <account>Payments stay in the contract and are tracked as proceeds(). Only the owner can withdraw them, to any address, in full or in part. In the IDE, enter the payment in the value (ASE) field of publicMint.
Transfers and approvals
A holder moves a token with transfer(to, tokenId). To let someone else move it, approve them for that one token, or approve an operator for all your tokens. An approved address uses transferFrom(from, to, tokenId). A per-token approval is cleared when the token moves; an operator approval stays until you revoke it.
# Send a token you own
asentum call ase1…collection transfer --from <account> --args '["ase1…friend","1"]'
# Let one address move token 2, then let it move it
asentum call ase1…collection approve --from <account> --args '["ase1…helper","2"]'
asentum call ase1…collection transferFrom --from <helper> \
--args '["ase1…your-address","ase1…buyer","2"]'
# Let a marketplace contract move any of your tokens (false revokes)
asentum call ase1…collection setApprovalForAll --from <account> --args '["ase1…market",true]'
# Destroy a token
asentum call ase1…collection burn --from <account> --args '["3"]'Operators are how marketplaces work: you call setApprovalForAll for the marketplace contract once, and it can then move a token in the same transaction that pays you. Inside the collection, msg.sender is the marketplace contract, which is what the approval checks. A marketplace method could look like this:
({
// nft is the collection address, passed in as an argument.
buy(nft, id) {
const seller = E(nft).ownerOf(id);
E(nft).transferFrom(seller, msg.sender, id);
// ...pay the seller from msg.value with transfer(seller, price)
return seller;
},
})If anything in the call throws, the whole transaction reverts, the transfer included. Only approve operators you trust: an operator can move every token you hold in the collection.
Reading with /view
Every read is free through POST /view: no wallet, no gas, nothing written. With the CLI:
asentum view ase1…collection ownerOf --args '["1"]'
asentum view ase1…collection tokenURI --args '["1"]'
asentum view ase1…collection tokensOfOwner --args '["ase1…holder","0","100"]'
asentum view ase1…collection totalSupplyWith plain HTTP. This contract accepts ase1 arguments even here, because it converts them itself:
curl -s -X POST https://testnet.asentum.com/view -H 'content-type: application/json' \
-d '{"contract":"ase1…collection","method":"tokenURI","args":["1"]}'
{"ok":true,"returnValue":"ipfs://<folder CID>/1","events":[],"gasUsed":"…","blockNumber":"…","finalized":false}
curl -s -X POST https://testnet.asentum.com/view -H 'content-type: application/json' \
-d '{"contract":"ase1…collection","method":"balanceOf","args":["ase1…holder"]}'
{"ok":true,"returnValue":"3",…}With the SDK:
import { AsentumClient, AsentumContract, toAse1 } from '@asentum/sdk';
const client = new AsentumClient('https://testnet.asentum.com');
const nft = new AsentumContract(client, 'ase1…collection');
// Reads are free. ase1 arguments are fine.
const page = await nft.view('tokensOfOwner', ['ase1…holder', '0', '100']);
console.log(page.total, page.ids); // "3" [ '1', '4', '9' ]
console.log(await nft.view('tokenURI', ['1'])); // "ipfs://<folder CID>/1"
// Addresses come back in the hex form contracts use. Show them as ase1.
console.log(toAse1(await nft.view('ownerOf', ['1'])));
// Writes need a wallet. valueWei pays for a public mint.
const tx = await nft.connect(wallet).send('publicMint', ['2'], { valueWei: 2n * 10n ** 18n });
const receipt = await tx.wait();
console.log(receipt.returnValue); // [ '10', '11' ]ownerOf and getApproved return the hex form contracts use, the same as msg.sender. The IDE converts them to ase1 for you; in your own code use toAse1. tokensOfOwner returns { total, ids }: fetch the next page with start set to the number of ids you have so far. To show a token, read its tokenURI, fetch the JSON, and display its image.
Current limitations
What works today and what does not:
- Wallets do not show NFTs yet. The Chrome extension, the Telegram wallet and the desktop app list ASE and fungible tokens only. Holders check their tokens with
balanceOfortokensOfOwner, or through your own frontend. - The explorer does not index NFTs yet. Mints and transfers show as
Transferevents in each transaction's detail, but address pages do not list NFT holdings or NFT transfers, and there are no image previews. - No safe-transfer receiver check. ERC-721 has
safeTransferFrom, which asks a receiving contract to confirm it can handle NFTs. Asentum contracts cannot yet tell whether an address is a contract, so there is no such check. A token sent to a contract that has no method to move it is stuck there for good. Double-check addresses, and only send tokens to contracts built to hold them. - No royalties. The contract does not take a cut of resales. A marketplace could pay one, but nothing in the chain enforces it.
- No randomness. Contracts have no random source, so ids are handed out in order. If you want a random reveal, shuffle the metadata before you upload it and freeze it afterwards.
- Events are not indexed by field. Each log has one topic, the event name. Finding the history of one token or one owner means reading the collection's
Transferlogs and filtering the decoded data. - Contracts are immutable. Once deployed, the code cannot change. Test thoroughly on the VM first; to fix a bug you deploy a new collection.
The contract here is example code for the testnet. It has been tested on the VM but not audited. Have it reviewed before you use it for anything of value.
Next in the series