这是WTF社区做的一个DApp的教程,有基础,感兴趣的可以学习:https://github.com/WTFAcademy/WTF-Dapp 文章相当于我的总结,开始教程前建议学习一下React。代码仓库:https://github.com/langjiyunmie/WTF-DAPP-STUDY
依据教程初期,我们需要了解这下面些库是干嘛的
<ConnectButton />
)与底层区块链工具库(如 wagmi
、ethers.js
或 Solana SDK)解耦。适配器的作用是 定义统一的交互接口,让同一个组件无需修改代码,即可通过不同适配器调用对应区块链的操作逻辑(例如连接 Ethereum 的 MetaMask 或 Solana 的 Phantom 钱包)。实际区块链网络通信和钱包交互仍由底层库(如 wagmi
)完成,适配器仅充当「翻译层」,让 UI 组件与多链生态兼容。import {
Address,
ConnectButton,
Connector,
NFTCard,
useAccount,
useProvider
} from "@ant-design/web3";
import {
Sepolia,
MetaMask,
WagmiWeb3ConfigProvider,
WalletConnect,
Polygon
} from "@ant-design/web3-wagmi";
import { Button, message } from "antd";
import { parseEther } from "viem";
import { createConfig, http, useReadContract, useWriteContract } from "wagmi";
import { sepolia, mainnet,polygon } from "wagmi/chains";
import { injected , walletConnect} from "wagmi/connectors";
const config = createConfig({
chains: [mainnet, sepolia,polygon],
transports: {
[mainnet.id]: http(),
[sepolia.id]: http(),
[polygon.id]: http(),
},
connectors: [
injected({
target: "metaMask",
}),
walletConnect({
projectId:'c07c0051c2055890eade3556618e38a6',
showQrModal:false
}),
],
});
const contractInfo = [
{
id:1,
name:"Ethereum",
contractAddress:"0xEcd0D12E21805803f70de03B72B1C162dB0898d9",
},
{
id:5,
name:"Sepolia",
contractAddress:"0x7e061614FAE039D59A6A1554886F1e235EEA6Fc1",
},
{
id:137,
name:"Polygon",
contractAddress:"0x7e061614FAE039D59A6A1554886F1e235EEA6Fc1"
}
]
const CallTest = () => {
const { account } = useAccount();
const { chain} = useProvider();
const result = useReadContract({
abi: [
{
type: "function",
name: "balanceOf",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ type: "uint256" }],
},
],
address: contractInfo.find( (item) => item.id === chain?.id)?.contractAddress as `0x${string}`,
functionName: "balanceOf",
args: [account?.address as `0x${string}`],
});
const { writeContract } = useWriteContract();
return (
<div>
{result.data?.toString()}
<Button
onClick={() => {
writeContract(
{
abi: [
{
type: "function",
name: "mint",
stateMutability: "payable",
inputs: [
{
internalType: "uint256",
name: "quantity",
type: "uint256",
},
],
outputs: [],
},
],
address: contractInfo.find((item) => item.id === chain?.id)?.contractAddress as `0x${string}`,
functionName: "mint",
args: [BigInt(1)],
value: parseEther("0.01"),
},
{
onSuccess: () => {
message.success("Mint Success");
},
onError: (err) => {
message.error(err.message);
},
}
);
}}
>
mint
</Button>
</div>
);
};
export default function Web3() {
return (
<WagmiWeb3ConfigProvider
config={config}
chains={[Sepolia,Polygon]}
wallets={[MetaMask(), WalletConnect()]}
>
<Address format address="0xEcd0D12E21805803f70de03B72B1C162dB0898d9" />
<NFTCard
address="0xEcd0D12E21805803f70de03B72B1C162dB0898d9"
tokenId={641}
/>
<Connector>
<ConnectButton />
</Connector>
<CallTest />
</WagmiWeb3ConfigProvider>
);
}
声明 DApp 支持哪些区块链网络,从 “wagmi/chains” 导入链的信息
import { sepolia, mainnet,polygon } from "wagmi/chains";
import { http } from "wagmi";
chains: [mainnet, sepolia,polygon],
为每个链指定 RPC 节点的通信方式(默认用 HTTP 协议连接公共节点)
transports: {
[mainnet.id]: http(),
[sepolia.id]: http(),
[polygon.id]: http(),
},
http() 内可以填入你的RPC节点信息
http("https://eth-mainnet.g.alchemy.com/v2/your-api-key")
import { injected , walletConnect} from "wagmi/connectors";
定义支持哪些钱包(如 MetaMask、WalletConnect)供用户连接。可以看到的是这两个钱包的定义方法是不一样的。
connectors: [
injected({
target: "metaMask",
}),
walletConnect({
projectId:'c07c0051c2055890eade3556618e38a6',
showQrModal:false
}),
],
MetaMask
它是一个浏览器插件钱包(当然也有移动端,只不过这里是浏览器插件的)。MetaMask 安装后,会向浏览器的 window.ethereum
注入一个 Provider 对象,DApp 通过此对象直接与钱包交互(如获取账户、签名交易)。所以类似这种注入式的钱包(coinbase,metamask等),都需要用到 injected()方法来定义
WalletConnect
它不是一个的具体钱包,而是一个开放协议。当我们要在 电脑上的 DApp(如 OpenSea)用 手机钱包(如 MetaMask Mobile)付款,但电脑和手机处在不同网络环境(可能不在同一个 WiFi),两者无法直接通信。此时需要一个 中间人(中继服务器) 帮助传递消息,但又要确保中间人无法窃取敏感数据(如私钥、交易内容),而 WalletConnect 就能让用户用手机钱包连接桌面 DApp。
projectId:'c07c0051c2055890eade3556618e38a6',
showQrModal:false
import {
Address,
ConnectButton,
Connector,
NFTCard,
useAccount,
useProvider
} from "@ant-design/web3";
const { account } = useAccount()
我们需要知道 wagmi 负责所有链上交互和状态管理。ant-design/web3-wagmi
中的 WagmiWeb3ConfigProvider
,它内部整合了 wagmi
库的状态管理。当用户点击 <ConnectButton />
并成功连接钱包后,wagmi 调用钱包的 API(如 MetaMask 的 eth_requestAccounts
),触发钱包弹窗钱包返回账户地址,wagmi 将其存入内部状态管理(如 Redux 或 Context)。作为 wagmi
的上层封装,WagmiWeb3ConfigProvider
会自动监听 wagmi
的状态变化,并将最新的 account
更新到 Web3ConfigProvider
的上下文中。
<WagmiWeb3ConfigProvider
config={config}
chains={[Sepolia,Polygon]}
wallets={[MetaMask(), WalletConnect()]}
>
const { chain} = useProvider();
同理,跟 useAccount() 方法一样,当用户切换网络(如从 Ethereum 转到 Polygon),wagmi
会自动更新当前链信息。WagmiWeb3ConfigProvider
同步数据:将 wagmi
中的 chain
和 provider
数据传递到 @ant-design/web3
的上下文中。
只读方法
const result = useReadContract({
abi: [
{
type: "function",
name: "balanceOf",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ type: "uint256" }],
},
],
address: contractInfo.find( (item) => item.id === chain?.id)?.contractAddress as `0x${string}`,
functionName: "balanceOf",
args: [account?.address as `0x${string}`],
});
abi结构
abi: [
{
type: "function",
name: "balanceOf",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ type: "uint256" }],
}
? 的使用
address: contractInfo.find( (item) => item.id === chain?.id)?.contractAddress as `0x${string}`
? 为可选链操作符。当chain不存在时,返回 undefined,而不是报错。反之存在时返回chain.id
下面是该方法定义传入的参数
export function useReadContract<
const abi extends Abi | readonly unknown[], //传入合约 ABI
functionName extends ContractFunctionName<abi, 'pure' | 'view'>, //functionName: "balanceOf", ABI 中定义的 pure 或 view 函数
args extends ContractFunctionArgs<abi, 'pure' | 'view', functionName>,//args: [account?.address as `0x${string}`], 参数数量:必须为 1 个(因 ABI 中 inputs 长度为 1),必须为 address 类型(通过 as 0x${string} 强制类型)
config extends Config = ResolvedRegister['config'] //自动继承 createConfig 中定义的全局配置(如 RPC 节点、链信息)
selectData = ReadContractData<abi, functionName, args>, //型参数,根据合约函数的 ABI 定义自动确定返回值类型
>
想合约写入数据,改变链上的参数状态。这里我们结合了 Button 组件。Button组件这里被我们命名为 mint 。当我们在页面上点击 mint 按钮,便会触发 useWriteContract() 的函数逻辑,为我们铸造一个链上的nft。
return (
<div>
{/* 显示合约调用结果(代币余额),将 bigint 转换为字符串 */}
{result.data?.toString()}
{/* Mint 按钮,点击后调用合约的 mint 函数 */}
<Button
onClick={() => {
// 调用 writeContract 发送交易
writeContract(
// 第一个参数:交易配置
{
// 合约的 ABI 定义,描述如何与合约交互
abi: [
{
type: "function", // 类型为函数
name: "mint", // 函数名
stateMutability: "payable", // 函数可接收 ETH(需支付 Gas)
inputs: [ // 输入参数定义
{
internalType: "uint256", // Solidity 内部类型
name: "quantity", // 参数名
type: "uint256" // 参数类型(256 位无符号整数)
},
],
outputs: [], // 无返回值(写入操作)
},
],
// 合约地址:根据当前链 ID 动态获取
address: contractInfo.find((item) => item.id === chain?.id)?.contractAddress as `0x${string}`,
// ^ 从 contractInfo 数组中找到与当前链 ID 匹配的合约地址
// ^ 强制转换为以太坊地址格式(0x 开头的字符串)
functionName: "mint", // 调用的函数名
// 函数参数:铸造数量为 1 个(需转换为 BigInt 类型)
args: [BigInt(1)], // BigInt 用于精确表示大整数
// 支付金额:0.01 ETH(需转换为 Wei 单位)
value: parseEther("0.01"), // parseEther("0.01") = 10000000000000000 Wei
},
// 第二个参数:交易回调配置
{
onSuccess: () => { // 交易成功回调
message.success("Mint Success"); // 显示成功提示
},
onError: (err) => { // 交易失败回调
message.error(err.message); // 显示错误信息(来自钱包或合约)
},
}
);
}}
>
mint
</Button>
</div>
);
将 UI组件与函数逻辑结合,输出。这里的NTFCard的address是链上存在的合约地址。
export default function Web3() {
return (
// Web3 配置提供者:为子组件注入区块链配置
<WagmiWeb3ConfigProvider
config={config} // 使用 wagmi 的全局配置(链、节点、连接器)
chains={[Sepolia,Polygon]} // 声明支持的网络:Sepolia 测试网 & Polygon 主网
wallets={[MetaMask(), WalletConnect()]} // 支持的钱包:MetaMask 插件和 WalletConnect 扫码
>
{/* 地址显示组件:格式化显示指定的以太坊地址 */}
<Address
format // 启用地址格式化(如 0x123...4567)
address="0xEcd0D12E21805803f70de03B72B1C162dB0898d9"
/>
{/* NFT 卡片组件:显示指定合约和 Token ID 的 NFT 信息 */}
<NFTCard
address="0xEcd0D12E21805803f70de03B72B1C162dB0898d9" // NFT 合约地址
tokenId={641} // 具体的 NFT Token ID
/>
{/* 钱包连接器容器:包裹连接按钮 */}
<Connector>
{/* 连接钱包按钮:点击后弹出钱包选择界面 */}
<ConnectButton />
</Connector>
{/* 自定义组件:包含代币铸造逻辑(如调用合约的 mint 函数) */}
<CallTest />
</WagmiWeb3ConfigProvider>
);
}
区块链地址本质是用户公钥的密码学哈希值(如以太坊地址为keccak256(公钥).slice(-20)
),作为链上资产的唯一标识符。私钥作为用户主权的密码学凭证,始终在客户端离线存储。智能合约的调用都需要地址对应的私钥签名认证,用户被强制性通过钱包私钥对交易签名,之后区块链网络自动验证签名有效性。但是对于非链上资产,就缺少了这一步,所以我们要补充这一步,由DApp服务端发起消息,而不是单纯的由客户端连接钱包就确定DApp中的资产就是用户的,因为其中的钱包api是可以被伪造的。
整个过程
ecrecover
(椭圆曲线公钥恢复函数)恢复出公钥,比对之前从前端(window.ethereum)的地址。import React from "react";
import { ConnectButton, Connector, useAccount } from "@ant-design/web3";
import { useSignMessage } from "wagmi";
import { message, Space, Button } from "antd";
const SignDemo: React.FC = () => {
const { signMessageAsync } = useSignMessage();
const { account } = useAccount();
const [signature,setSignature] = React.useState(false);
const doSignature = async () => {
setSignature(true);
try {
const signature = await signMessageAsync({
message: "test message for WTF-DApp demo",
});
await checkSignature({
address: account?.address,
signature,
});
}
catch (error: any) {
message.error(`Sign error:${error.message}`);
}
setSignature(false);
}
const checkSignature = async (
params:{
address?: string,
signature?: string,
}
) => {
try{
const response = await fetch("/api/signatureCheck",{
method:"POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify(params),
});
const result = await response.json();
if(result.data){
message.success("Signature success");
}
else{
message.error("Signature failed");
}
}
catch (error: any) {
message.error(`Check signature error:${error.message}`);
}
}
return (
<Space>
<Connector>
<ConnectButton />
</Connector>
<Button
loading={signature}
disabled={!account?.address}
onClick={doSignature}
>
Sign Message
</Button>
</Space>
)
}
export default SignDemo;
方法结构如下:
const [state, setState] = React.useState(initialState);
参数:initialState
(状态的初始值,可以是任意类型)
返回值
:一个数组,包含两个元素:
state
:当前状态的值。setState
:更新状态的函数。import React from 'react';
function Counter() {
// 使用 useState 定义状态:count 初始值为 0
const [count, setCount] = React.useState(0);
return (
<div>
<p>当前计数: {count}</p>
{/* 点击按钮调用 setCount 更新状态 */}
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
点击按钮,通过向 setCount 函数传入参数 count + 1,更新 count 参数的值为 1。
const doSignature = async () => {
setSignature(true);
try {
const signature = await signMessageAsync({
message: "test message for WTF-DApp demo",
});
await checkSignature({
address: account?.address,
signature,
});
}
catch (error: any) {
message.error(`Sign error:${error.message}`);
}
setSignature(false);
}
try
块:包裹可能出错的代码(如网络请求、用户取消操作)。
catch
块:捕获错误并处理(如提示用户、记录日志)。
就像网购时「尝试下单 → 若失败显示错误提示」
该方法会对指定消息进行数字签名,这里的签名消息是 “test message for WTF-DApp demo",
客户端进行签名,发送消息给服务端,服务端调用api目录下的signatureCheck文件,从三元组的签名消息中恢复公钥,跟前端(window.ethereum)返回的地址进行匹配。之后返回消息 response.json()
const checkSignature = async (
params:{
address?: string,
signature?: string,
}
) => {
try{
const response = await fetch("/api/signatureCheck",{
method:"POST",
headers: {"Content-Type":"application/json"},
body: JSON.stringify(params),
});
const result = await response.json();
if(result.data){
message.success("Signature success");
}
else{
message.error("Signature failed");
}
}
catch (error: any) {
message.error(`Check signature error:${error.message}`);
}
}
fetch("/api/signatureCheck", ...)
向服务器发送 HTTP 请求,路径为 /api/signatureCheck
),就像在浏览器地址栏输入网址。这里是程序自动发送请求。
method: "POST
使用 POST 方法发送数据(适合敏感操作,如提交表单、验证签名)。POST 请求的数据在请求体中,GET 请求的数据在 URL 中(不适用于敏感数据)。
headers: {"Content-Type": "application/json"}
告诉服务器发送的数据是 JSON 格式(如 {"address": "0x123...", "signature": "0x456..."}
)。若未设置,服务器可能无法正确解析数据。
body: JSON.stringify(params)
将 JavaScript 对象(params
)转换为 JSON 字符串发送到服务器。
response.json()
将服务器返回的响应体解析为 JSON 对象(假设服务器返回如 { data: true }
)。
return (
<Space>
<Connector>
<ConnectButton />
</Connector>
<Button
loading={signature}// 控制按钮的加载状态(是否显示加载动画),来源:通过 React.useState 定义的 signature 状态
disabled={!account?.address}// 根据钱包地址是否存在,控制按钮是否可点击。
onClick={doSignature}// 触发该函数同时触发 response请求,调用signatureCheck文件进行验签,返回请求数据。
>
Sign Message
</Button>
</Space>
)
这里就包含了我在 checkSignature 所说的行为逻辑。那我们下面来看看 signatureCheck文件。
import type { NextApiRequest, NextApiResponse } from "next";
import { createPublicClient, http} from "viem";
import { mainnet} from "viem/chains";
export const publicClient = createPublicClient({
chain:mainnet, //指定要连接的区块链网络是以太坊主网(Mainnet)
transport:http(), //// 使用 HTTP 协议与节点通信
})
export default async function handler(
// 这里定义两个消息参数,req: NextApiRequest —— 接收客户端请求, res: NextApiResponse —— 向客户端发送响应
req: NextApiRequest,
res: NextApiResponse
) {
try{
const body = req.body;
const valid = await publicClient.verifyMessage({
address: body.address,
message: "test message for WTF-DApp demo",
signature: body.signature,
})
res.status(200).json({ data: valid });// 表示签名验证逻辑成功完成
}
catch (error: any) {
res.status(500).json({ error: error.message }); //表示服务端发生了意外错误
}
}
创建一个与以太坊主网(Mainnet)连接的公共客户端实例,用于与区块链进行交互。
这个方法就是验签的核心。它会接收 signature参数,执行底层函数。同时匹配地址。
支付逻辑的组件
import * as React from "react";
import { useSendTransaction, useWaitForTransactionReceipt,type BaseError } from "wagmi";
import { parseEther } from "viem";
import { FormProps } from "antd";
import { Form, Input, Button } from "antd";
type FieldType = {
to: `0x${string}`;
value: string;
};
export const SendEth:React.FC = () => {
const {data: hash,error,isPending,sendTransaction} = useSendTransaction();
const{ isLoading:isConfirming,isSuccess:isConfirmed} = useWaitForTransactionReceipt({hash});
const onFinish: FormProps<FieldType>["onFinish"] = (values) => {
console.log("Success:", values);
sendTransaction({
to: values.to,
value: parseEther(values.value),
});
}
const onFinishFailed: FormProps<FieldType>["onFinishFailed"] = (errorInfo) => {
console.log("Failed:", errorInfo);
}
return (
<Form
name="basic"
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
style={{ maxWidth: 600 }}
initialValues={{ remember: true }}
onFinish={onFinish}
onFinishFailed={onFinishFailed}
autoComplete="off"
>
<Form.Item<FieldType>
label="to"
name="to"
rules={[{ required: true, message: 'Please input!' }]}
>
<Input />
</Form.Item>
<Form.Item<FieldType>
label="value"
name="value"
rules={[{ required: true, message: 'Please input!' }]}
>
<Input />
</Form.Item>
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Button type="primary" htmlType="submit">
{isPending ? 'Confirming...' : 'Send'}
</Button>
</Form.Item>
{hash && <div>Transaction Hash: {hash}</div>}
{isConfirming && <div>Waiting for confirmation...</div>}
{isConfirmed && <div>Transaction confirmed.</div>}
{error && (
<div>Error: {(error as BaseError).shortMessage || error.message}</div>
)}
</Form>
)
}
创建并发送以太坊交易
{
data: hash, // 交易哈希(发送成功后获得)
error, // 错误对象(发送失败时存在)
isPending, // 发送中状态(true表示正在等待钱包确认)
sendTransaction // 触发交易发送的函数
}
核心参数--sendTransaction
sendTransaction({
to: '0x...', // 必填:接收地址
value: parseEther('0.1'), // 转账金额(需转换为wei单位)
// 可选参数:
gas: 21000, // 手动设置gas limit
gasPrice: 5000000000, // 手动设置gas价格
chainId: 1, // 指定链ID
})
监听交易确认状态
{
hash: '0x...' // 必须提供有效的交易哈希
}
返回状态
{
isLoading: isConfirming, // 是否在等待区块确认
isSuccess: isConfirmed, // 是否已成功确认
isError, // 是否确认失败
error // 失败错误信息
}
整个流程
交易发送成功 → 获得hash → 开始监听 → 区块打包中(isConfirming=true) → 打包完成(isConfirmed=true)
↘ 超过等待时间/失败 → isError=true
const onFinish: FormProps<FieldType>["onFinish"] = (values) => {}
FormProps<FieldType>
中的 <FieldType>
相当于一个类型的定义,这个类型就是 下面代码中定义的type(自定义)类型,传入的参数values 必须满足该类型结构。
type FieldType = {
to: `0x${string}`;
value: string;
};
sendTransaction({
to: values.to,
value: parseEther(values.value),
});
<Form.Item<FieldType>
label="to"
name="to"
rules={[{ required: true, message: 'Please input!' }]}
>
onFinish
是 Form 组件的内置属性名,不能随意更名。
required: true : 强制用户必须填写该字段,否则阻止表单提交并显示错误提示。且填入参数类型满足 FieldType
定义了输入参数的UI组件
return (
<Form
name="basic"
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
style={{ maxWidth: 600 }}
initialValues={{ remember: true }}
onFinish={onFinish}
onFinishFailed={onFinishFailed}
autoComplete="off"
>
<Form.Item<FieldType>
label="to"
name="to"
rules={[{ required: true, message: 'Please input!' }]}
>
<Input />
</Form.Item>
<Form.Item<FieldType>
label="value"
name="value"
rules={[{ required: true, message: 'Please input!' }]}
>
<Input />
</Form.Item>
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Button type="primary" htmlType="submit">
{isPending ? 'Confirming...' : 'Send'}
</Button>
</Form.Item>
{hash && <div>Transaction Hash: {hash}</div>}
{isConfirming && <div>Waiting for confirmation...</div>}
{isConfirmed && <div>Transaction confirmed.</div>}
{error && (
<div>Error: {(error as BaseError).shortMessage || error.message}</div>
)}
</Form>
)
onFinish={onFinish} //定义表单提交成功的业务逻辑(如发送请求)
onFinishFailed={onFinishFailed} //定义表单提交失败的处理逻辑(如错误提示)
表单验证 通过 或 失败 后的回调函数
label属性标签:定义表单项的 标签文本(即输入框左侧的说明文字)
<Form.Item<FieldType>>
泛型参数,确保 name
只能是 FieldType
的键(to
或 value
),且是独立的输入项,要明确数据归属,name="to"
→ 用户输入的地址会映射到 values.to
。
import { mainnet } from "viem/chains";
import { sepolia } from "wagmi/chains";
import { createConfig, http, injected } from "wagmi";
import React from "react";
import { MetaMask ,WagmiWeb3ConfigProvider} from "@ant-design/web3-wagmi";
import { ConnectButton, Connector } from "@ant-design/web3";
import {SendEth} from "../../componments/SendEth"
const config = createConfig({
chains: [mainnet ,sepolia],
transports:{
[mainnet.id] :http(),
[sepolia.id] :http(),
},
connectors:[
injected({
target:"metaMask",
}),
],
});
const TransactionDemo:React.FC = () => {
return(
<WagmiWeb3ConfigProvider
config={config}
eip6963={{
autoAddInjectedWallets:true,
}}
wallets={[
MetaMask()
]}
>
<Connector>
<ConnectButton />
</Connector>
<SendEth />
</WagmiWeb3ConfigProvider>
)
}
export default TransactionDemo;
eip6963={{
autoAddInjectedWallets:true,
}}
当设置为 true
时,DApp 会自动检测用户浏览器中安装的 符合 EIP-6963 标准 的钱包(如 MetaMask、Coinbase Wallet 等),并将其添加到可连接钱包列表中。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!