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 even "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.

As 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 its business function, 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 those with rich programming experience, it is easy to see that Solidity is an object-oriented static type language. Although there are some unfamiliar keywords, it does not 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 seen as a domain-specific variant of the class keyword, semantically expressing the concept of "contract," so writing a contract is equivalent to writing a class.

State variables are used to store data within the contract, equivalent 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 is a regular function, usually some 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 additionally, there is an external modifier indicating that it is only for external calls.

Function modifiers are equivalent 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 viewed as objects in ES, but their usage scenarios differ:

  • Structs (struct) are used to define entities;
  • Enums (enum) are a collection of finite options;
  • Mappings (mapping) represent infinite 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 considered a "necessity." Therefore, the syntax level 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 concise semantic version of the following 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 fundamental 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, which is beneficial for those transitioning from front-end development.

In terms of IDE selection, many people use the Remix provided by Ethereum, while I continue to use VS Code, mainly to minimize learning costs when just starting out.

If you are not familiar with Hardhat, you can follow the official tutorial to selectively set up the environment step by step. In the generated directory structure, aside 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 be extended based on the verified OpenZeppelin Contracts.

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) during 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() above is actually the account address that deploys the contract. If you deploy it using your own account address, the initial tokens will all go into your account.

Since my ERC-20 token is just for fun and won't 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 require minimal coding, and the real areas that need thought mainly focus on the NFT market contract, such as—

Should the NFT list in the market be paginated?

If paginated, the delay when flipping pages will be quite noticeable, leading to a poor user experience; but if not paginated, having many 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 will affect performance and user experience.

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

If so, the data would be redundant compared to the market contract, making the NFT contract appear bloated; if not, 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 at present, and the user experience will be very poor!

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

Therefore, I temporarily abandoned the product-oriented mindset and stopped worrying about whether things were reasonable, shifting to a focus on meeting the assignment requirements first—just having the relevant functionality is enough.

This way, decision-making became much easier—whatever could complete the assignment faster is what I would do! Thus, the three doubts mentioned 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 are iterable; mapping is not, 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 length was specified 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 cause an error
      allItem[i] = _listedNfts[nftIdx.nftContract][nftIdx.tokenId];
    }

    return allItem;
  }
}

Debugging Smart Contracts#

After writing the smart contract source code, I need to write test code to expose and resolve some basic issues.

As mentioned above, in a Hardhat project, test code is placed in the test folder, with each file 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 adopted the more understandable loadFixture().

Testing is quite tedious; it feels like I spent almost a whole day on it. However, during this process, I gained a deeper understanding of how ERC-20 tokens, ERC-721 tokens, the 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 unit testing of the smart contracts was sufficient, it was time to deploy them to the local node and conduct integration testing with the front end. This time, I would need to use the Hardhat Ignition module.

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

Hardhat Ignition supports submodules, which can be used with .useModule(). This allows you to handle submodules when compiling and deploying modules. In other words—

Suppose I have three modules: RaiCoin.ts, RaiE.ts, and RaiGallery.ts, where RaiGallery.ts needs the address returned after deploying RaiCoin.ts. 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 deploy RaiCoin.ts, so only two deployment commands need to be executed.

Next, I modified the defaultNetwork configuration in hardhat.config.ts to 'localhost', then executed npx hardhat node in the root directory of the Hardhat project to start the local node, and 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.

After 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 integration testing.

Before starting integration testing, 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, one point 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.

Yet 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 integration testing, 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 (the default is 18);
  3. Before purchasing an NFT, check the balance of your custom ERC-20 token in the wallet to ensure it is sufficient, to avoid mistaking Ether (ETH) for your ERC-20 token balance;
  4. When purchasing an NFT, you must first call your ERC-20 token contract's approve to authorize the market contract to transfer funds.

Integration testing 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 Required.”

At this point, I turned my attention back to the Hardhat project, opened the hardhat.config.ts file, temporarily changed defaultNetwork to 'sepolia', and added 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 before to deploy to the local node. After verifying the functionality in the front end for the test network environment, I can submit the assignment, yay!

Conclusion#

I have open-sourced all the code related to the NFT market dApp in ourai/my-first-nft-market and plan to resolve the points I struggled with in the text as much as possible, aiming to set a benchmark for this type of demo in the future.

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

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