banner
欧雷

欧雷流 on-Ch@iN

🫣
follow

How did a complete novice in smart contracts complete their first dApp - NFT market?

As an older front-end engineer who has been pessimistic about the traditional internet industry and web front-end development, I have been looking for a way to maximize the use of my existing knowledge and skills—choosing to transition to full-stack development in the Web3 field.

At the time I made this decision, I didn't know much about Web3, whether from the perspective of a practitioner or an ordinary user. I urgently needed a way to quickly get started!

By chance, I learned about OpenBuild’s “Web3 Frontend Bootcamp,” which seemed to meet my needs based on the content description, so I signed up without hesitation—it's free, and there's "money" to earn, why hesitate!

The content of this article is a practical note from the bootcamp course, focusing on "how a complete novice in smart contract development with a front-end background can develop their first NFT market dApp." In other words, it will cover task 3, task 4, and task 5.

Since I am a beginner just transitioning to Web3, I don't understand many things, and the following content only represents my personal understanding. Please feel free to point out any mistakes or omissions.

I believe the name "smart contract" comes from the business role it plays, and for developers, it is merely a software program that needs to be written in some programming language.

Therefore, to write Ethereum smart contracts, one must learn and understand Solidity syntax, ERC standards, and on-chain interaction processes. Once these are understood, the code can be written correctly, and the remaining task is deployment.

Learning Solidity#

For someone with rich programming experience, it is easy to see that Solidity is an object-oriented static typed language. Although there are some unfamiliar keywords, it doesn't prevent me from viewing it as a "class" dressed in the guise of a "contract."

Thus, if one is familiar with typed class-based programming languages like TypeScript or Java, they can quickly gain a preliminary understanding of Solidity by establishing a mapping relationship.

The contract keyword can be considered a domain-specific variant of the class keyword, expressing the concept of "contract" more semantically. Therefore, writing a contract is equivalent to writing a class.

State variables are used to store data within the contract, akin to member variables of a class, i.e., class properties.

Functions can be defined both inside and outside the contract—the former is equivalent to member functions of a class, i.e., class methods; the latter are ordinary functions, usually utility functions.

Unlike TypeScript and Java, in Solidity, visibility modifiers are not placed at the front, and their positions are inconsistent for variables and functions, which is somewhat counterintuitive.

The semantics of private and public are the same as in other languages, but there is no protected; instead, there is internal, and there is also an external modifier indicating that it is only for external calls.

Function modifiers are akin to TypeScript decorators or Java annotations, allowing for aspect-oriented programming, i.e., AOP; both functions and function modifiers can be overridden by derived contracts.

The following types can be seen as objects in ES, but their usage scenarios differ:

  • Structs (struct) are used to define entities;
  • Enums (enum) are a collection of limited options;
  • Mappings (mapping) are for unlimited options.

Solidity supports multiple inheritance and function polymorphism, allowing for better composition and reuse; since contract development tends to be ERC-driven, the side effects of multiple inheritance should not be as severe as in other languages.

Given that Solidity is born for blockchain, and considering the characteristics of blockchain itself and its application scenarios, supporting events and external communication, as well as rolling back previous operations when encountering errors, can be said to be "essential." Therefore, the syntax supports event and error-related handling.

The usage of the require() function is also somewhat special to me; require(initialValue > 999, "Initial supply must be greater than 999."); is equivalent to the following concise semantic version of ES code:

if (initialValue <= 999) {
  throw new Error('Initial supply must be greater than 999.');
}

Understanding ERC#

In Ethereum, "ERC" stands for "Ethereum Request for Comments," which is a type of EIP (Ethereum Improvement Proposal) that defines standards and conventions related to smart contract applications.

Since Web3 advocates decentralization and openness, ensuring the interoperability of smart contract applications has become a basic requirement, making ERC standards very important.

The most basic ERCs in Ethereum smart contract application development are as follows:

  • ERC-20—fungible tokens, serving as the infrastructure for class financial systems, such as virtual currencies and contribution points;
  • ERC-721—non-fungible tokens (NFTs), serving as the infrastructure for identity systems, such as medals, certificates, and tickets.

In fact, ERCs can be seen as authoritative API documentation.

Writing Smart Contracts#

When developing smart contract applications, it is necessary to choose a framework to assist, and it seems that Hardhat and Foundry are commonly used—I chose the former because it is friendly to the JS tech stack, making it suitable for those transitioning from front-end development.

Regarding IDE selection, many people use the Remix provided by Ethereum, while I continue to use VS Code, mainly to minimize learning costs when just getting started.

If you are unfamiliar with Hardhat, you can selectively follow the official tutorial to set up the running environment step by step. In the generated directory structure, apart from the hardhat.config.ts configuration file, you mainly need to focus on four folders and their files:

  • contracts—smart contract source code;
  • artifacts—compiled files generated by hardhat compile;
  • ignition—used for deploying smart contracts based on Hardhat Ignition;
  • test—smart contract functional test code.

The ignition folder will also generate compiled files, but unlike artifacts, these are bound to the target chain being deployed, meaning they are generated in the folder corresponding to the chain ID to be deployed.

The three tasks for the bootcamp assignment all involve the ERC-20 token, ERC-721 token, and NFT market contracts, where the first two token contracts can leverage the verified OpenZeppelin Contracts for extension.

Here is the implementation code for my ERC-20 token RaiCoin:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract RaiCoin is ERC20("RaiCoin", "RAIC") {
  constructor(uint256 initialValue) {
    require(initialValue > 999, "Initial supply must be greater than 999.");
    _mint(msg.sender, initialValue * 10 ** 2);
  }

  function decimals() public view virtual override returns (uint8) {
    return 2;
  }
}

It is best to mint a certain amount of tokens (usually a large number) at initialization and set the owner to your own account address; otherwise, when trading later, it will prompt that there is no balance, making it more troublesome to handle.

The msg.sender in the constructor() is actually the account address deploying the contract; if you deploy it with your own account address, then the initial tokens will all go into your account.

Since my ERC-20 token is just for fun and will not appreciate, I can consider overriding the decimals() in OpenZeppelin and setting the value a bit lower.

Below is the implementation code for the ERC-721 token RaiE:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract RaiE is ERC721 {
  uint256 private _totalCount;

  constructor() ERC721("RaiE", "RAIE") {
    _totalCount++;
  }

  function mint() external returns (uint256) {
    uint256 tokenId = _totalCount;

    _safeMint(msg.sender, tokenId);

    _totalCount++;

    return tokenId;
  }
}

I only implemented a mint() function, which does not take any parameters and simply mints tokens. Why is that? Shouldn't NFTs have corresponding images? The specific reason will be explained later.

These two token contracts are quite straightforward, requiring minimal code, while the real considerations mainly focus on the NFT market contract, such as—

Should the NFT list in the market be paginated?

If paginated, the delay during each page turn will be quite noticeable, leading to a poor user experience; but if not paginated, having a large number of NFTs will also cause similar issues.

Where should the NFT image URLs be stored? In the NFT contract or the market contract?

Theoretically, they should be stored in the NFT contract, but if so, accessing the NFT contract frequently through external calls when retrieving the NFT list would affect performance and user experience.

Should the NFT contract maintain a list of "who owns which tokens" that can be accessed externally?

If yes, the data would be redundant compared to the market contract, making the NFT contract appear bloated; if no, it would be impossible to explicitly know which tokens exist and who they belong to.

It can be seen that relying solely on blockchain-related technology to create a product-level application has significant limitations, and the user experience will be very poor!

In other words, the performance and experience of the product still need to rely on traditional application architecture, with blockchain serving merely as identity verification and a "backup" for some data.

Therefore, I temporarily abandoned the product-oriented mindset and shifted to focusing on meeting the assignment requirements—just having the relevant functionality is sufficient.

This made decision-making much easier—whatever can complete the assignment faster is the way to go! Thus, the three questions above were quickly resolved:

  • The NFT list in the market will not be paginated—there will only be a few NFTs;
  • The NFT image URLs will be stored in the market contract—the NFT contract will only be used by its own market contract;
  • The NFT contract will not maintain a list of token ownership—temporary operations can remember which account minted which token.

While implementing the NFT market RaiGallery, I found that only arrays can be traversed; mapping cannot, and arrays with specified lengths during initialization cannot use .push() to add elements, only indexed access:

contract RaiGallery {
  struct NftItem { address seller; address nftContract; uint tokenId; string tokenUrl; uint256 price; uint256 listedAt; bool listing; }

  struct NftIndex { address nftContract; uint tokenId; }

  NftIndex[] private _allNfts;

  function getAll() external view returns (NftItem[] memory) {
    // An array with specified length during initialization
    NftItem[] memory allItem = new NftItem[](_allNfts.length);
    NftIndex memory nftIdx;

    for (uint256 i = 0; i < _allNfts.length; i++) {
      nftIdx = _allNfts[i];
      // Using `allItem.push(_listedNfts[nftIdx.nftContract][nftIdx.tokenId])` here would throw an error
      allItem[i] = _listedNfts[nftIdx.nftContract][nftIdx.tokenId];
    }

    return allItem;
  }
}

Debugging Smart Contracts#

After writing the smart contract source code, the next step is to write test code to expose and resolve some basic issues.

As mentioned earlier, in a Hardhat project, test code is placed in the test folder, with each file typically corresponding to a contract. Of course, reusable logic between different files can also be extracted into additional files, such as helper.ts.

The test code is written based on the Mocha and Chai APIs. Before actually testing the contract functionality, the contract needs to be deployed to a local environment, which can be the built-in hardhat or a local node localhost; I chose the former for now.

At this point, the deployment method can reuse the Hardhat Ignition module, but I haven't figured out how to use it yet, so I opted for the more understandable loadFixture().

Testing is quite time-consuming; it feels like I spent almost a whole day on it. However, during this process, I gained a deeper understanding of how the ERC-20 token, ERC-721 token, NFT market, and users should interact, such as:

  • If you directly call methods using the contract instance, the caller is the contract itself. You need to use contractInstance.connect(someAccount) before calling to simulate operations with users;
  • The owner of the NFT must call .setApprovalForAll(marketContractAddress, true) to authorize all their NFTs to the NFT market before listing them for sale.

Once I felt that the unilateral testing of the smart contract was sufficient, it was time to deploy it to a local node and conduct joint debugging with the front end, which would require using the Hardhat Ignition module.

When I looked at the documentation for learning, I found it somewhat obscure and hard to understand; it made me feel sleepy as I read it. However, looking back now, each module essentially describes how to initialize the corresponding contract when deploying that module.

Hardhat Ignition supports submodules, which can be used with .useModule(), allowing for the handling of submodules during compilation and deployment. This means—

Suppose I have three modules: RaiCoin.ts, RaiE.ts, and RaiGallery.ts, where RaiGallery.ts needs the address returned by the deployment of RaiCoin.ts. In that case, I can treat RaiCoin.ts as a submodule of RaiGallery.ts:

import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';
import RaiCoin from './RaiCoin';

export default buildModule('RaiGallery', m => {
  const { coin } = m.useModule(RaiCoin);
  const gallery = m.contract('RaiGallery', [coin]);

  return { gallery };
});

In this way, RaiE.ts is deployed separately, while deploying RaiGallery.ts will cascade the deployment of RaiCoin.ts, so only two deployment commands need to be executed.

Next, I modified the defaultNetwork configuration in hardhat.config.ts to 'localhost', executed npx hardhat node in the root directory of the Hardhat project to start the local node, and then opened another terminal window to deploy the smart contracts:

  • Execute npx hardhat ignition deploy ./ignition/modules/RaiE.ts to deploy the ERC-721 token contract;
  • Execute npx hardhat ignition deploy ./ignition/modules/RaiGallery.ts to deploy the ERC-20 token contract and the NFT market contract.

Once all deployments are successful, compiled contract-related files will be generated in the ignition/deployments/chain-31337 folder (where "31337" is the chain ID of the local node):

  • The deployed_addresses.json lists the contract addresses;
  • The JSON files in the artifacts folder contain the contract's ABI.

These two key pieces of information need to be copied and pasted into the global shared variables of the front-end project for use during joint debugging.

Before starting joint debugging, two things need to be done in the MetaMask wallet:

The third-party libraries and frameworks I relied on for the front end mainly include Vite, React, Ant Design Web3, and Wagmi; since the front end is what I am familiar with, I won't elaborate further.

However, while developing the front end, there was one point that troubled me for a while—

Although the program requires minting a new NFT before listing it for sale in the market, the interface should reflect this in one step, meaning that after filling in the NFT-related information and clicking "Confirm," it should be listed directly.

However, the assignment requires two steps: minting first and then listing, which I found somewhat unreasonable or poor in user experience.

In the end, due to my unfamiliarity with using Wagmi and my eagerness to submit the assignment, I didn't continue to dwell on it… 😂😂😂

If you encounter issues during joint debugging, you can follow these steps to troubleshoot:

  1. When listing an NFT for sale, you must first call the NFT token contract's setApprovalForAll to authorize the market contract to manage the transfer of the NFT;
  2. Before sending the listing request, you need to use viem or ethers' parseUnits to convert it to a number that conforms to the decimals() defined in your ERC-20 token contract (default is 18);
  3. Before purchasing an NFT, check the balance of your custom ERC-20 tokens in the wallet to ensure it is sufficient, to avoid mistaking Ether (ETH) for the balance of your ERC-20 tokens;
  4. When purchasing an NFT, you must first call your ERC-20 token contract's approve to authorize the market contract to transfer tokens.

Joint debugging is also complete, and finally, it’s time for the last step—deploying to the Sepolia test network!

This requires having Sepolia Ether, and a common way to obtain it is to drip it from those "faucets," where you can only get a little each day. Fortunately, @Mika-Lahtinen provided a PoW method, detailed in @zer0fire’s notes “🚀 Simplified Faucet Tutorial - No Transaction Records or Account Balance Needed.”

At this point, turning back to the Hardhat project, I opened the hardhat.config.ts file and temporarily changed defaultNetwork to 'sepolia', adding a sepolia entry in networks:

const config: HardhatUserConfig = {
  defaultNetwork: 'sepolia',  // Temporarily change the default network to this
  networks: {
    sepolia: {
      url: 'your Sepolia endpoint URL',
      accounts: ['your wallet account private key'],
    },
  },
};

The Sepolia endpoint can be obtained by registering for an account with Infura or Alchemy.

Then, follow the same process as deploying to the local node, and once the functionality in the front end is verified in the test network environment, you can submit the assignment!

Conclusion#

I have open-sourced all the code related to the NFT market dApp at ourai/my-first-nft-market and plan to resolve the points I struggled with in the text and turn it into a benchmark for such demos in the future.

Since the Sepolia contract address is already configured inside, it can be run locally directly. Feel free to refer to it, discuss, and provide feedback.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.