Три всадника апокалипсиса: создание NFT коллекций на Aptos, Sui и Solana
Всем привет! С вами ArteMm aka Скамушка ツ
В этой статье мы создадим простые NFT коллекции на Aptos, Sui и Solana. Но стоит понимать, что первые два блокчейна ещё активно разрабатываются и следовательно этот туториал интересен только для ознакомления, т. к. некоторые вещи могут в любой момент поменяться.
Навигация по статье:
1. Aptos
- 1.1. Подготовка
- 1.2. Инициализация клиентов
- 1.3. Создание локальных аккаунтов
- 1.4. Создание блокчейн аккаунтов
- 1.5. Создание коллекции
- 1.6. Создание токена
- 1.7. Чтение метаданных токена и коллекции
- 1.8. Чтение баланса токенов
- 1.9. Предложение и получение токена
- 1.10. Безопасная односторонняя передача токена
2. Sui
3. Solana
- 3.1. Подготовка
- 3.2. Настройка проекта
- 3.3. Создание конфига и публикация CM2
- 3.4. Минт NFT
4. Справочник
1. Aptos
1.1. Подготовка
Убедитесь, что у вас установлен Node.js (>=16.10) и включен Corepack.
corepack enable
yarn add aptos dotenv && yarn add ts-node typescript @types/node -D
1.2. Инициализация клиентов
На первом этапе в примере инициализируются API
и faucet
(кран) клиенты.
- Клиент API взаимодействует с REST API
- Клиент faucet взаимодействует с сервисом devnet Faucet для создания и пополнения аккаунтов.
Создайте файл index.ts
и добавьте импорты:
import dotenv from 'dotenv'; import { AptosClient, AptosAccount, FaucetClient, TokenClient, } from 'aptos'; import { NODE_URL, FAUCET_URL } from './common'; dotenv.config();
(async () => { const client = new AptosClient(NODE_URL); const faucetClient = new FaucetClient(NODE_URL, FAUCET_URL); const tokenClient = new TokenClient(client);
Используя клиент API, мы можем создать TokenClient
, который мы используем для общих операций с токенами, таких как создание коллекций и токенов, их передача, получение и т. д.
const alice = new AptosAccount(); const bob = new AptosAccount();
В common.ts
инициализируйте значения URL:
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';
По умолчанию URL-адреса обеих служб указывают на службы Aptos devnet. Однако их можно настроить с помощью следующих переменных окружения: APTOS_NODE_URL
, APTOS_FAUCET_URL
.
1.3. Создание локальных аккаунтов
Следующим шагом будет создание двух аккаунтов локально. Аккаунты представляют собой как on-chain, так и off-chain состояние. Оффчейн состояние состоит из адреса и пары открытого и закрытого ключей, используемых для аутентификации владельца. Этот шаг демонстрирует, как создать это оффчейн состояние (продолжаем в index.ts
).
const alice = new AptosAccount(); const bob = new AptosAccount();
1.4. Создание блокчейн аккаунтов
В Aptos каждый аккаунт должен иметь ончейн представление, чтобы поддерживать получение токенов и монет, а также взаимодействие с другими dApps. Аккаунт представляет собой средство для хранения активов, поэтому он должен быть явно создан. В данном примере кран используется для создания аккаунтов Алисы и Боба. Финансируется только Алиса:
await faucetClient.fundAccount(alice.address(), 20_000); await faucetClient.fundAccount(bob.address(), 20_000);
1.5. Создание коллекции
Теперь начинается процесс создания токенов. Во-первых, создатель должен создать коллекцию для хранения токенов. Коллекция может содержать ноль, один или много различных токенов. Коллекция не ограничивает атрибуты токенов, так как это всего лишь контейнер.
Ваше приложение будет вызывать createCollection
:
const collectionName = "Alice's"; const tokenName = "Alice's first token"; const tokenPropertyVersion = 0; const txnHash1 = await tokenClient.createCollection( alice, collectionName, "Alice's simple collection", 'https://alice.com', ); await client.waitForTransaction(txnHash1, { checkSuccess: true });
Сигнатура метода createCollection
. Он возвращает хэш транзакции:
async createCollection( account: AptosAccount, name: string, description: string, uri: string, maxAmount: BCS.AnyNumber = MAX_U64_BIG_INT, ): Promise<string> {
1.6. Создание токена
Ваше приложение будет вызывать createToken
:
const txnHash2 = await tokenClient.createToken( alice, collectionName, tokenName, "Alice's simple token", 1, 'https://aptos.dev/img/nyan.jpeg', ); await client.waitForTransaction(txnHash2, { checkSuccess: true });
Сигнатура метода createToken
. Он возвращает хэш транзакции:
async createToken( account: AptosAccount, collectionName: string, name: string, description: string, supply: number, uri: string, max: BCS.AnyNumber = MAX_U64_BIG_INT, royalty_payee_address: MaybeHexString = account.address(), royalty_points_denominator: number = 0, royalty_points_numerator: number = 0, property_keys: Array<string> = [], property_values: Array<string> = [], property_types: Array<string> = [], ): Promise<string> {
1.7. Чтение метаданных токена и коллекции
Метаданные коллекции и токена хранятся на аккаунте создателя в его Collections
в таблице. SDKs предоставляют удобные обертки для запросов к этим конкретным таблицам.
Чтобы прочитать метаданные коллекции:
const collectionData = await tokenClient.getCollectionData(alice.address(), collectionName); console.log(`Alice's collection: ${JSON.stringify(collectionData, null, 4)}`);
Чтобы прочитать метаданные токена:
const tokenData = await tokenClient.getTokenData(alice.address(), collectionName, tokenName); console.log(`Alice's token data: ${JSON.stringify(tokenData, null, 4)}`);
Вот как getTokenData
запрашивает метаданные токена:
async getTokenData( creator: MaybeHexString, collectionName: string, tokenName: string, ): Promise<TokenTypes.TokenData> { const creatorHex = creator instanceof HexString ? creator.hex() : creator; const collection: { type: Gen.MoveStructTag; data: any } = await this.aptosClient.getAccountResource( creatorHex, "0x3::token::Collections", ); const { handle } = collection.data.token_data; const tokenDataId = { creator: creatorHex, collection: collectionName, name: tokenName, }; const getTokenTableItemRequest: Gen.TableItemRequest = { key_type: "0x3::token::TokenDataId", value_type: "0x3::token::TokenData", key: tokenDataId, }; // Мы знаем, что ответом будет структура, содержащая TokenData, отсюда и неявное приведение. return this.aptosClient.getTableItem(handle, getTokenTableItemRequest); }
1.8. Чтение баланса токенов
Каждый токен в Aptos является отдельным активом, активы, принадлежащие пользователю, хранятся в его TokenStore
. Чтобы получить баланс:
const aliceBalance1 = await tokenClient.getToken( alice.address(), collectionName, tokenName, `${tokenPropertyVersion}`, ); console.log(`Alice's token balance: ${aliceBalance1.amount}`);
1.9. Предложение и получение токена
Многие пользователи получили нежелательные токены, которые могут вызвать как минимальный дискомфорт, так и серьезные последствия. Aptos дает право каждому владельцу аккаунта решать, принимать или не принимать односторонние переводы. По умолчанию односторонние переводы не поддерживаются. Таким образом, Aptos обеспечивает основу для предложения и клейма токенов.
Чтобы предложить (отправить) токен:
const txnHash3 = await tokenClient.offerToken( alice, bob.address(), alice.address(), collectionName, tokenName, 1, tokenPropertyVersion, ); await client.waitForTransaction(txnHash3, { checkSuccess: true });
const txnHash4 = await tokenClient.claimToken( bob, alice.address(), alice.address(), collectionName, tokenName, tokenPropertyVersion, ); await client.waitForTransaction(txnHash4, { checkSuccess: true });
1.10. Безопасная односторонняя передача токена
Чтобы обеспечить безопасную одностороннюю передачу токена, отправитель может сначала попросить получателя подтвердить оффчейн о предстоящей передаче. Это происходит в форме запроса мультиагентной транзакции. Мультиагентные транзакции содержат несколько подписей, по одной для каждого ончейн аккаунта. Затем Move может использовать это для предоставления разрешений на уровне подписи всем подписантам. Для передачи токенов это гарантирует, что принимающая сторона действительно желает получить этот токен, не требуя использования описанной выше структуры передачи токенов.
const txnHash5 = await tokenClient.directTransferToken( bob, alice, alice.address(), collectionName, tokenName, 1, tokenPropertyVersion, ); await client.waitForTransaction(txnHash5, { checkSuccess: true }); })();
npx ts-node index
Alice's collection: { "description": "Alice's simple collection", "maximum": "18446744073709551615", "mutability_config": { "description": false, "maximum": false, "uri": false }, "name": "Alice's", "supply": "1", "uri": "https://alice.com" } Alice's token data: { "default_properties": { "map": { "data": [] } }, "description": "Alice's simple token", "largest_property_version": "0", "maximum": "18446744073709551615", "mutability_config": { "description": false, "maximum": false, "properties": false, "royalty": false, "uri": false }, "name": "Alice's first token", "royalty": { "payee_address": "0x3bb244826f49ac293aff101564c462c8e2046912dc8f581db655e2dbb5bffee6", "royalty_points_denominator": "0", "royalty_points_numerator": "0" }, "supply": "1", "uri": "https://aptos.dev/img/nyan.jpeg" } Alice's token balance: 1
Готово! Достаточно просто. Полный код примера можно найти на GitHub. А тут можно увидеть оригинал туториала.
2. Sui
В Sui всё представляет из себя NFT — объекты уникальны, не взаимозаменяемы и принадлежат друг другу. Так что технически достаточно простой публикации. Я решил разобрать два способа создания NFT: простой с помощью Sui CLI и более гибкий с помощью смарт-контракта на языке Move.
2.1. Подготовка
2.2. Реализация с помощью Sui CLI
Вы можете создать NFT-подобный объект на Sui, используя следующую команду:
sui client create-example-nft
Successfully created an ExampleNFT: ----- Move Object (0x524f9fae3ca4554e01354415daf58a05e5bf26ac[1]) ----- Owner: Account Address ( 0xb02b5e57fe3572f94ad5ac2a17392bfb3261f7a0 ) Version: 1 Storage Rebate: 25 Previous Transaction: 98HbDxEwEUknQiJzyWM8AiYIM479BEKuGwxrZOGtAwk= ----- Data ----- type: 0x2::devnet_nft::DevNetNFT description: An NFT created by the Sui Command Line Tool id: 0x524f9fae3ca4554e01354415daf58a05e5bf26ac[1] name: Example NFT url: ipfs://bafkreibngqhl3gaa7daob4i2vccziay2jjlp435cf66vhono7nrvww53ty
А вот так выглядит контракт DevNetNFT:
module sui::devnet_nft { use sui::url::{Self, Url}; use std::string; use sui::object::{Self, ID, UID}; use sui::event; use sui::transfer; use sui::tx_context::{Self, TxContext}; /// Example NFT, который может заминтить кто угодно struct DevNetNFT has key, store { id: UID, /// Имя токена name: string::String, /// Описание токена description: string::String, /// URL токена url: Url, // Можно добавить кастомные аттрибуты } struct MintNFTEvent has copy, drop { // Идентификатор объекта NFT object_id: ID, // Создатель NFT creator: address, // Название NFT name: string::String, } /// Создать новый devnet_nft public entry fun mint( name: vector<u8>, description: vector<u8>, url: vector<u8>, ctx: &mut TxContext ) { let nft = DevNetNFT { id: object::new(ctx), name: string::utf8(name), description: string::utf8(description), url: url::new_unsafe_from_bytes(url) }; let sender = tx_context::sender(ctx); event::emit(MintNFTEvent { object_id: object::uid_to_inner(&nft.id), creator: sender, name: nft.name, }); transfer::transfer(nft, sender); } /// Обновить `description` у `nft` на `new_description` public entry fun update_description( nft: &mut DevNetNFT, new_description: vector<u8>, _: &mut TxContext ) { nft.description = string::utf8(new_description) } /// Навсегда удалить `nft` public entry fun burn(nft: DevNetNFT, _: &mut TxContext) { let DevNetNFT { id, name: _, description: _, url: _ } = nft; object::delete(id) } /// Получить NFT's `name` public fun name(nft: &DevNetNFT): &string::String { &nft.name } /// Получить NFT's `description` public fun description(nft: &DevNetNFT): &string::String { &nft.description } /// Получить NFT's `url` public fun url(nft: &DevNetNFT): &Url { &nft.url } }
2.3. Реализация с помощью смарт-контракта
Теперь же создадим простую и очень проблемную коллекцию. За основу я взял контракт DevNetNFT и пример коллекции. Также упомяну, что мои знания в языке Move нулевые, спасало только то, что это Rust-подобный язык.
Сначала создайте пустой пакет Move:
sui move new my_nfts
git clone https://github.com/MystenLabs/sui.git
Обновите [dependencies]
в файле Move.toml
вот так:
[package] name = "my_nfts" version = "0.0.1" [dependencies] Sui = { local = "../sui/crates/sui-framework" } [addresses] my_nfts = "0x0"
А теперь создайте файл collection.move
:
touch my_nfts/sources/collection.move
И обновите его (комментарии на русском языке не поддерживаются, позже их нужно будет удалить):
module my_nfts::collection { use sui::url::{Self, Url}; use std::string; use sui::object::{Self, ID, UID}; use sui::event; use sui::transfer; use sui::typed_id::{Self, TypedID}; use sui::tx_context::{Self, TxContext}; use sui::vec_set::{Self, VecSet}; /// Example NFT, который может заминтить кто угодно struct MyNFT has key, store { id: UID, /// Имя токена name: string::String, /// Описание токена description: string::String, /// URL токена url: Url, } // ===== События ===== struct MintNFTEvent has copy, drop { // Идентификатор объекта NFT object_id: ID, // Создатель NFT creator: address, // Название NFT name: string::String, } // ===== Public view функции ===== /// Получить NFT's `name` public fun name(nft: &MyNFT): &string::String { &nft.name } /// Получить NFT's `description` public fun description(nft: &MyNFT): &string::String { &nft.description } /// Получить NFT's `url` public fun url(nft: &MyNFT): &Url { &nft.url } // ===== Entrypoints (точки входа) ===== /// Создать новый my_nft (этот ентри метод необязателен) public entry fun mint_to_sender( name: vector<u8>, description: vector<u8>, url: vector<u8>, ctx: &mut TxContext ) { let nft = MyNFT { id: object::new(ctx), name: string::utf8(name), description: string::utf8(description), url: url::new_unsafe_from_bytes(url) }; let sender = tx_context::sender(ctx); event::emit(MintNFTEvent { object_id: object::uid_to_inner(&nft.id), creator: sender, name: nft.name, }); transfer::transfer(nft, sender); } /// Перевести `nft` `получателю` public entry fun transfer( nft: MyNFT, recipient: address, _: &mut TxContext ) { transfer::transfer(nft, recipient) } /// Обновить `description` у `nft` на `new_description` public entry fun update_description( nft: &mut MyNFT, new_description: vector<u8>, _: &mut TxContext ) { nft.description = string::utf8(new_description) } /// Навсегда удалить `nft` public entry fun burn(nft: MyNFT, _: &mut TxContext) { let MyNFT { id, name: _, description: _, url: _ } = nft; object::delete(id) } // ===== Коллекция ===== // Коды ошибок /// Максимальная емкость, установленная для коллекции, не может превышать жесткого ограничения, /// которое равно DEFAULT_MAX_CAPACITY. const EInvalidMaxCapacity: u64 = 0; /// Попытка добавить объект в коллекцию, когда коллекция /// уже заполнена до предела. const EMaxCapacityExceeded: u64 = 1; const DEFAULT_MAX_CAPACITY: u64 = 0x10000; struct Collection<phantom T: store> has key { id: UID, objects: VecSet<ID>, max_capacity: u64, } /// Создать новую коллекцию и вернуть ее. public fun new<T: store>(ctx: &mut TxContext): Collection<T> { new_with_max_capacity(ctx, DEFAULT_MAX_CAPACITY) } /// Создать новую коллекцию с настраиваемым ограничением размера и вернуть ее. public fun new_with_max_capacity<T: store>( ctx: &mut TxContext, max_capacity: u64, ): Collection<T> { assert!(max_capacity <= DEFAULT_MAX_CAPACITY && max_capacity > 0, EInvalidMaxCapacity); let c = Collection { id: object::new(ctx), objects: vec_set::empty(), max_capacity, }; // Минтим 2 MyNFT и добавляем в коллекцию mint_and_add_nft(b"MyNFT #1", b"This is MyNFT #1", b"ipfs://bafkreibngqhl3gaa7daob4i2vccziay2jjlp435cf66vhono7nrvww53ty", &mut c, ctx); mint_and_add_nft(b"MyNFT #2", b"This is MyNFT #2", b"ipfs://bafkreibngqhl3gaa7daob4i2vccziay2jjlp435cf66vhono7nrvww53ty", &mut c, ctx); c } /// Создать новую коллекцию и передать ее подписавшему. public entry fun create<T: store>(ctx: &mut TxContext) { transfer::transfer(new<T>(ctx), tx_context::sender(ctx)) } /// Вернуть размер коллекции. public fun size<T: store>(c: &Collection<T>): u64 { vec_set::size(&c.objects) } /// Заминтить MyNFT объект и добавить в коллекцию. public fun mint_and_add_nft<T: store>( name: vector<u8>, description: vector<u8>, url: vector<u8>, c: &mut Collection<T>, ctx: &mut TxContext, ): TypedID<MyNFT> { assert!(size(c) + 1 <= c.max_capacity, EMaxCapacityExceeded); let nft = MyNFT { id: object::new(ctx), name: string::utf8(name), description: string::utf8(description), url: url::new_unsafe_from_bytes(url) }; let sender = tx_context::sender(ctx); event::emit(MintNFTEvent { object_id: object::uid_to_inner(&nft.id), creator: sender, name: nft.name, }); vec_set::insert(&mut c.objects, object::uid_to_inner(&nft.id)); let nft_id = typed_id::new(&nft); transfer::transfer_to_object(nft, c); nft_id } /// Проверить, содержит ли коллекция определенный объект, /// идентифицируемый идентификатором объекта в байтах. public fun contains<T: store>(c: &Collection<T>, id: &ID): bool { vec_set::contains(&c.objects, id) } /// Удалить и вернуть объект из коллекции. /// Прервать, если объект не найден. public fun remove<T: store>(c: &mut Collection<T>, nft: MyNFT): MyNFT { vec_set::remove(&mut c.objects, object::uid_as_inner(&nft.id)); nft } /// Удалить объект из коллекции, а затем передать его подписавшему. public entry fun remove_and_take<T: key + store>( c: &mut Collection<T>, nft: MyNFT, ctx: &mut TxContext, ) { let object = remove(c, nft); transfer::transfer(object, tx_context::sender(ctx)); } public fun transfer_to_object_id<T: key + store>( obj: Collection<T>, owner_id: &mut UID, ) { transfer::transfer_to_object_id(obj, owner_id) } }
Этот контракт — сплошной эксперимент, т. к. в итоге у него есть ряд проблем: создатель MyNFT
считается коллекция, а если MyNFT
вытащить из коллекции, принадлежность к ней исчезнет. В общем, нет функции минта :)
А ещё, как видите, я закомментировал методы remove
и remove_and_take
, которые как раз таки вытаскивали NFT из коллекции, потому что пока не смог исправить ошибку в методе remove
(edit 20.09: исправил ошибку).
Но всё же пример получился интересным на мой взгляд. Тестируем.
sui client publish --path my_nfts --gas-budget 30000
----- Certificate ---- Transaction Hash: Mpa5fQD++ej/RlNFaBK181iDsyfAwzAn2p6tYs89Ppk= Transaction Signature: AA==@8uR5Yl+9yT3fvScztVN1+9oTXrYpEbO8HDWc/gCxXpW+Qa3Dl6Wsqa7VR9QhgradYqxW1o9H5afbuiNcf87DAg==@7izXcEge0JZjZ1Ifhjit3s1A/NcFQ7SbezzQY2+xxeM= Signed Authorities Bitmap: RoaringBitmap<[1, 2, 3]> Transaction Kind : Publish ----- Transaction Effects ---- Status : Success Created Objects: - ID: 0x585677ee8152c2365b64e218a0a90842d6b9ba45 , Owner: Immutable Mutated Objects: - ID: 0x3f9594a043ff74d79f423f989d445f1317005050 , Owner: Account Address ( 0x8f624561d9659c623cf55153df227133fe409482 ) ----- Publish Results ---- The newly published package object ID: 0x585677ee8152c2365b64e218a0a90842d6b9ba45 Updated Gas : Coin { id: 0x3f9594a043ff74d79f423f989d445f1317005050, value: 29850 }
Вызываем метод create
(создаём коллекцию):
sui client call --function create --module collection --package <ID> --type-args <ID>::collection::MyNFT --gas-budget 30000
Вместо <ID> — id пакета (контракта), в моём случае это 0x585677ee8152c2365b64e218a0a90842d6b9ba45
.
Получаем примерно такой вывод:
----- Certificate ---- Transaction Hash: 2N4LFMjqEbrV1tDEXcYzIA+pzewCtN4n+FAv4Ar4f3c= Transaction Signature: AA==@+BGpI+WaXhzLl5Zynql2X44px8Q7+PI0ihxQUl5s/LpwvO2XvN508zRZm82rGv1Y7oKHefdY4VjWcKLXbSv7Cw==@7izXcEge0JZjZ1Ifhjit3s1A/NcFQ7SbezzQY2+xxeM= Signed Authorities Bitmap: RoaringBitmap<[0, 1, 3]> Transaction Kind : Call Package ID : 0x585677ee8152c2365b64e218a0a90842d6b9ba45 Module : collection Function : create Arguments : [] Type Arguments : ["0x585677ee8152c2365b64e218a0a90842d6b9ba45::collection::MyNFT"] ----- Transaction Effects ---- Status : Success Created Objects: - ID: 0x28a887795de98bd02aad3922b5d48fa444b005cb , Owner: Object ID: ( 0xceaad4a58eb097a0ecfd4412006abfcdd55b1928 ) - ID: 0x4f0f93e8480bc73bbbcf51800e14bad5c85deeee , Owner: Object ID: ( 0xceaad4a58eb097a0ecfd4412006abfcdd55b1928 ) - ID: 0xceaad4a58eb097a0ecfd4412006abfcdd55b1928 , Owner: Account Address ( 0x8f624561d9659c623cf55153df227133fe409482 ) Mutated Objects: - ID: 0x7478f82dc5fcc49b00d97c93179b43c347a8ea83 , Owner: Account Address ( 0x8f624561d9659c623cf55153df227133fe409482 )
А теперь давайте посмотрим как выглядит эта коллекция в эксплорере: сама коллекция, MyNFT #1, MyNFT #2.
Готово! Получился интересный эксперимент. Если когда-нибудь я бы начал делать полноценную коллекцию я бы обратил внимание на официальный пример num и/или дождался пример ImageNFT, которого пока что нет.
3. Solana
Здесь же мы выберем один из самых простых способов создания NFT коллекций и будем использовать Metaplex, а в особенности Sugar. Кстати, про NFT в Солане у меня уже была статья: https://teletype.in/@scamushka/solana_nft.
3.1. Подготовка
Установите Solana Tool Suite, Sugar и настройте Solana CLI кошелёк.
3.2. Настройка проекта
Скачайте архив с тестовыми ассетами и перекиньте папку assets
в новую папку. Каталог проекта должен выглядеть следующим образом:
MyProject/ assets/ 0.png 0.json 1.png 1.json . . .
3.3. Создание конфига и публикация CM2
Запустите следующую команду из каталога вашего проекта:
sugar launch
Отвечаем на все вопросы (везде отвечаем yes
) и деплоим Candy Machine. Подробное описание всех вопросов тут.
3.4. Минт NFT
Чтобы убедиться, что всё работает должным образом, минтим NFT на адрес нашего кошелька:
sugar mint
Получаем примерно такой вывод:
[1/2] 🔍 Loading candy machine Candy machine ID: Ews3L5NoAjjLEHYqEu47DqQ77nsqgNQs3NuELjBCd5bb ▪️▪️▪️▪️▪️ Done [2/2] 🍬 Minting from candy machine Minting to PanbgtcTiZ2PveV96t2FHSffiLHXXjMuhvoabUUKKm8 ▪️▪️▪️▪️▪️ Signature: jAUVJv4ezyumvKYWvuEsMcDtWRujCK4xFL9q8MCe7PmDiVuAGHNY5PFGKUH5hY4PnqtGMyvDjX821xxCiGAChzQ ✅ Command successful.
И на этом всё! Без заключений и каких-либо выводов ❤️
4. Справочник
Your First NFT (Aptos): https://aptos.dev/tutorials/your-first-nft
Aptos Examples: https://github.com/aptos-labs/aptos-core/tree/main/ecosystem/typescript/sdk/examples
Sui Docs (Move): https://docs.sui.io/build/move
Sui NFTs Examples: https://github.com/MystenLabs/sui/tree/main/sui_programmability/examples/nfts
Metaplex Docs (Sugar): https://docs.metaplex.com/developer-tools/sugar/