Web3 开发实战:基于 Ethers.js 与 Hardhat 的智能合约全栈开发指南

656 阅读5分钟

引言

Web3 开发正在重塑互联网的底层架构,为开发者提供了构建去中心化应用(DApps)的全新范式。本文将深入探讨如何使用现代工具链(Ethers.js + Hardhat)进行智能合约开发、测试与部署的全过程,涵盖从基础概念到高级实践的各个细节。

第一部分:环境配置与工具链搭建

1.1 开发环境准备

# 初始化项目
mkdir web3-dapp && cd web3-dapp
npm init -y

# 安装核心依赖
npm install --save-dev hardhat ethers @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers

# 创建Hardhat项目
npx hardhat

选择"Create a basic sample project"并确认所有选项,这将生成以下目录结构:

contracts/ - Solidity智能合约源文件
scripts/ - 部署脚本
test/ - 测试文件
hardhat.config.js - Hardhat配置文件

1.2 配置多网络支持

修改hardhat.config.js以支持多网络部署:

require('@nomiclabs/hardhat-waffle');

const INFURA_PROJECT_ID = 'your-infura-project-id';
const PRIVATE_KEY = 'your-metamask-private-key';

module.exports = {
  solidity: "0.8.4",
  networks: {
    hardhat: {
      chainId: 1337
    },
    ropsten: {
      url: `https://ropsten.infura.io/v3/${INFURA_PROJECT_ID}`,
      accounts: [PRIVATE_KEY]
    },
    mainnet: {
      url: `https://mainnet.infura.io/v3/${INFURA_PROJECT_ID}`,
      accounts: [PRIVATE_KEY]
    }
  }
};

第二部分:智能合约开发详解

2.1 编写ERC20代币合约

创建contracts/MyToken.sol

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

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract MyToken is ERC20, Ownable {
    uint8 private _decimals;
    
    constructor(
        string memory name_,
        string memory symbol_,
        uint8 decimals_,
        uint256 initialSupply_
    ) ERC20(name_, symbol_) {
        _decimals = decimals_;
        _mint(msg.sender, initialSupply_ * 10**decimals_);
    }
    
    function decimals() public view override returns (uint8) {
        return _decimals;
    }
    
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
    
    function burn(uint256 amount) public {
        _burn(msg.sender, amount);
    }
}

关键点解析:

  1. 使用OpenZeppelin标准库确保安全性
  2. 自定义小数位数处理
  3. 实现铸币(mint)和销毁(burn)功能
  4. 采用Ownable模式进行权限控制

2.2 编写NFT合约(ERC721)

创建contracts/MyNFT.sol

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract MyNFT is ERC721 {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    
    constructor() ERC721("MyNFT", "MNFT") {}
    
    function mintNFT(address recipient, string memory tokenURI) 
        public 
        returns (uint256)
    {
        _tokenIds.increment();
        uint256 newItemId = _tokenIds.current();
        _mint(recipient, newItemId);
        _setTokenURI(newItemId, tokenURI);
        return newItemId;
    }
}

第三部分:测试策略与实现

3.1 编写ERC20测试用例

创建test/MyToken.test.js

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("MyToken Contract", function () {
  let Token;
  let hardhatToken;
  let owner;
  let addr1;
  let addr2;
  let addrs;

  beforeEach(async function () {
    Token = await ethers.getContractFactory("MyToken");
    [owner, addr1, addr2, ...addrs] = await ethers.getSigners();
    
    hardhatToken = await Token.deploy(
      "My Token",
      "MTK",
      18,
      1000000
    );
  });

  describe("Deployment", function () {
    it("Should assign the total supply of tokens to the owner", async function () {
      const ownerBalance = await hardhatToken.balanceOf(owner.address);
      expect(await hardhatToken.totalSupply()).to.equal(ownerBalance);
    });
    
    it("Should set the correct decimals", async function () {
      expect(await hardhatToken.decimals()).to.equal(18);
    });
  });

  describe("Transactions", function () {
    it("Should transfer tokens between accounts", async function () {
      await hardhatToken.transfer(addr1.address, 100);
      const addr1Balance = await hardhatToken.balanceOf(addr1.address);
      expect(addr1Balance).to.equal(100);
      
      await hardhatToken.connect(addr1).transfer(addr2.address, 50);
      const addr2Balance = await hardhatToken.balanceOf(addr2.address);
      expect(addr2Balance).to.equal(50);
    });

    it("Should fail if sender doesn't have enough tokens", async function () {
      const initialOwnerBalance = await hardhatToken.balanceOf(owner.address);
      
      await expect(
        hardhatToken.connect(addr1).transfer(owner.address, 1)
      ).to.be.revertedWith("ERC20: transfer amount exceeds balance");
      
      expect(await hardhatToken.balanceOf(owner.address)).to.equal(
        initialOwnerBalance
      );
    });
  });

  describe("Minting", function () {
    it("Should mint new tokens", async function () {
      const initialSupply = await hardhatToken.totalSupply();
      await hardhatToken.mint(addr1.address, 1000);
      expect(await hardhatToken.totalSupply()).to.equal(initialSupply.add(1000));
    });
    
    it("Should reject minting from non-owner", async function () {
      await expect(
        hardhatToken.connect(addr1).mint(addr1.address, 1000)
      ).to.be.revertedWith("Ownable: caller is not the owner");
    });
  });
});

3.2 高级测试技巧

  1. 使用hardhat-network-helpers模拟时间流逝:
const helpers = require("@nomicfoundation/hardhat-network-helpers");

// 快进1小时
await helpers.time.increase(3600);

// 快进到特定时间戳
await helpers.time.increaseTo(1640995200);
  1. Gas消耗测试:
const tx = await hardhatToken.transfer(addr1.address, 100);
const receipt = await tx.wait();
console.log("Gas used:", receipt.gasUsed.toString());

第四部分:部署脚本与前端集成

4.1 编写自动化部署脚本

创建scripts/deploy.js

async function main() {
  const [deployer] = await ethers.getSigners();
  
  console.log("Deploying contracts with the account:", deployer.address);
  console.log("Account balance:", (await deployer.getBalance()).toString());

  // 部署ERC20代币
  const Token = await ethers.getContractFactory("MyToken");
  const token = await Token.deploy(
    "Web3 Token",
    "WEB3",
    18,
    1000000
  );
  await token.deployed();
  console.log("Token address:", token.address);
  
  // 部署NFT合约
  const NFT = await ethers.getContractFactory("MyNFT");
  const nft = await NFT.deploy();
  await nft.deployed();
  console.log("NFT address:", nft.address);
  
  // 保存部署地址到前端可以访问的文件
  const fs = require("fs");
  const contractsDir = __dirname + "/../frontend/src/contracts";
  
  if (!fs.existsSync(contractsDir)) {
    fs.mkdirSync(contractsDir);
  }
  
  fs.writeFileSync(
    contractsDir + "/contract-addresses.json",
    JSON.stringify({
      Token: token.address,
      NFT: nft.address
    }, undefined, 2)
  );
  
  // 保存ABI
  const tokenArtifact = artifacts.readArtifactSync("MyToken");
  const nftArtifact = artifacts.readArtifactSync("MyNFT");
  
  fs.writeFileSync(
    contractsDir + "/Token.json",
    JSON.stringify(tokenArtifact, null, 2)
  );
  fs.writeFileSync(
    contractsDir + "/NFT.json",
    JSON.stringify(nftArtifact, null, 2)
  );
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

4.2 前端集成(Ethers.js + React)

安装前端依赖:

npm install ethers @web3-react/core @web3-react/injected-connector

创建React连接器:

// src/connectors.js
import { InjectedConnector } from '@web3-react/injected-connector';

export const injected = new InjectedConnector({
  supportedChainIds: [1, 3, 4, 5, 42, 1337]
});

创建自定义hook:

// src/hooks/useWeb3.js
import { useWeb3React } from '@web3-react/core';
import { injected } from '../connectors';
import { useEffect, useState } from 'react';
import TokenABI from '../contracts/Token.json';
import NFTABI from '../contracts/NFT.json';

export function useWeb3() {
  const { active, account, library, connector, activate, deactivate } = useWeb3React();
  const [tokenContract, setTokenContract] = useState(null);
  const [nftContract, setNFTContract] = useState(null);
  const [balance, setBalance] = useState('0');
  
  useEffect(() => {
    const init = async () => {
      try {
        await activate(injected);
      } catch (err) {
        console.error(err);
      }
    };
    
    if (!active) {
      init();
    }
  }, [active, activate]);
  
  useEffect(() => {
    if (account && library) {
      // 初始化合约实例
      const token = new library.eth.Contract(
        TokenABI.abi,
        TokenABI.networks[library._network.chainId]?.address
      );
      
      const nft = new library.eth.Contract(
        NFTABI.abi,
        NFTABI.networks[library._network.chainId]?.address
      );
      
      setTokenContract(token);
      setNFTContract(nft);
      
      // 获取余额
      token.methods.balanceOf(account).call()
        .then(bal => setBalance(bal.toString()));
    }
  }, [account, library]);
  
  const disconnect = () => {
    deactivate();
    setTokenContract(null);
    setNFTContract(null);
  };
  
  const mintToken = async (amount) => {
    return tokenContract.methods.mint(account, amount)
      .send({ from: account });
  };
  
  const mintNFT = async (tokenURI) => {
    return nftContract.methods.mintNFT(account, tokenURI)
      .send({ from: account });
  };
  
  return {
    active,
    account,
    balance,
    tokenContract,
    nftContract,
    disconnect,
    mintToken,
    mintNFT
  };
}

第五部分:高级开发技巧

5.1 Gas优化策略

  1. 批量操作模式:
function batchTransfer(
    address[] calldata recipients, 
    uint256[] calldata amounts
) external {
    require(recipients.length == amounts.length, "Arrays length mismatch");
    
    for (uint i = 0; i < recipients.length; i++) {
        _transfer(msg.sender, recipients[i], amounts[i]);
    }
}
  1. 使用EIP-712结构化签名:
function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v,
    bytes32 r,
    bytes32 s
) external {
    require(deadline >= block.timestamp, "Expired permit");
    
    bytes32 structHash = keccak256(
        abi.encode(
            PERMIT_TYPEHASH,
            owner,
            spender,
            value,
            nonces[owner]++,
            deadline
        )
    );
    
    bytes32 hash = keccak256(
        abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            structHash
        )
    );
    
    address signer = ecrecover(hash, v, r, s);
    require(signer == owner, "Invalid signature");
    
    _approve(owner, spender, value);
}

5.2 安全最佳实践

  1. 重入攻击防护:
// 使用Checks-Effects-Interactions模式
function withdraw() external nonReentrant {
    uint amount = balances[msg.sender];
    require(amount > 0, "No balance");
    
    balances[msg.sender] = 0; // 先更新状态
    
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}
  1. 签名验证:
function verify(
    address signer,
    bytes32 hash,
    bytes memory signature
) internal pure returns (bool) {
    require(signature.length == 65, "Invalid signature length");
    
    bytes32 r;
    bytes32 s;
    uint8 v;
    
    assembly {
        r := mload(add(signature, 32))
        s := mload(add(signature, 64))
        v := byte(0, mload(add(signature, 96)))
    }
    
    if (v < 27) v += 27;
    require(v == 27 || v == 28, "Invalid signature version");
    
    return signer == ecrecover(hash, v, r, s);
}

结语

Web3开发是一个快速发展的领域,本指南涵盖了从智能合约开发到前端集成的完整流程。随着生态系统的成熟,开发者需要持续关注EIP提案、Layer2解决方案和新的开发工具。建议定期查阅OpenZeppelin文档、Hardhat更新和以太坊官方博客,以保持技术栈的更新。

通过本文介绍的技术栈和最佳实践,您已经具备了构建安全、高效的Web3应用程序的基础能力。下一步可以探索更复杂的DeFi协议开发、DAO治理机制或跨链互操作性解决方案。