A comprehensive guide based on building a SimpleCoin (ERC-20 equivalent) on Aptos.
- Overview: Aptos vs Ethereum
- Project Setup
- Move Language Fundamentals
- Fungible Asset Standard (ERC-20 Equivalent)
- Aptos CLI Commands
- Frontend Integration
- Common Patterns
- Troubleshooting
| Concept | Ethereum/Solidity | Aptos/Move |
|---|---|---|
| Smart Contract Language | Solidity | Move |
| Token Standard | ERC-20 | Fungible Asset (FA) or Coin |
| Account Model | EOA + Contract Accounts | Unified Resource Accounts |
| Storage | Contract Storage Slots | Global Storage with Resources |
| Authentication | msg.sender | &signer reference |
| Safety | Runtime checks | Compile-time + Runtime (resource safety) |
| Gas Token | ETH | APT |
| VM | EVM | MoveVM |
- Resource-oriented: Assets can't be duplicated or destroyed accidentally
- Formal verification: Built-in support for proving correctness
- Module system: Better code organization and upgradeability
- Type safety: Strong typing with abilities system
my_project/
├── .aptos/
│ └── config.yaml # Account keys and network config
├── sources/
│ └── my_module.move # Move source files
├── scripts/ # Transaction scripts (optional)
├── tests/ # Test files
├── build/ # Compiled output
└── Move.toml # Project manifest
# Create project directory
mkdir my_project && cd my_project
mkdir sources scripts tests
# Initialize Aptos (creates .aptos/config.yaml with new keypair)
aptos init --network testnet
# This outputs your account address, e.g.:
# 0xde2454bdd311fafec119a096c2763ddea839cd1e188418981f90f3d1487bfe16[package]
name = "MyProject"
version = "1.0.0"
authors = []
[addresses]
# Replace with your account address from `aptos init`
my_project = "de2454bdd311fafec119a096c2763ddea839cd1e188418981f90f3d1487bfe16"
[dependencies.AptosFramework]
git = "https://github.com/aptos-labs/aptos-framework.git"
rev = "mainnet"
subdir = "aptos-framework"
[dev-dependencies]# Option 1: Web faucet
# Visit: https://aptos.dev/network/faucet?address=YOUR_ADDRESS
# Option 2: CLI with faucet URL
aptos account fund-with-faucet \
--account YOUR_ADDRESS \
--faucet-url https://faucet.testnet.aptoslabs.com/// Module documentation
module my_address::module_name {
// 1. Imports
use std::string::{Self, String};
use std::signer;
use aptos_framework::event;
// 2. Constants
const E_NOT_AUTHORIZED: u64 = 1;
const E_INSUFFICIENT_BALANCE: u64 = 2;
// 3. Structs (Resources)
struct MyResource has key, store {
value: u64,
}
// 4. Events
#[event]
struct MyEvent has drop, store {
actor: address,
value: u64,
}
// 5. Init function (called once on deploy)
fun init_module(deployer: &signer) {
// Initialization logic
}
// 6. Entry functions (callable from transactions)
public entry fun do_something(account: &signer, value: u64) {
// Transaction logic
}
// 7. View functions (read-only, no gas)
#[view]
public fun get_value(addr: address): u64 {
// Read and return data
}
// 8. Internal functions
fun helper_function(): u64 {
42
}
}Move has 4 abilities that control what you can do with types:
| Ability | Meaning |
|---|---|
copy |
Value can be copied |
drop |
Value can be discarded |
store |
Value can be stored in global storage |
key |
Value can be a top-level resource in global storage |
// Can be stored as a resource, and nested in other structs
struct Token has key, store {
amount: u64,
}
// Can be emitted as event (must be droppable)
#[event]
struct TransferEvent has drop, store {
from: address,
to: address,
amount: u64,
}
// Primitive types have all abilities by default
// Structs have NO abilities by default - you must declare them// The signer represents the transaction sender (authenticated)
public entry fun transfer(sender: &signer, to: address, amount: u64) {
// Get address from signer
let sender_addr = signer::address_of(sender);
// Only the signer can authorize actions on their account
// This is Move's equivalent to msg.sender in Solidity
}// Store a resource under an account
move_to<MyResource>(account, MyResource { value: 100 });
// Check if resource exists
let exists = exists<MyResource>(addr);
// Borrow immutable reference
let resource = borrow_global<MyResource>(addr);
// Borrow mutable reference
let resource = borrow_global_mut<MyResource>(addr);
// Remove resource from storage
let MyResource { value } = move_from<MyResource>(addr);
// IMPORTANT: Functions that access global storage must declare it:
public fun get_value(addr: address): u64 acquires MyResource {
borrow_global<MyResource>(addr).value
}use aptos_framework::event;
#[event]
struct TransferEvent has drop, store {
from: address,
to: address,
amount: u64,
}
// Emit event
event::emit(TransferEvent {
from: sender_addr,
to: recipient,
amount: 100,
});// Define error codes as constants
const E_NOT_OWNER: u64 = 1;
const E_INSUFFICIENT_BALANCE: u64 = 2;
// Assert with error code
assert!(sender_addr == @my_module, E_NOT_OWNER);
// The transaction aborts if assertion fails
// Error codes help identify the failure reasonAptos has two token standards:
- Coin (legacy) - Simpler but less flexible
- Fungible Asset (modern) - More features, recommended for new projects
module my_address::my_token {
use std::string::{Self, String};
use std::signer;
use std::option;
use aptos_framework::object::{Self, Object, ExtendRef};
use aptos_framework::fungible_asset::{Self, Metadata, MintRef, TransferRef, BurnRef};
use aptos_framework::primary_fungible_store;
use aptos_framework::event;
const E_NOT_ADMIN: u64 = 1;
/// Seed for creating the token metadata object
const ASSET_SYMBOL: vector<u8> = b"MYTOKEN";
/// Stores management capabilities
struct ManagedFungibleAsset has key {
mint_ref: MintRef, // Capability to mint
transfer_ref: TransferRef, // Capability to transfer
burn_ref: BurnRef, // Capability to burn
extend_ref: ExtendRef, // Capability to extend object
}
#[event]
struct MintEvent has drop, store {
recipient: address,
amount: u64,
}
/// Called once when module is deployed
fun init_module(admin: &signer) {
// Create a named object for the token metadata
let constructor_ref = object::create_named_object(admin, ASSET_SYMBOL);
// Initialize the fungible asset
primary_fungible_store::create_primary_store_enabled_fungible_asset(
&constructor_ref,
option::none(), // max_supply (none = unlimited)
string::utf8(b"My Token"), // name
string::utf8(ASSET_SYMBOL), // symbol
8, // decimals
string::utf8(b"https://example.com/icon.png"), // icon_uri
string::utf8(b"https://example.com"), // project_uri
);
// Generate management capabilities
let mint_ref = fungible_asset::generate_mint_ref(&constructor_ref);
let burn_ref = fungible_asset::generate_burn_ref(&constructor_ref);
let transfer_ref = fungible_asset::generate_transfer_ref(&constructor_ref);
let extend_ref = object::generate_extend_ref(&constructor_ref);
// Store capabilities in the metadata object
let metadata_signer = object::generate_signer(&constructor_ref);
move_to(&metadata_signer, ManagedFungibleAsset {
mint_ref,
transfer_ref,
burn_ref,
extend_ref,
});
}
/// Get the token metadata object
#[view]
public fun get_metadata(): Object<Metadata> {
let addr = object::create_object_address(&@my_address, ASSET_SYMBOL);
object::address_to_object<Metadata>(addr)
}
/// Mint tokens (admin only)
public entry fun mint(admin: &signer, to: address, amount: u64) acquires ManagedFungibleAsset {
// Check admin authorization
assert!(signer::address_of(admin) == @my_address, E_NOT_ADMIN);
let metadata = get_metadata();
let managed = borrow_global<ManagedFungibleAsset>(object::object_address(&metadata));
// Ensure recipient has a token store
let to_wallet = primary_fungible_store::ensure_primary_store_exists(to, metadata);
// Mint and deposit
let fa = fungible_asset::mint(&managed.mint_ref, amount);
fungible_asset::deposit_with_ref(&managed.transfer_ref, to_wallet, fa);
event::emit(MintEvent { recipient: to, amount });
}
/// Transfer tokens
public entry fun transfer(from: &signer, to: address, amount: u64) {
let metadata = get_metadata();
primary_fungible_store::transfer(from, metadata, to, amount);
}
/// Burn tokens
public entry fun burn(account: &signer, amount: u64) acquires ManagedFungibleAsset {
let metadata = get_metadata();
let managed = borrow_global<ManagedFungibleAsset>(object::object_address(&metadata));
let wallet = primary_fungible_store::primary_store(signer::address_of(account), metadata);
fungible_asset::burn_from(&managed.burn_ref, wallet, amount);
}
/// Get balance
#[view]
public fun balance(account: address): u64 {
let metadata = get_metadata();
if (primary_fungible_store::primary_store_exists(account, metadata)) {
primary_fungible_store::balance(account, metadata)
} else {
0
}
}
#[view]
public fun name(): String { fungible_asset::name(get_metadata()) }
#[view]
public fun symbol(): String { fungible_asset::symbol(get_metadata()) }
#[view]
public fun decimals(): u8 { fungible_asset::decimals(get_metadata()) }
}| Concept | Description |
|---|---|
Metadata |
Token information (name, symbol, decimals) |
FungibleStore |
Holds token balance for an account |
MintRef |
Capability to create new tokens |
BurnRef |
Capability to destroy tokens |
TransferRef |
Capability to move tokens (bypass frozen) |
primary_fungible_store |
Default token store for each account |
# Initialize new project with testnet account
aptos init --network testnet
# Initialize with specific network
aptos init --network mainnet
aptos init --network devnet
# Compile Move modules
aptos move compile
# Compile with named addresses override
aptos move compile --named-addresses my_module=0x123...
# Run tests
aptos move test
# Publish/deploy module
aptos move publish
# Publish with gas options
aptos move publish --max-gas 10000 --gas-unit-price 100
# Publish and assume yes to prompts
aptos move publish --assume-yes# Check account balance
aptos account balance --account YOUR_ADDRESS
# Fund account (testnet only)
aptos account fund-with-faucet \
--account YOUR_ADDRESS \
--faucet-url https://faucet.testnet.aptoslabs.com
# List account resources
aptos account list --account YOUR_ADDRESS# Call a view function
aptos move view \
--function-id 'ADDRESS::module::function_name' \
--args 'address:0x123'
# Execute entry function
aptos move run \
--function-id 'ADDRESS::module::function_name' \
--args 'u64:100' 'address:0x456'# Config stored in .aptos/config.yaml
profiles:
default:
network: Testnet
private_key: "0x..."
public_key: "0x..."
account: "your_address_without_0x"
rest_url: "https://fullnode.testnet.aptoslabs.com"npm install @aptos-labs/ts-sdk @aptos-labs/wallet-adapter-react// main.tsx
import { AptosWalletAdapterProvider } from '@aptos-labs/wallet-adapter-react'
createRoot(document.getElementById('root')!).render(
<AptosWalletAdapterProvider autoConnect={true}>
<App />
</AptosWalletAdapterProvider>
)import { useWallet } from '@aptos-labs/wallet-adapter-react'
import { Aptos, AptosConfig, Network } from '@aptos-labs/ts-sdk'
const MODULE_ADDRESS = '0x...'
const MODULE_NAME = 'my_module'
// Initialize Aptos client
const aptos = new Aptos(new AptosConfig({ network: Network.TESTNET }))
function App() {
const {
connect, // Connect wallet
disconnect, // Disconnect wallet
account, // Current account info
connected, // Connection status
signAndSubmitTransaction, // Submit transactions
wallets // Available wallets
} = useWallet()
// Connect to first available wallet
const handleConnect = async () => {
if (wallets && wallets.length > 0) {
await connect(wallets[0].name)
}
}
// Get account address as string
const addressStr = account?.address?.toString()
return (...)
}const fetchBalance = async () => {
if (!account?.address) return
try {
const result = await aptos.view({
payload: {
function: `${MODULE_ADDRESS}::${MODULE_NAME}::balance`,
functionArguments: [account.address.toString()],
},
})
// result is an array, first element is the return value
const balance = Number(result[0]) / 1e8 // Adjust for decimals
console.log('Balance:', balance)
} catch (error) {
console.error('View call failed:', error)
}
}const handleMint = async (amount: number) => {
if (!account?.address) return
try {
const response = await signAndSubmitTransaction({
data: {
function: `${MODULE_ADDRESS}::${MODULE_NAME}::mint`,
functionArguments: [
account.address.toString(), // recipient
Math.floor(amount * 1e8), // amount with decimals
],
},
options: {
maxGasAmount: 10000, // Important: set gas limit
},
})
// Wait for transaction confirmation
await aptos.waitForTransaction({ transactionHash: response.hash })
console.log('Transaction successful:', response.hash)
} catch (error) {
console.error('Transaction failed:', error)
}
}const response = await signAndSubmitTransaction({
data: {
function: `${MODULE_ADDRESS}::${MODULE_NAME}::function`,
functionArguments: [...],
typeArguments: [], // For generic functions
},
options: {
maxGasAmount: 10000, // Max gas units
gasUnitPrice: 100, // Price per gas unit in octas
expireTimestamp: Date.now() + 60000, // Expiration
},
})const E_NOT_ADMIN: u64 = 1;
public entry fun admin_function(admin: &signer) {
assert!(signer::address_of(admin) == @my_module, E_NOT_ADMIN);
// Admin logic here
}struct Config has key {
admin: address,
fee_percentage: u64,
paused: bool,
}
fun init_module(deployer: &signer) {
move_to(deployer, Config {
admin: signer::address_of(deployer),
fee_percentage: 100, // 1%
paused: false,
});
}
public entry fun update_fee(admin: &signer, new_fee: u64) acquires Config {
let config = borrow_global_mut<Config>(@my_module);
assert!(signer::address_of(admin) == config.admin, E_NOT_ADMIN);
config.fee_percentage = new_fee;
}use aptos_framework::timestamp;
public fun is_expired(deadline: u64): bool {
timestamp::now_seconds() > deadline
}use aptos_framework::coin;
use aptos_framework::aptos_coin::AptosCoin;
public entry fun deposit_apt(account: &signer, amount: u64) {
let coins = coin::withdraw<AptosCoin>(account, amount);
// Do something with coins
coin::deposit(signer::address_of(account), coins);
}| Error | Cause | Solution |
|---|---|---|
E_NOT_ADMIN / ENOT_AUTHORIZED |
Wrong account calling admin function | Use the deployer account |
EINSUFFICIENT_BALANCE |
Not enough tokens | Check balance first |
module_not_found |
Contract not deployed | Run aptos move publish |
MAX_GAS_UNITS_BELOW_MIN |
Gas limit too low | Add options: { maxGasAmount: 10000 } |
RESOURCE_NOT_FOUND |
Accessing non-existent resource | Check exists<T>(addr) first |
-
Check transaction on explorer: https://explorer.aptoslabs.com/?network=testnet
-
View function testing: Use CLI before integrating frontend
aptos move view --function-id 'ADDR::module::view_func' -
Account mismatch: Ensure wallet is on correct network (testnet/mainnet)
-
Address format:
- Move uses addresses without
0xprefix in Move.toml - Frontend/CLI use
0xprefix account.addressin wallet adapter is an object, use.toString()
- Move uses addresses without
- Aptos Documentation: https://aptos.dev
- Move Language Book: https://move-language.github.io/move/
- Aptos Framework Source: https://github.com/aptos-labs/aptos-framework
- Aptos Explorer: https://explorer.aptoslabs.com
- Testnet Faucet: https://aptos.dev/network/faucet
- Petra Wallet: https://petra.app
# Setup
aptos init --network testnet
# Development cycle
aptos move compile # Compile
aptos move test # Test
aptos move publish # Deploy
# Fund testnet account
# Visit: https://aptos.dev/network/faucet?address=YOUR_ADDRESS// Module template
module my_addr::my_module {
use std::signer;
struct MyResource has key { value: u64 }
fun init_module(deployer: &signer) {
move_to(deployer, MyResource { value: 0 });
}
public entry fun set_value(account: &signer, val: u64) acquires MyResource {
borrow_global_mut<MyResource>(signer::address_of(account)).value = val;
}
#[view]
public fun get_value(addr: address): u64 acquires MyResource {
borrow_global<MyResource>(addr).value
}
}