Skip to content
This guide targets Fe 26.4. Fe is not production-ready. Install Fe 26.4

NFT Ownership Contract

This example implements the ownership core of an NFT: an authorized minter assigns an unused ID, and its owner can transfer it. It is not a complete ERC721 implementation. It omits approvals, operator permissions, balances, safe-transfer callbacks, metadata, and ERC165 interface detection.

use std::abi::sol
msg NftMsg {
#[selector = sol("mint(address,uint256)")]
Mint { to: Address, token_id: u256 },
#[selector = sol("ownerOf(uint256)")]
OwnerOf { token_id: u256 } -> Address,
#[selector = sol("transferFrom(address,address,uint256)")]
TransferFrom { from: Address, to: Address, token_id: u256 },
}
#[event]
struct Transfer {
#[indexed]
from: Address,
#[indexed]
to: Address,
#[indexed]
token_id: u256,
}
pub contract Collectible {
minter: Address,
mut owners: StorageMap<u256, Address>,
init(admin: Address) uses (mut minter) {
assert!(admin != Address::zero(), "zero minter")
minter = admin
}
recv NftMsg {
Mint { to, token_id } uses (ctx: Ctx, log: mut Log, minter, mut owners) {
assert!(ctx.caller() == minter, "not minter")
assert!(to != Address::zero(), "zero recipient")
assert!(owners.get(key: token_id) == Address::zero(), "already minted")
owners.set(key: token_id, value: to)
log.emit(event: Transfer { from: Address::zero(), to, token_id })
}
OwnerOf { token_id } -> Address uses owners {
let owner = owners.get(key: token_id)
assert!(owner != Address::zero(), "unknown token")
owner
}
TransferFrom { from, to, token_id } uses (ctx: Ctx, log: mut Log, mut owners) {
let owner = owners.get(key: token_id)
assert!(owner != Address::zero(), "unknown token")
assert!(owner == from, "wrong owner")
assert!(ctx.caller() == owner, "not owner")
assert!(to != Address::zero(), "zero recipient")
owners.set(key: token_id, value: to)
log.emit(event: Transfer { from, to, token_id })
}
}
}
#[test]
fn mints_and_transfers() uses (evm: mut Evm) {
let owner = evm.address()
let addr = evm.create2<Collectible>(value: 0, args: (owner,), salt: 0)
evm.call(addr, gas: 200000, value: 0, message: NftMsg::Mint { to: owner, token_id: 7 })
let recipient = Address { inner: 42 }
evm.call(addr, gas: 200000, value: 0,
message: NftMsg::TransferFrom { from: owner, to: recipient, token_id: 7 })
let actual: Address = evm.call(addr, gas: 100000, value: 0,
message: NftMsg::OwnerOf { token_id: 7 })
assert!(actual == recipient)
}
#[test]
fn rejects_duplicate_mint() uses (evm: mut Evm) {
let owner = evm.address()
let addr = evm.create2<Collectible>(value: 0, args: (owner,), salt: 0)
evm.call(addr, gas: 200000, value: 0, message: NftMsg::Mint { to: owner, token_id: 7 })
let again = evm.try_call(addr, gas: 200000, value: 0,
message: NftMsg::Mint { to: owner, token_id: 7 })
assert!(!again.success())
}
#[test]
fn rejects_transfer_by_non_owner() uses (evm: mut Evm) {
let addr = evm.create2<Collectible>(value: 0, args: (evm.address(),), salt: 0)
let owner = Address { inner: 42 }
evm.call(addr, gas: 200000, value: 0, message: NftMsg::Mint { to: owner, token_id: 7 })
let outcome = evm.try_call(addr, gas: 200000, value: 0,
message: NftMsg::TransferFrom { from: owner, to: evm.address(), token_id: 7 })
assert!(!outcome.success())
}

The zero address represents an unminted ID, so minting and transfers must reject it as a recipient. Notice that ownership maps by token ID, unlike the balance maps in ERC20.

To implement the full standard, add per-owner balances, per-token approvals, operator approvals, the required events and interface IDs, and both safeTransferFrom overloads. Clear token approval on transfer. Safe transfers to contracts must verify the receiver callback after updating state and account for reentrancy. Do not advertise ERC721 support until all required behavior is implemented and tested.