Skip to main content

Your First NFT

This tutorial describes how to create and transfer non-fungible assets on the Aptos blockchain. The Aptos no-code implementation for non-fungible digital assets can be found in the aptos_token.move Move module.

Step 1: Pick an SDK

Install your preferred SDK from the below list:


Step 2: Run the example

Each SDK provides an examples directory. This tutorial covers the simple_aptos_token example.

Clone the aptos-core repo:

git clone git@github.com:aptos-labs/aptos-core.git ~/aptos-core

Navigate to the Typescript SDK examples directory:

cd ~/aptos-core/ecosystem/typescript/sdk/examples/typescript

Install the necessary dependencies:

pnpm install

Run the Typescript simple_aptos_token example:

pnpm run simple_aptos_token

Step 3: Understand the output

The following output should appear after executing the simple_aptos_token example, though some values will be different:

=== Addresses ===
Alice: 0x5acb91a64a2bbc5fc606a534709db5a1e60e439e15069d1e7bbaecddb4189b48
Bob: 0x612febb35dabc40df3260f7dd6c012f955671eb99862ba12390d2182ee3ab5de

=== Initial Coin Balances ===
Alice: 100000000
Bob: 100000000

=== Creating Collection and Token ===
Alice's collection: {
"collection_id": "0x65b4000927646cae66251ed121f69ffa9acc2a6fb58a574fc66fd002b3d15d4f",
"token_standard": "v2",
"collection_name": "Alice's",
"creator_address": "0x5acb91a64a2bbc5fc606a534709db5a1e60e439e15069d1e7bbaecddb4189b48",
"current_supply": 1,
"description": "Alice's simple collection",
"uri": "https://alice.com"
}
Alice's token balance: 1
Alice's token data: {
"token_data_id": "0xca2139c819fe03e2e268314c078948410dd14a64142ac270207a82cfddcc1fe7",
"token_name": "Alice's first token",
"token_uri": "https://aptos.dev/img/nyan.jpeg",
"token_properties": {},
"token_standard": "v2",
"largest_property_version_v1": null,
"maximum": null,
"is_fungible_v2": false,
"supply": 0,
"last_transaction_version": 77174329,
"last_transaction_timestamp": "2023-08-02T01:23:05.620127",
"current_collection": {
"collection_id": "0x65b4000927646cae66251ed121f69ffa9acc2a6fb58a574fc66fd002b3d15d4f",
"collection_name": "Alice's",
"creator_address": "0x5acb91a64a2bbc5fc606a534709db5a1e60e439e15069d1e7bbaecddb4189b48",
"uri": "https://alice.com",
"current_supply": 1
}
}

=== Transferring the token to Bob ===
Alice's token balance: 0
Bob's token balance: 1

=== Transferring the token back to Alice ===
Alice's token balance: 1
Bob's token balance: 0

=== Checking if indexer devnet chainId same as fullnode chainId ===
Fullnode chain id is: 67, indexer chain id is: 67

=== Getting Alices's NFTs ===
Alice current token ownership: 1. Should be 1

=== Getting Bob's NFTs ===
Bob current token ownership: 0. Should be 0

This example demonstrates:

  • Initializing the REST and faucet clients.
  • The creation of two accounts: Alice and Bob.
  • The funding and creation of Alice and Bob's accounts.
  • The creation of a collection and a token using Alice's account.
  • Alice sending a token to Bob.
  • Bob sending the token back to Alice.

Step 4: The SDK in depth

See the full code

See simple_aptos_token for the complete code as you follow the below steps.


Step 4.1: Initializing the clients

In the first step, the example initializes both the API and faucet clients.

  • The API client interacts with the REST API.
  • The faucet client interacts with the devnet Faucet service for creating and funding accounts.
const provider = new Provider(Network.DEVNET);
const faucetClient = new FaucetClient(NODE_URL, FAUCET_URL);

Using the API client we can create a TokenClient that we use for common token operations such as creating collections and tokens, transferring them, claiming them, and so on.

const aptosTokenClient = new AptosToken(provider); 

common.ts initializes the URL values as such:

export const NODE_URL = process.env.APTOS_NODE_URL || "https://fullnode.devnet.aptoslabs.com";
export const FAUCET_URL = process.env.APTOS_FAUCET_URL || "https://faucet.devnet.aptoslabs.com";
tip

By default, the URLs for both the services point to Aptos devnet services. However, they can be configured with the following environment variables:

  • APTOS_NODE_URL
  • APTOS_FAUCET_URL

Step 4.2: Creating local accounts

The next step is to create two accounts locally. Accounts consist of a public address and the public/private key pair used to authenticate ownership of the account. This step demonstrates how to generate an Account and store its key pair and address in a variable.

const alice = new AptosAccount();
const bob = new AptosAccount();
info

Note that this only generates the local keypair. After generating the keypair and public address, the account still does not exist on-chain.


Step 4.3: Creating blockchain accounts

In order to actually instantiate the Account on-chain, it must be explicitly created somehow. On the devnet network, you can request free coins with the Faucet API to use for testing purposes. This example leverages the faucet to fund and inadvertently create Alice and Bob's accounts:

await faucetClient.fundAccount(alice.address(), 100_000_000);
await faucetClient.fundAccount(bob.address(), 100_000_000);

Step 4.4: Creating a collection

Now begins the process of creating the digital, non-fungible assets. First, as the creator, you must create a collection that groups the assets. A collection can contain zero, one, or many distinct fungible or non-fungible assets within it. The collection is simply a container, intended only to group assets for a creator.

Your application will call createCollection:

const txnHash1 = await aptosTokenClient.createCollection(
alice,
"Alice's simple collection",
collectionName,
"https://alice.com",
maxSupply,
{
royaltyNumerator: 5,
royaltyDenominator: 100,
},
);

This is the function signature of createCollection. It returns a transaction hash:

async createCollection(
creator: AptosAccount,
description: string,
name: string,
uri: string,
maxSupply: AnyNumber = MAX_U64_BIG_INT,
options?: CreateCollectionOptions,
extraArgs?: OptionalTransactionArgs,
): Promise<string> {


Step 4.5: Creating a token

To create a token, the creator must specify an associated collection. A token must be associated with a collection, and that collection must have remaining tokens that can be minted. There are many attributes associated with a token, but the helper API exposes only the minimal amount required to create static content.

Your application will call mint:

const txnHash2 = await aptosTokenClient.mint(
alice,
collectionName,
"Alice's simple token",
tokenName,
"https://aptos.dev/img/nyan.jpeg",
[],
[],
[],
);

This is the function signature of mint. It returns a transaction hash:

async mint(
account: AptosAccount,
collection: string,
description: string,
name: string,
uri: string,
propertyKeys: Array<string> = [],
propertyTypes: Array<string> = [],
propertyValues: Array<string> = [],
extraArgs?: OptionalTransactionArgs,
): Promise<string> {


Step 4.6: Reading token and collection metadata

Both the collection and token assets are Objects on-chain with unique addresses. Their metadata is stored at the object address. The SDKs provide convenience wrappers around querying this data:

To read a collection's metadata:

const collectionData = (await provider.getCollectionData(alice.address(), collectionName)).current_collections_v2[0];
console.log(`Alice's collection: ${JSON.stringify(collectionData, null, 4)}`);

To read a token's metadata:

const tokenData = (await provider.getTokenData(tokenAddress.toString())).current_token_datas_v2[0];
console.log(`Alice's token data: ${JSON.stringify(tokenData, null, 4)}`);

Here's how getTokenData queries the token metadata using the indexer client:

async getTokenData(
token: string,
extraArgs?: {
tokenStandard?: TokenStandard;
options?: IndexerPaginationArgs;
orderBy?: IndexerSortBy<Current_Token_Datas_V2_Order_By>[];
},
): Promise<GetTokenDataQuery> {
const tokenAddress = HexString.ensure(token).hex();
IndexerClient.validateAddress(tokenAddress);

const whereCondition: any = {
token_data_id: { _eq: tokenAddress },
};

if (extraArgs?.tokenStandard) {
whereCondition.token_standard = { _eq: extraArgs?.tokenStandard };
}
const graphqlQuery = {
query: GetTokenData,
variables: {
where_condition: whereCondition,
offset: extraArgs?.options?.offset,
limit: extraArgs?.options?.limit,
order_by: extraArgs?.orderBy,
},
};
return this.queryIndexer(graphqlQuery);
}

Step 4.7: Reading an object's owner

Each object created from the aptos_token.move contract is a distinct asset. The assets owned by a user are stored separately from the user's account. To check if a user owns an object, check the object's owner:

Extracting the balance from the indexer query
const collectionAddress = HexString.ensure(collectionData.collection_id);
let { tokenAddress, amount: aliceAmount } = await getTokenInfo(provider, alice.address(), collectionAddress);
console.log(`Alice's token balance: ${aliceAmount}`);
Making the query to get the data
async function getTokenInfo(
provider: Provider,
ownerAddress: HexString,
collectionAddress: HexString,
): Promise<{ tokenAddress?: HexString; amount: number }> {
const tokensOwnedQuery = await provider.getTokenOwnedFromCollectionAddress(
ownerAddress,
collectionAddress.toString(),
{
tokenStandard: "v2",
},
);
const tokensOwned = tokensOwnedQuery.current_token_ownerships_v2.length;
if (tokensOwned > 0) {
return {
tokenAddress: HexString.ensure(tokensOwnedQuery.current_token_ownerships_v2[0].current_token_data.token_data_id),
amount: tokensOwnedQuery.current_token_ownerships_v2[0].amount,
};
} else {
return {
tokenAddress: undefined,
amount: tokensOwned,
};
}
}

Step 4.8: Transfer the object back and forth

Each object created from the aptos_token.move contract is a distinct asset. The assets owned by a user are stored separately from the user's account. To check if a user owns an object, check the object's owner:

Extracting the balance from the indexer query
const collectionAddress = HexString.ensure(collectionData.collection_id);
let { tokenAddress, amount: aliceAmount } = await getTokenInfo(provider, alice.address(), collectionAddress);
console.log(`Alice's token balance: ${aliceAmount}`);
Making the query to get the data
async function getTokenInfo(
provider: Provider,
ownerAddress: HexString,
collectionAddress: HexString,
): Promise<{ tokenAddress?: HexString; amount: number }> {
const tokensOwnedQuery = await provider.getTokenOwnedFromCollectionAddress(
ownerAddress,
collectionAddress.toString(),
{
tokenStandard: "v2",
},
);
const tokensOwned = tokensOwnedQuery.current_token_ownerships_v2.length;
if (tokensOwned > 0) {
return {
tokenAddress: HexString.ensure(tokensOwnedQuery.current_token_ownerships_v2[0].current_token_data.token_data_id),
amount: tokensOwnedQuery.current_token_ownerships_v2[0].amount,
};
} else {
return {
tokenAddress: undefined,
amount: tokensOwned,
};
}
}
Transfer the token from Alice to Bob
const txnHash3 = await aptosTokenClient.transferTokenOwnership(alice, tokenAddress, bob.address()); 
Print each user's queried token amount
aliceAmount = (await getTokenInfo(provider, alice.address(), collectionAddress)).amount;
let bobAmount = (await getTokenInfo(provider, bob.address(), collectionAddress)).amount;
console.log(`Alice's token balance: ${aliceAmount}`);
console.log(`Bob's token balance: ${bobAmount}`);
Transfer the token back to Alice
let txnHash4 = await aptosTokenClient.transferTokenOwnership(bob, tokenAddress, alice.address()); 
Print each user's queried token amount again
aliceAmount = (await getTokenInfo(provider, alice.address(), collectionAddress)).amount;
bobAmount = (await getTokenInfo(provider, bob.address(), collectionAddress)).amount;
console.log(`Alice's token balance: ${aliceAmount}`);
console.log(`Bob's token balance: ${bobAmount}`);

Supporting documentation