使用以太坊经典工具库Ethers.js 创建一个区块链DApp应用

原文作者:PaperMoon团队

⚠️ PolkaVM 的以太坊兼容智能合约仍处于早期开发阶段,可能存在不稳定或功能不完整的情况。

去中心化应用(dApp)已经成为 Web3 生态系统的核心组成部分,它允许开发者创建能够直接与区块链网络交互的应用程序。Polkadot Hub 是一条支持智能合约功能的区块链,为 dApp 的部署和交互提供了一个非常理想的平台。

在本教程中,你将构建一个完整的 dApp,用于与部署在 Polkadot Hub 测试网 上的智能合约进行交互。该应用将使用 Ethers.js 作为区块链交互库,并使用 Next.js 作为前端框架。

完成本教程后,你将拥有一个功能齐全的 dApp,支持:
    •    连接用户钱包
    •    从区块链读取数据
    •    发送交易并修改链上状态

前置条件 

在开始之前,请确保你已经具备以下条件:
    •    本地已安装 Node.js v16 或更高版本
    •    一个加密钱包(如 MetaMask),并且钱包中有一些测试代币
👉 详情可参考 Connect to Polkadot 指南
    •    对 React 和 JavaScript 有基本了解
    •    对 区块链概念和 Solidity 有一定了解(非必须,但会有帮助)

项目概览 

该 dApp 将与一个简单的 Storage 合约进行交互。你可以参考 Create Contracts 教程了解如何一步步创建该合约。

该合约支持以下功能:
    •    从区块链读取一个存储的数字
    •    使用新值更新该存储数字

该合约已经部署在 Polkadot Hub 测试网,可直接用于测试:

0x58053f0e8ede1a47a1af53e43368cd04ddcaf66f

如果你希望自行部署合约,可以参考 Deploying Contracts 章节。

下面是你将要构建的应用的简化结构示意图:

项目目录结构

项目最终的目录结构如下所示:

ethers-dapp
├── abis
│   └── Storage.json
└── app
    ├── components
    │   ├── ReadContract.js
    │   ├── WalletConnect.js
    │   └── WriteContract.js
    ├── favicon.ico
    ├── globals.css
    ├── layout.js
    ├── page.js
    └── utils
        ├── contract.js
        └── ethers.js

项目初始化

首先,创建一个新的 Next.js 项目:

npx create-next-app ethers-dapp --js --eslint --tailwind --app --yes
cd ethers-dapp

接下来,安装所需的依赖:

npm install ethers@6.13.5

连接到 Polkadot Hub

为了与 Polkadot Hub 交互,你需要使用 Ethers.js Provider 连接到区块链网络。本示例中我们使用 Polkadot Hub 测试网,以便安全地进行实验。

新建文件 app/utils/ethers.js,并添加如下代码:

app/utils/ethers.js

import { JsonRpcProvider } from 'ethers';

export const PASSET_HUB_CONFIG = {
  name: 'Passet Hub',
  rpc: 'https://testnet-passet-hub-eth-rpc.polkadot.io/', // Passet Hub testnet RPC
  chainId: 420420422, // Passet Hub testnet chainId
  blockExplorer: 'https://blockscout-passet-hub.parity-testnet.parity.io/',
};

export const getProvider = () => {
  return new JsonRpcProvider(PASSET_HUB_CONFIG.rpc, {
    chainId: PASSET_HUB_CONFIG.chainId,
    name: PASSET_HUB_CONFIG.name,
  });
};

// Helper to get a signer from a provider
export const getSigner = async (provider) => {
  if (window.ethereum) {
    await window.ethereum.request({ method: 'eth_requestAccounts' });
    const ethersProvider = new ethers.BrowserProvider(window.ethereum);
    return ethersProvider.getSigner();
  }
  throw new Error('No Ethereum browser provider detected');
};

该文件完成了以下工作:
    •    建立与 Polkadot Hub 测试网 的 RPC 连接
    •    提供获取 Provider(只读) 与 Signer(可写) 的辅助方法

设置智能合约接口

本 dApp 使用的是一个已经部署好的 Storage 合约,因此我们需要其 ABI(应用二进制接口)。

1️⃣ 创建 ABI 文件

在项目根目录下创建 abis 文件夹,并新建 Storage.json 文件,粘贴以下 ABI 内容:

abis/Storage.json

[
    {
        "inputs": [
            {
                "internalType": "uint256",
                "name": "_newNumber",
                "type": "uint256"
            }
        ],
        "name": "setNumber",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {
        "inputs": [],
        "name": "storedNumber",
        "outputs": [
            {
                "internalType": "uint256",
                "name": "",
                "type": "uint256"
            }
        ],
        "stateMutability": "view",
        "type": "function"
    }
]

2️⃣ 创建合约工具文件

新建文件 app/utils/contract.js:

app/utils/contract.js

import { Contract } from 'ethers';
import { getProvider } from './ethers';
import StorageABI from '../../abis/Storage.json';

export const CONTRACT_ADDRESS = '0x58053f0e8ede1a47a1af53e43368cd04ddcaf66f';

export const CONTRACT_ABI = StorageABI;

export const getContract = () => {
  const provider = getProvider();
  return new Contract(CONTRACT_ADDRESS, CONTRACT_ABI, provider);
};

export const getSignedContract = async (signer) => {
  return new Contract(CONTRACT_ADDRESS, CONTRACT_ABI, signer);
};

该文件定义了:
    •    合约地址
    •    ABI
    •    只读合约实例(Provider)
    •    可写合约实例(Signer)

创建钱包连接组件

接下来,我们创建一个用于连接钱包的组件。

新建文件 app/components/WalletConnect.js:

app/components/WalletConnect.js

'use client';

import React, { useState, useEffect } from 'react';
import { PASSET_HUB_CONFIG } from '../utils/ethers';

const WalletConnect = ({ onConnect }) => {
  const [account, setAccount] = useState(null);
  const [chainId, setChainId] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Check if user already has an authorized wallet connection
    const checkConnection = async () => {
      if (window.ethereum) {
        try {
          // eth_accounts doesn't trigger the wallet popup
          const accounts = await window.ethereum.request({
            method: 'eth_accounts',
          });
          if (accounts.length > 0) {
            setAccount(accounts[0]);
            const chainIdHex = await window.ethereum.request({
              method: 'eth_chainId',
            });
            setChainId(parseInt(chainIdHex, 16));
          }
        } catch (err) {
          console.error('Error checking connection:', err);
          setError('Failed to check wallet connection');
        }
      }
    };

    checkConnection();

    if (window.ethereum) {
      // Setup wallet event listeners
      window.ethereum.on('accountsChanged', (accounts) => {
        setAccount(accounts[0] || null);
        if (accounts[0] && onConnect) onConnect(accounts[0]);
      });

      window.ethereum.on('chainChanged', (chainIdHex) => {
        setChainId(parseInt(chainIdHex, 16));
      });
    }

    return () => {
      // Cleanup event listeners
      if (window.ethereum) {
        window.ethereum.removeListener('accountsChanged', () => {});
        window.ethereum.removeListener('chainChanged', () => {});
      }
    };
  }, [onConnect]);

  const connectWallet = async () => {
    if (!window.ethereum) {
      setError(
        'MetaMask not detected! Please install MetaMask to use this dApp.'
      );
      return;
    }

    try {
      // eth_requestAccounts triggers the wallet popup
      const accounts = await window.ethereum.request({
        method: 'eth_requestAccounts',
      });
      setAccount(accounts[0]);

      const chainIdHex = await window.ethereum.request({
        method: 'eth_chainId',
      });
      const currentChainId = parseInt(chainIdHex, 16);
      setChainId(currentChainId);

      // Prompt user to switch networks if needed
      if (currentChainId !== PASSET_HUB_CONFIG.chainId) {
        await switchNetwork();
      }

      if (onConnect) onConnect(accounts[0]);
    } catch (err) {
      console.error('Error connecting to wallet:', err);
      setError('Failed to connect wallet');
    }
  };

  const switchNetwork = async () => {
    try {
      await window.ethereum.request({
        method: 'wallet_switchEthereumChain',
        params: [{ chainId: `0x${PASSET_HUB_CONFIG.chainId.toString(16)}` }],
      });
    } catch (switchError) {
      // Error 4902 means the chain hasn't been added to MetaMask
      if (switchError.code === 4902) {
        try {
          await window.ethereum.request({
            method: 'wallet_addEthereumChain',
            params: [
              {
                chainId: `0x${PASSET_HUB_CONFIG.chainId.toString(16)}`,
                chainName: PASSET_HUB_CONFIG.name,
                rpcUrls: [PASSET_HUB_CONFIG.rpc],
                blockExplorerUrls: [PASSET_HUB_CONFIG.blockExplorer],
              },
            ],
          });
        } catch (addError) {
          setError('Failed to add network to wallet');
        }
      } else {
        setError('Failed to switch network');
      }
    }
  };

  // UI-only disconnection - MetaMask doesn't support programmatic disconnection
  const disconnectWallet = () => {
    setAccount(null);
  };

  return (
    <div className="border border-pink-500 rounded-lg p-4 shadow-md bg-white text-pink-500 max-w-sm mx-auto">
      {error && <p className="text-red-500 text-sm mb-2">{error}</p>}

      {!account ? (
        <button
          onClick={connectWallet}
          className="w-full bg-pink-500 hover:bg-pink-600 text-white font-bold py-2 px-4 rounded-lg transition"
        >
          Connect Wallet
        </button>
      ) : (
        <div className="flex flex-col items-center">
          <span className="text-sm font-mono bg-pink-100 px-2 py-1 rounded-md text-pink-700">
            {`${account.substring(0, 6)}...${account.substring(38)}`}
          </span>
          <button
            onClick={disconnectWallet}
            className="mt-3 w-full bg-gray-200 hover:bg-gray-300 text-pink-500 py-2 px-4 rounded-lg transition"
          >
            Disconnect
          </button>
          {chainId !== PASSET_HUB_CONFIG.chainId && (
            <button
              onClick={switchNetwork}
              className="mt-3 w-full bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-2 px-4 rounded-lg transition"
            >
              Switch to Passet Hub
            </button>
          )}
        </div>
      )}
    </div>
  );
};

export default WalletConnect;

将组件集成到页面中

为了将钱包连接组件集成到你的 dApp 中,需要覆盖 app/page.js 中原有的 Next.js 模板代码,替换为如下内容:

app/page.js

'use client';

import { useState } from 'react';

import WalletConnect from './components/WalletConnect';

export default function Home() {
  const [account, setAccount] = useState(null);

  const handleConnect = (connectedAccount) => {
    setAccount(connectedAccount);
  };

  return (
    <section className="min-h-screen bg-white text-black flex flex-col justify-center items-center gap-4 py-10">
      <h1 className="text-2xl font-semibold text-center">
        Ethers.js dApp - Passet Hub Smart Contracts
      </h1>
      <WalletConnect onConnect={handleConnect} />
    </section>
  );
}

在终端中运行以下命令启动项目:

npm run dev

此时你将看到如下界面:

从区块链读取数据

现在,我们来创建一个组件,用于从智能合约中读取数据。

新建文件 app/components/ReadContract.js:

app/components/ReadContract.js

'use client';

import React, { useState, useEffect } from 'react';
import { getContract } from '../utils/contract';

const ReadContract = () => {
  const [storedNumber, setStoredNumber] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Function to read data from the blockchain
    const fetchData = async () => {
      try {
        setLoading(true);
        const contract = getContract();
        // Call the smart contract's storedNumber function
        const number = await contract.storedNumber();
        setStoredNumber(number.toString());
        setError(null);
      } catch (err) {
        console.error('Error fetching stored number:', err);
        setError('Failed to fetch data from the contract');
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    // Poll for updates every 10 seconds to keep UI in sync with blockchain
    const interval = setInterval(fetchData, 10000);

    // Clean up interval on component unmount
    return () => clearInterval(interval);
  }, []);

  return (
    <div className="border border-pink-500 rounded-lg p-4 shadow-md bg-white text-pink-500 max-w-sm mx-auto">
      <h2 className="text-lg font-bold text-center mb-4">Contract Data</h2>
      {loading ? (
        <div className="flex justify-center my-4">
          <div className="w-6 h-6 border-4 border-pink-500 border-t-transparent rounded-full animate-spin"></div>
        </div>
      ) : error ? (
        <p className="text-red-500 text-center">{error}</p>
      ) : (
        <div className="text-center">
          <p className="text-sm font-mono bg-pink-100 px-2 py-1 rounded-md text-pink-700">
            <strong>Stored Number:</strong> {storedNumber}
          </p>
        </div>
      )}
    </div>
  );
};

export default ReadContract;

该组件会调用合约中的 storedNumber 函数,并将返回值展示给用户。同时,它还设置了一个 轮询机制,每 10 秒从区块链重新获取一次数据,以保持 UI 与链上状态同步。

将 ReadContract 集成到页面中

为了在页面中看到读取的合约数据,需要再次更新 app/page.js:

app/page.js

'use client';

import { useState } from 'react';

import WalletConnect from './components/WalletConnect';
import ReadContract from './components/ReadContract';

export default function Home() {
  const [account, setAccount] = useState(null);

  const handleConnect = (connectedAccount) => {
    setAccount(connectedAccount);
  };

  return (
    <section className="min-h-screen bg-white text-black flex flex-col justify-center items-center gap-4 py-10">
      <h1 className="text-2xl font-semibold text-center">
        Ethers.js dApp - Passet Hub Smart Contracts
      </h1>
      <WalletConnect onConnect={handleConnect} />
      <ReadContract />
    </section>
  );
}

此时,你的 dApp 会自动更新为如下状态:

向区块链写入数据

最后,我们创建一个组件,允许用户向智能合约写入数据(即更新存储的数字)。

新建文件 app/components/WriteContract.js:

app/components/WriteContract.js

'use client';

import { useState } from 'react';
import { getSignedContract } from '../utils/contract';
import { ethers } from 'ethers';

const WriteContract = ({ account }) => {
  const [newNumber, setNewNumber] = useState('');
  const [status, setStatus] = useState({ type: null, message: '' });
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();

    // Validation checks
    if (!account) {
      setStatus({ type: 'error', message: 'Please connect your wallet first' });
      return;
    }

    if (!newNumber || isNaN(Number(newNumber))) {
      setStatus({ type: 'error', message: 'Please enter a valid number' });
      return;
    }

    try {
      setIsSubmitting(true);
      setStatus({ type: 'info', message: 'Initiating transaction...' });

      // Get a signer from the connected wallet
      const provider = new ethers.BrowserProvider(window.ethereum);
      const signer = await provider.getSigner();
      const contract = await getSignedContract(signer);

      // Send transaction to blockchain and wait for user confirmation in wallet
      setStatus({
        type: 'info',
        message: 'Please confirm the transaction in your wallet...',
      });

      // Call the contract's setNumber function
      const tx = await contract.setNumber(newNumber);

      // Wait for transaction to be mined
      setStatus({
        type: 'info',
        message: 'Transaction submitted. Waiting for confirmation...',
      });
      const receipt = await tx.wait();

      setStatus({
        type: 'success',
        message: `Transaction confirmed! Transaction hash: ${receipt.hash}`,
      });
      setNewNumber('');
    } catch (err) {
      console.error('Error updating number:', err);

      // Error code 4001 is MetaMask's code for user rejection
      if (err.code === 4001) {
        setStatus({ type: 'error', message: 'Transaction rejected by user.' });
      } else {
        setStatus({
          type: 'error',
          message: `Error: ${err.message || 'Failed to send transaction'}`,
        });
      }
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div className="border border-pink-500 rounded-lg p-4 shadow-md bg-white text-pink-500 max-w-sm mx-auto space-y-4">
      <h2 className="text-lg font-bold">Update Stored Number</h2>
      {status.message && (
        <div
          className={`p-2 rounded-md break-words h-fit text-sm ${
            status.type === 'error'
              ? 'bg-red-100 text-red-500'
              : 'bg-green-100 text-green-700'
          }`}
        >
          {status.message}
        </div>
      )}
      <form onSubmit={handleSubmit} className="space-y-4">
        <input
          type="number"
          placeholder="New Number"
          value={newNumber}
          onChange={(e) => setNewNumber(e.target.value)}
          disabled={isSubmitting || !account}
          className="w-full p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-pink-400"
        />
        <button
          type="submit"
          disabled={isSubmitting || !account}
          className="w-full bg-pink-500 hover:bg-pink-600 text-white font-bold py-2 px-4 rounded-lg transition disabled:bg-gray-300"
        >
          {isSubmitting ? 'Updating...' : 'Update'}
        </button>
      </form>
      {!account && (
        <p className="text-sm text-gray-500">
          Connect your wallet to update the stored number.
        </p>
      )}
    </div>
  );
};

export default WriteContract;

该组件允许用户输入一个新数字,并发送交易调用合约的 setNumber 函数来更新链上状态。当交易成功确认后,ReadContract 组件将在下一个轮询周期中自动显示最新的存储值。

集成所有组件

最后,更新 app/page.js,将所有组件组合在一起:

app/page.js

'use client';

import { useState } from 'react';

import WalletConnect from './components/WalletConnect';
import ReadContract from './components/ReadContract';
import WriteContract from './components/WriteContract';

export default function Home() {
  const [account, setAccount] = useState(null);

  const handleConnect = (connectedAccount) => {
    setAccount(connectedAccount);
  };

  return (
    <section className="min-h-screen bg-white text-black flex flex-col justify-center items-center gap-4 py-10">
      <h1 className="text-2xl font-semibold text-center">
        Ethers.js dApp - Passet Hub Smart Contracts
      </h1>
      <WalletConnect onConnect={handleConnect} />
      <ReadContract />
      <WriteContract account={account} />
    </section>
  );
}

最终完成的 UI 如下所示:

总结 

恭喜你!你已经成功构建了一个完整的 dApp,使用 Ethers.js 与 Next.js,并与 Polkadot Hub 测试网上的智能合约进行了交互。

你的应用现在已经具备以下能力:
    •    连接用户钱包
    •    从智能合约中读取数据
    •    发送交易并更新合约状态

这些基础能力为你在 Polkadot Hub 上构建更复杂的 dApp 打下了坚实的基础。你可以在此之上继续扩展,与更复杂的智能合约交互,并构建更加高级的用户界面。

获取完整示例代码 

如果你希望直接使用一个可运行的完整示例,可以克隆官方仓库并进入对应目录:

git clone https://github.com/polkadot-developers/polkavm-storage-contract-dapps.git -b v0.0.2
cd polkavm-storage-contract-dapps/ethers-dapp

原文链接:https://docs.polkadot.com/tutorials/smart-contracts/launch-your-first-project/create-dapp-ethers-js/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值