В этой статье мы обсудим, как вы можете построить свой собственный NFT-маркетплейс с нуля (ну да…). Однако, это будет не только это. Мы пойдем еще дальше. Мы не будем использовать для транзакций «крутого папочку» — ETH. Но мы будем использовать для этого наш собственный токен ERC20. Будет 3 отдельных контракта —
- Общий контракт ERC20.
- Общий контракт ERC721.
- Не совсем обычный контракт NFT Marketplace.
Если вы новичок в этой области (а сейчас прекрасное время для этого), то я бы сказал, что просто убедитесь, что вы изучили основы solidity, прежде чем вступать в игру. Вот и все. Вы можете использовать всеми любимую старую добрую IDE — REMIX или, если хотите, Truffle, Hardhat, Brownie или Foundry.
Файлы кода для этого руководства будут предоставлены через репозиторий GitHub, упомянутый в конце. Вы можете заметить, что в нем много файлов и папок. Это потому, что это проект Hardhat. Основные файлы, с которыми вам придется иметь дело в этом руководстве, находятся в каталоге contracts
. В отдельной статье я расскажу о Hardhat и о том, как развертывать и тестировать контракты в Hardhat. Итак, давайте начнем представление!
Небольшое примечание:
- В этом руководстве я буду использовать OpenZeppelin. Если вы не знаете, что это такое (ух ты, вы действительно новичок), то просто знайте — это простой и умный способ написания смарт-контрактов в Ethereum с использованием уже проверенного кода.
- Я использую фрагменты кода, полный код можно найти в репозитории GitHub, указанном в конце статьи.
Общий контракт ERC20
contract TestToken is ERC20, Ownable {
constructor() ERC20("TestToken", "TT") {}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}
Итак, для общего контракта ERC20 мы будем наследовать контракт ERC 20 от OpenZeppelin, а также использовать контракт Ownable
. Код для него довольно прост. Контракт TestToken
наследует от ERC20
и Ownable
.
Контракт ERC20 помогает нам получить функциональные возможности токена, который должен следовать конвенции ERC 20 без особых усилий. Все функции, которые могут понадобиться, уже присутствуют в нашем токене без написания даже одного собственного метода. Честно говоря, мы могли бы остановиться на этом, и технически это было бы все.
Контракт Ownable
предоставляет нам одну важную функциональность — возможность «владеть» нашим контрактом. Сейчас вы можете подумать: «Эй! Минутку. Мы же сами создаем и развертываем его, почему же мы не владеем им!?». И как новичок вы будете правы, думая так. Но дело в том, что любой может запустить любую функцию на развернутом контракте.
По сути, любой может просто прийти и намайнить 1 миллион этих токенов для себя. Ownable предоставляет нам возможность наложить ограничение на определенные функции. Адрес, на котором развернут смарт-контракт, становится естественным владельцем, и методы, имеющие модификатор onlyOwner
, будут выполняться только с этого адреса. Если приводить аналогию с веб-разработкой, то это, по сути, «администратор», который может решать, что будет размещено на сайте.
Существует еще один контракт, который является своего рода обобщением этого контракта в OpenZeppelin — AccessControl
. Он обеспечивает более тонкую RBAC в смарт-контрактах. Но мы не будем его обсуждать, поскольку это выходит за рамки данной статьи.
Как вы можете видеть в коде, мы инициализируем контракт с именем и символом, передаваемыми в конструктор ERC20. Это имя и символ нашего токена. Не стесняйтесь вставить свои собственные.
Единственным другим методом является mint
. У этого метода есть модификатор onlyOwner
, о котором мы говорили ранее. Это означает, что адрес размещения может майнить только эти токены ERC20. Размещающий адрес может майнить их для себя или для любого другого адреса, который пожелает владелец.
Вот и все для типового контракта на токены ERC20.
Общий контракт ERC721
contract TestNft is ERC721, ERC721URIStorage, ERC721Burnable, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;
constructor() ERC721("WildNFTs", "WF") {}
...
}
Далее речь пойдет о типовом контракте ERC721. Теперь, вы можете подумать, что это статья о рынке «NFT», и контракт «NFT» будет MC (главный герой для анимешников). Но это не так. Честно говоря, вы можете иметь любой контракт NFT и изменять его в соответствии со своими прихотями. Ничего не изменится.
В этом контракте мы наследуем от ERC721
, ERC721URIStorage
и Ownable
. Последний контракт был объяснен в предыдущем разделе. Два других не представляют собой ничего особенного. Как и ERC20, конвенция ERC721 также требует, чтобы токены соответствовали определенным нормам. В основном это означает, что в контракте токена должны быть реализованы определенные функции. Они могут использоваться не так часто, но все же они должны присутствовать, чтобы токен назывался «ERC721». Токены, созданные на основе этого контракта, называются «NFT», если это еще не ясно.
contract TestNft is ERC721, ERC721URIStorage, ERC721Burnable, Ownable {
...
function safeMint(address to, string memory uri) public onlyOwner {
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}
...
}
Чтобы майнить новый NFT, вы используете функцию safeMint
(передавая адрес, который будет владельцем этого NFT и URL).
contract TestNft is ERC721, ERC721URIStorage, ERC721Burnable, Ownable {
...
function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
...
}
И наконец, чтобы просмотреть записанный URl НМТ, вы используете функцию tokenURI
. Это так просто. Мы используем здесь счетчик для отслеживания идентификаторов токенов. Вы можете заменить его переменной uint256
, если хотите, чтобы идентификаторы токенов NFT начинались с определенного числа.
Не совсем обычный контракт на рынке NFT
А теперь главный босс — контракт NFT marketplace. Как правило, многие проекты не хотят иметь 3 отдельных контракта. Они могут даже не захотеть использовать пользовательский токен и вместо этого обойтись ETH. Что касается торговой площадки, они могут объединить контракт NFT с контрактом торговой площадки и все. На самом деле, это даже решит проблему, которую мы обсудим в этом разделе. Но где в этом удовольствие?
При таком подходе остается возможность для совершенствования. Например, вы можете захотеть расширить торговую площадку на более чем один проект NFT и токен ERC20 (и есть много текущих проектов, делающих это), вашему проекту может понадобиться использовать более одного токена ERC20 наряду с ETH для транзакций NFTS (то, что мы рассмотрим в одной из будущих статей), или вы даже можете захотеть включить сервис Oracle для чего угодно — возможностей много.
Однако в этой статье мы будем использовать один контракт NFT с одним пользовательским токеном ERC20.
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
using Counters for Counters.Counter;
Counters.Counter private _itemIds;
Counters.Counter private _itemsSold;
uint256 private _amountCollected;
address public nftContract;
address public acceptedTokenAddress;
uint256 public listingPrice = 0.1 ether;
struct MarketItem {
uint itemId;
uint256 tokenId;
address seller;
address owner;
uint256 price;
bool isSold;
bool isUpForSale;
bool exists;
}
mapping(uint256 => MarketItem) public idToMarketItem;
event MarketItemCreated (
uint indexed itemId,
uint256 indexed tokenId,
address seller,
address owner,
uint256 price
);
event MarketItemUpForSale (
uint indexed itemId,
uint256 indexed tokenId,
address seller,
address owner,
uint256 price
);
...
}
В строках, следующих за конструктором контракта, мы делаем много вещей, на которые необходимо обратить внимание:
- Мы определяем переменные для хранения адресов токена ERC20, который мы хотим использовать, контракта NFT, который мы хотим использовать, и цены листинга. Первые две переменные легко понять, верно? Цена листинга может быть чем-то новым. Здесь вам нужно задать вопрос: почему я позволяю кому-либо бесплатно размещать объявления на моей торговой площадке? Что в этом есть для вас? Цена листинга — это минимальная цена, которую другие должны заплатить (в указанном токене ERC20) за использование вашей торговой площадки. По сути, это одноразовая плата.
- Мы объявляем переменную
_amountCollected
. Она используется для подсчета того, сколько токенов ERC20 было собрано контрактом со всего листинга. Затем это количество может быть выведено владельцем контракта на свой счет в качестве оплаты за предоставление услуг маркетплейса. - Структура
MarketItem
. Она используется для хранения информации о перечисленных НФТ. Такие вещи, какprice
,owner
,seller
,tokenID
НМТ в контракте НМТ, ID НМТ в этом маркетплейсе (itemID
). Возможно, вы думаете, в чем здесь разница между владельцем и продавцом. Это объясняется в последующих разделах. - Мы также объявляем отображение, которое будет использоваться для хранения всех рыночных товаров.
- Мы также объявляем два события — одно для того, чтобы объявить о появлении нового товара, а другое — для того, чтобы выставить товар на продажу. Идея заключается в том, что даже если предмет внесен в список, он может быть не выставлен на продажу. Владелец (который, возможно, купил товар на этой торговой площадке) может захотеть придержать его.
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
constructor(address _nftContract, address _acceptedTokenAddress) {
nftContract = _nftContract;
acceptedTokenAddress = _acceptedTokenAddress;
}
...
}
В конструкторе мы передаем токен ERC20, который мы хотим принять для транзакций с НФТ на этой торговой площадке, и контракт НФТ, к которому эти НФТ должны принадлежать.
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external override returns (bytes4){
return this.onERC721Received.selector;
}
...
}
Существует функция onERC721Received
, которая не обсуждается во многих статьях и руководствах по маркетплейсу. Это важная функция, и ее применение является лучшей практикой, поскольку она позволяет безопасно передавать NFT. Это также является проблемой, о которой мы говорили в предыдущей части. Когда вы объединяете контракт NFT с контрактом Marketplace, этой проблемы удается избежать, поскольку наследование ERC721 обеспечивает эту функцию (спорим, вы этого не знали?). В нашем случае, для пользовательского маркетплейса, нам нужно реализовать ее самостоятельно. Это простая функция, которая не делает ничего, кроме как возвращает свой селектор в контракт NFT, когда инициируется передача из контракта NFT в этот контракт marketplace.
Функция onERC721Received
является защитной, поскольку контракты NFT, наследующие ERC721, не разрешают передачу на адрес контракта, так как этот контракт может не иметь функциональности для передачи на другие адреса NFT, которые он получает. Когда инициируется передача прав собственности на NFT, контракт NFT вызывает эту функцию (обратите внимание, что это внешняя функция, то есть она не может быть вызвана контрактом на рынке, а только внешними агентами). Это позволяет нам использовать функцию safeTransferFrom
, которая обеспечивает дополнительные гарантии для передачи прав собственности NFT. Таким образом, onERC721Received — это часто просматриваемая важная функция, которая должна присутствовать в NFT marketplace.
После этого у нас есть 3 функции, которые составляют суть контракта — addItemToMarket
, createSale
и buyItem
. Они используются для добавления НМТ в marketplace, создания продажи этого НМТ и разрешения пользователям купить этот НМТ соответственно. В зависимости от вашего опыта работы с подобными вещами, у вас может возникнуть ощущение, что чего-то не хватает. Вы правы. Я не определил ни одной функции для исключения товара из списка товаров на рынке. Я оставляю это на ваше усмотрение. Я, конечно, помогу вам, объяснив логику для этого в конце. Но перед этим давайте обсудим три основные функции.
addItemToMarket
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
function addItemToMarket(
uint256 tokenId,
uint256 price
) public nonReentrant {
require(price > 0, "Price must be at least 1 wei");
require(price >= listingPrice, "Price should be at least same as listing price");
_itemIds.increment();
uint256 itemId = _itemIds.current();
idToMarketItem[itemId] = MarketItem(
itemId,
tokenId,
msg.sender,
address(0),
price,
false,
false,
true
);
IERC20(acceptedTokenAddress).transferFrom(msg.sender, address(this), listingPrice);
IERC721(nftContract).safeTransferFrom(msg.sender, address(this), tokenId);
_amountCollected += listingPrice;
emit MarketItemCreated(
itemId,
tokenId,
msg.sender,
address(0),
price
);
}
...
}
Эта функция используется для — и вы правильно догадались — добавления NFT из нашего контракта NFT на этот рынок. Вызывающий передает ID токена NFT в нашем смарт-контракте NFT вместе с ценой, которую он/она (никогда не знаешь, когда робот может обидеться) хочет получить.
Здесь мы вводим 1 проверку — цена должна быть выше или равна цене листинга. Цена листинга вычитается из цены в этой функции, и оставшаяся цена — это цена, по которой NFT выставлен на продажу.
Мы увеличиваем счетчик _itemIds
, чтобы получить ID элемента этого контракта для данного НМТ, и используем его для создания рыночного элемента через структуру, которую мы объявили ранее. Таким образом, НМТ, размещенный на торговой площадке, имеет свой ID токена из родного контракта, ID элемента из контракта торговой площадки, записанный вместе с msg.sender
, установленным как владелец, и этот контракт, установленный как продавец. Также установлены 3 булевых значения, которые утверждают, что товар никогда не продавался, не выставлен на продажу и существует соответственно. Последнее значение предназначено для проверки в последующих функциях.
После определения рыночного предмета в контракте мы облегчаем передачу. Если вызывающая функция не является владельцем, выполнение завершится, а транзакция завершится или вернется. Как новичок, вы можете подумать: «Но ведь товар уже создан на рынке!», но дело в том, что выполнение функций — это атомарные транзакции: они либо выполняются полностью, либо нет.
После завершения передачи данных мы записываем сумму, которую мы собрали за счет взимаемой нами платы за листинг, а затем выдаем событие о том, что товар был создан. На этом процесс листинга НМТ на рынке завершен.
createSale
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
function createSale(
uint256 itemId
) public nonReentrant {
MarketItem memory item = idToMarketItem[itemId];
require(item.owner == msg.sender, "Only Item owner can create sale.");
require(item.exists == true, "Item does not exist.");
idToMarketItem[itemId].isUpForSale = true;
emit MarketItemUpForSale (
itemId,
item.tokenId,
msg.sender,
item.seller,
item.price
);
}
...
}
Эта функция используется для создания продажи из уже выставленных на продажу НМТ. Здесь пригодится последний bool в определении структуры рыночного элемента. Если товар не внесен в список/создан, то этот bool по умолчанию будет иметь значение false
. Также мы проверяем, что вызывающий является владельцем НМТ (никто не захочет, чтобы его НМТ продавали без его согласия, верно?).
Здесь мы предоставляем владельцу НФТ возможность изменить цену НФТ на случай, если он/она (опять же, обиженные боты) захочет продать его по более высокой цене. Мы завершаем функцию, испуская событие, которое сообщает, что НМТ выставлен на продажу.
buyItem
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
function buyItem(
uint256 itemId,
uint256 itemPrice
) public nonReentrant {
uint price = idToMarketItem[itemId].price;
uint tokenId = idToMarketItem[itemId].tokenId;
bool isUpForSale = idToMarketItem[itemId].isUpForSale;
require(itemPrice >= price, "Asking Price not satisfied!");
require(isUpForSale == true, "NFT not for sale.");
address prevSeller = idToMarketItem[itemId].seller;
idToMarketItem[itemId].owner = msg.sender;
idToMarketItem[itemId].seller = msg.sender;
idToMarketItem[itemId].isSold = true;
idToMarketItem[itemId].isUpForSale = false;
IERC721(nftContract).transferFrom(prevSeller, msg.sender, tokenId);
IERC20(acceptedTokenAddress).transferFrom(msg.sender, prevSeller, itemPrice);
_itemsSold.increment();
}
...
}
В этой функции вызывающий вводит itemID
НМТ на рынке вместе с ценой (itemPrice
), по которой он/она хочет его купить. Обычно никто не хочет платить больше, чем продажная цена НМТ, но мы просто оптимистичны в данном случае, устанавливая проверку, что аргумент itemPrice
должен быть больше или равен цене НМТ (кто знает, может быть, вызывающий чувствует себя щедрым). Есть также важная проверка, действительно ли предмет выставлен на продажу или нет.
Мы храним адрес предыдущего владельца НМТ, а затем меняем и владельца, и продавца на адрес инвокера. Это означает, что при первой покупке НМТ на торговой площадке продавец сменит адрес контракта на адрес инвокера. С этого момента владелец и продавец будут одним целым. Это просто одна из логик, которую я реализовал. Мнения могут быть разными, и у вас может быть другая логика в этом случае.
Мы обновляем флаг isSold
на true, указывая, что NFT уже однажды переходил из рук в руки на этой торговой площадке, а затем меняем флаг isUpForSale
на false, указывая, что новому владельцу теперь нужно вручную создать новую продажу для этого NFT.
Затем мы облегчаем передачу ERC20 Token от покупателя на адрес владельца и NFT от владельца на адрес покупателя. И последнее, но не менее важное, мы увеличиваем счетчик, который у нас есть для отслеживания общего количества проданных товаров.
Если у вас такая же логика присвоения идентификаторов токенов NFT, как и у этого смарт-контракта, то разница между счетчиком идентификаторов токенов и счетчиком общего количества проданных товаров даст вам общее количество непроданных товаров.
Другие нюансы
contract TestMarketplace is Ownable, ReentrancyGuard, IERC721Receiver {
...
function getMarketItemById(uint256 marketItemId) public view returns (MarketItem memory) {
MarketItem memory item = idToMarketItem[marketItemId];
return item;
}
function getUnsoldItems() public view returns (MarketItem[] memory) {
uint itemCount = _itemIds.current();
uint unsoldItemCount = _itemIds.current() - _itemsSold.current();
uint currentIndex = 0;
MarketItem[] memory items = new MarketItem[](unsoldItemCount);
for (uint i = 0; i < itemCount; i++) {
if (!idToMarketItem[i + 1].isSold) {
uint currentId = i + 1;
MarketItem memory currentItem = idToMarketItem[currentId];
items[currentIndex] = currentItem;
currentIndex += 1;
}
}
return items;
}
...
}
Теперь, когда основные моменты рассмотрены, нам необходимо обсудить две другие полезные функции в контракте. Функция getMarketItemById
предоставляет подробную информацию о товаре, если вызывающий предоставляет ID товара, а функция getUnsoldItems
предоставляет непроданные товары на рынке.
Это отличные примеры того, как можно потратить газ впустую по двум причинам: —
- Связка
idToMarketItem
является публичной, поэтому все видно оттуда. - Скорее всего, пользователи будут взаимодействовать с интерфейсом перед этим контрактом, так что эти вещи могут быть вычислены вне цепочки без лишней суеты. Но эй, если ваш клиент хочет быть расточительным, кто вы такой, чтобы возражать? XDДругая вещь, которую вы можете заметить, это модификатор
nonReentrant
в прототипе некоторых функций. Это то, что предотвращает «Reentrancy Attack». Если вы новичок и не знаете об этом, просто знайте, что это очень важно, и обязательно поищите информацию об этом позже. Она обеспечивается наследованием от смарт-контрактаReentrancyGaurd
, предоставляемого OpenZeppelin. Вы также можете подумать, почему функции, в которых мы совершаем операции с токенами ERC20, не объявлены какpayable
? Это потому, что мы совершаем транзакции не в Эфире. Транзакции в Эфире и токенах ERC20 — это две совершенно разные вещи. Я полагаю, что на момент написания статьи в разработке находится EIP, который может изменить это в будущем. Для получения полного списка EIP посетите сайт https://github.com/ethereum/EIPs, и вы найдете их все в каталоге EIPS.
Прежде чем мы закроем тему, я думаю, осталось обсудить еще 2 вопроса —
- Вывод средств из контракта — Все накопленные токены ERC20 должны быть выведены в какой-то момент. Иначе, какой смысл их начислять, если вы не можете их использовать, верно? По этой причине вам необходимо реализовать функцию withdraw, которая будет выводить сумму
_amountCollected
этих токенов ERC20 из контракта на адрес владельца (обязательно реализуйте ее так, чтобы только владелец мог ее вызвать). - Удаление NFT с торговой площадки — Это делается так же просто, как удаление ключа/идентификатора элемента NFT с торговой площадки. Поскольку мы используем отображение, это один из доступных вариантов. Вы также можете пойти на много шагов дальше и сделать маркетплейс, который может принимать несколько контрактов и токенов NFT.Завершение работыКод этого руководства доступен на моем GitHub здесь В этой статье мы увидели, как легко можно создать токен ERC 20 для транзакций с NFT типа ERC 721 на пользовательском контракте маркетплейса. Надеюсь, не все прошло мимо вашей головы. Есть несколько тем, которые я хотел бы рассмотреть в будущих статьях, например, тестирование в hardhat (с использованием этих контрактов), продвижение этого маркетплейса на шаг вперед и использование Uniswap для добавления функции покупки через несколько токенов, а также многое другое. Надеюсь, вам понравилось читать все это, и если у вас есть предложения, не стесняйтесь связаться со мной через Twitter или по моей почте (abhik@abhikbanerjee.com).
До тех пор, продолжайте верить в Web3 и WAGMI !!!