目录
- [一、EIP-7702 真正的用途是什么](#一、EIP-7702 真正的用途是什么)
- 二、详细操作步骤
-
- 第1步:初始化项目
- [第2步:安装 Hardhat](#第2步:安装 Hardhat)
- [第3步:初始化 Hardhat 项目](#第3步:初始化 Hardhat 项目)
- [第4步:配置 BSC Testnet 网络 + 保存私钥](#第4步:配置 BSC Testnet 网络 + 保存私钥)
-
- [1. 安装 dotenv](#1. 安装 dotenv)
- [2. 在项目根目录创建 .env 文件](#2. 在项目根目录创建 .env 文件)
- [第5步:修改 hardhat.config.ts,加入 BSC Testnet](#第5步:修改 hardhat.config.ts,加入 BSC Testnet)
- [第6步:编写 GasSponsor.sol 合约](#第6步:编写 GasSponsor.sol 合约)
- [第7步:编写部署脚本部署 GasSponsor.sol 合约](#第7步:编写部署脚本部署 GasSponsor.sol 合约)
- [第8步:编写充值合约脚本 fundContract.ts](#第8步:编写充值合约脚本 fundContract.ts)
- [第9步:编写批量发送脚本 batchSendGas.ts](#第9步:编写批量发送脚本 batchSendGas.ts)
- [第10步:编写验证调用脚本 verifyUnauthorized.ts](#第10步:编写验证调用脚本 verifyUnauthorized.ts)
- [第11步:编写脚本 sign7702.ts](#第11步:编写脚本 sign7702.ts)
- 总结
需要说明的是,当前实现更偏向于 基础设施验证阶段:一方面验证了 Sponsor 批量出资能力,另一方面验证了 7702 授权链路可行性。要进一步达到"用户无需持有 BNB,也能由 Sponsor 代付 gas 完成 approve + buy、transfer + swap 等真实业务批量执行"的效果,下一步还需要把 7702 授权账户 与 批量执行逻辑合约 结合起来,真正让用户地址通过委托获得多调用执行能力。请看这篇(传送🚪)
一、EIP-7702 真正的用途是什么
EIP-7702 的核心价值是:让普通钱包(EOA)临时变成智能合约钱包。
haskell
场景:用户想一次性批准代币 + 购买 NFT(两步操作)
没有 EIP-7702:
交易1:approve(用户付gas)
交易2:buy(用户付gas)
= 2笔交易,2次gas,用户需要有 BNB
有了 EIP-7702:
用户委托一个"批量执行合约"到自己的地址
→ 用户地址临时拥有批量执行能力
→ Sponsor 发一笔交易,同时完成 approve + buy
= 1笔交易,Sponsor付gas,用户不需要有 BNB
二、详细操作步骤
第1步:初始化项目
打开终端,进入你的项目目录,运行以下命令:
npm init -y
创建 package.json 文件,这是 Node.js 项目的配置文件,记录项目名称和依赖列表,-y 表示所有问题都用默认值。运行完之后,你会看到目录里多了一个 package.json 文件。
第2步:安装 Hardhat
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
这条命令会下载两个包:
hardhat:合约开发框架,负责编译 Solidity 代码、把合约部署到链上
@nomicfoundation/hardhat-toolbox:官方插件包,内置了 ethers.js(JS 操作链的库)等工具
--save-dev 表示"开发依赖",只在你写代码时用,不需要跑在服务器上。
安装需要 1-2 分钟,等它跑完。跑完后你会看到 node_modules 文件夹出现在目录里。

第3步:初始化 Hardhat 项目
npx hardhat --init
运行后会出现一个交互式菜单,用键盘方向键选择,按回车确认:

Hardhat v3.2.0 已初始化
viem 已经自动安装了(我们后面做 EIP-7702 就用它)
项目已经是 ESM 模式
到这里可以看到我们的目录是这样子哒!加油!继续!

第4步:配置 BSC Testnet 网络 + 保存私钥
1. 安装 dotenv
用来读取 .env 文件里的私钥,避免私钥直接写在代码里
npm install dotenv

2. 在项目根目录创建 .env 文件
你需要准备以下信息填进去:
PRIVATE_KEY:部署合约和充值的人
SPONSOR_PRIVATE_KEY:唯一允许批量发 gas 的人
UNAUTHORIZED_PRIVATE_KEY:故意拿来测失败的人
BSC_TESTNET_RPC:BSC Testnet RPC
RECIPIENT_1、RECIPIENT_2:两个测试收款地址
SPONSOR_ADDRESS:只能 Sponsor 调用
AMOUNT_1_ETH、AMOUNT_2_ETH:给这两个地址分别发多少 tBNB
MAX_BATCH_SIZE:单次最多处理多少人
MIN_TOPUP_AMOUNT_ETH:单笔最小补 gas 金额
FUND_AMOUNT_ETH:给合约先充值多少 tBNB
CONTRACT_ADDRESS:部署后再填进去
怎么从 MetaMask 导出私钥:打开 MetaMask → 点击账户头像 → 账户详情 → 导出私钥 → 输入密码 → 复制
在你打开的 .env 文件里,填入以下内容:
typescript
PRIVATE_KEY=0x你的部署钱包私钥
SPONSOR_PRIVATE_KEY=0x你的sponsor钱包私钥
UNAUTHORIZED_PRIVATE_KEY=0x你的未授权测试钱包私钥
BSC_TESTNET_RPC=https://bsc-testnet-dataseed.bnbchain.org
RECIPIENT_1=
RECIPIENT_2=
SPONSOR_ADDRESS=
AMOUNT_1_ETH=0.001
AMOUNT_2_ETH=0.002
MAX_BATCH_SIZE=50
MIN_TOPUP_AMOUNT_ETH=0.0001
FUND_AMOUNT_ETH=0.05
CONTRACT_ADDRESS=
Tips
这里教大家怎么去找 RPC,这里介绍两种方式
最快的方式是去 ChainList (传送🚪) 里面搜索,钱包连接,复制 RPC

还有一种是去 BNB 官网(传送🚪 )查找,比较官方权威

注意:
私钥粘贴进去,不要加 0x 前缀(MetaMask 导出的有些有 0x 开头,去掉它)
这个文件绝对不能上传 GitHub,在 .gitignore 里加了 .env 记得保存

第5步:修改 hardhat.config.ts,加入 BSC Testnet
typescript
import hardhatToolboxViemPlugin from "@nomicfoundation/hardhat-toolbox-viem";
import { defineConfig } from "hardhat/config";
import dotenv from "dotenv";
dotenv.config();
export default defineConfig({
plugins: [hardhatToolboxViemPlugin],
solidity: {
profiles: {
default: {
version: "0.8.28",
},
},
},
networks: {
bscTestnet: {
type: "http",
chainType: "generic",
url: process.env.BSC_TESTNET_RPC!,
accounts: [
process.env.PRIVATE_KEY!,
process.env.SPONSOR_PRIVATE_KEY!,
process.env.UNAUTHORIZED_PRIVATE_KEY!,
],
},
},
});
dotenv.config() --- 读取 .env 文件,把里面的变量加载到 process.env 里
process.env.BSC_TESTNET_RPC --- 从环境变量读取 RPC 地址,不硬写在代码里
process.env.PRIVATE_KEY --- 从环境变量读取私钥
! --- TypeScript 语法,告诉编译器"这个值不会是 undefined"
accounts 数组里放的是部署者的私钥,也就是钱包A
Tips
去这个 BNB 官网(传送门)领测试 tBNB(免费):


第6步:编写 GasSponsor.sol 合约
现在打开 contracts/ 目录,里面有一个模板文件 Counter.sol,我们不管它,直接新建我们自己的合约文件。
在 Cursor 里,在 contracts/ 目录下新建一个文件,命名为 GasSponsor.sol
先装依赖 OpenZeppelin 库
npm install @openzeppelin/contracts
编译合约
npx hardhat compile
第7步:编写部署脚本部署 GasSponsor.sol 合约
npx hardhat run "scripts/ deploy.ts" --network bscTestnet
合约代码如下:
rust
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract GasSponsor is Ownable, Pausable, ReentrancyGuard {
using Address for address payable;
using SafeERC20 for IERC20;
address public sponsor;
// 单次批量最大发送人数,防止一笔交易太大导致 gas 超限
uint256 public maxBatchSize;
// 单个地址最小补 gas 金额,防止误传 0 或过小金额
uint256 public minTopUpAmount;
// 防止后端重复提交同一个批次
mapping(bytes32 => bool) public executedRequestIds;
error NotSponsor();
error InvalidSponsor();
error InvalidRecipient(uint256 index);
error EmptyRecipients();
error LengthMismatch();
error InvalidRequestId();
error DuplicateRequestId(bytes32 requestId);
error BatchTooLarge(uint256 provided, uint256 maxAllowed);
error AmountTooSmall(uint256 index, uint256 amount);
error InsufficientNativeBalance(uint256 required, uint256 available);
error InvalidWithdrawAddress();
error InvalidWithdrawAmount();
error InvalidMaxBatchSize();
error InvalidMinTopUpAmount();
event SponsorUpdated(address indexed oldSponsor, address indexed newSponsor);
event MaxBatchSizeUpdated(uint256 oldValue, uint256 newValue);
event MinTopUpAmountUpdated(uint256 oldValue, uint256 newValue);
event NativeReceived(address indexed from, uint256 amount);
event GasSent(
bytes32 indexed requestId,
address indexed operator,
address indexed recipient,
uint256 amount
);
event BatchGasSent(
bytes32 indexed requestId,
address indexed operator,
uint256 recipientCount,
uint256 totalAmount
);
event NativeWithdrawn(address indexed to, uint256 amount);
event ERC20Recovered(address indexed token, address indexed to, uint256 amount);
modifier onlySponsor() {
if (msg.sender != sponsor) revert NotSponsor();
_;
}
constructor(
address initialSponsor,
uint256 initialMaxBatchSize,
uint256 initialMinTopUpAmount
) Ownable(msg.sender) {
if (initialSponsor == address(0)) revert InvalidSponsor();
if (initialMaxBatchSize == 0) revert InvalidMaxBatchSize();
if (initialMinTopUpAmount == 0) revert InvalidMinTopUpAmount();
sponsor = initialSponsor;
maxBatchSize = initialMaxBatchSize;
minTopUpAmount = initialMinTopUpAmount;
}
receive() external payable {
emit NativeReceived(msg.sender, msg.value);
}
function setSponsor(address newSponsor) external onlyOwner {
if (newSponsor == address(0)) revert InvalidSponsor();
address oldSponsor = sponsor;
sponsor = newSponsor;
emit SponsorUpdated(oldSponsor, newSponsor);
}
function setMaxBatchSize(uint256 newMaxBatchSize) external onlyOwner {
if (newMaxBatchSize == 0) revert InvalidMaxBatchSize();
uint256 oldValue = maxBatchSize;
maxBatchSize = newMaxBatchSize;
emit MaxBatchSizeUpdated(oldValue, newMaxBatchSize);
}
function setMinTopUpAmount(uint256 newMinTopUpAmount) external onlyOwner {
if (newMinTopUpAmount == 0) revert InvalidMinTopUpAmount();
uint256 oldValue = minTopUpAmount;
minTopUpAmount = newMinTopUpAmount;
emit MinTopUpAmountUpdated(oldValue, newMinTopUpAmount);
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
function batchSendGas(
bytes32 requestId,
address[] calldata recipients,
uint256[] calldata amounts
) external onlySponsor whenNotPaused nonReentrant {
if (requestId == bytes32(0)) revert InvalidRequestId();
if (executedRequestIds[requestId]) revert DuplicateRequestId(requestId);
uint256 length = recipients.length;
if (length == 0) revert EmptyRecipients();
if (length != amounts.length) revert LengthMismatch();
if (length > maxBatchSize) revert BatchTooLarge(length, maxBatchSize);
uint256 totalAmount = 0;
for (uint256 i = 0; i < length; ) {
if (recipients[i] == address(0)) revert InvalidRecipient(i);
if (amounts[i] < minTopUpAmount) revert AmountTooSmall(i, amounts[i]);
totalAmount += amounts[i];
unchecked {
++i;
}
}
uint256 currentBalance = address(this).balance;
if (currentBalance < totalAmount) {
revert InsufficientNativeBalance(totalAmount, currentBalance);
}
// 先标记已执行,防止重入或重复提交
executedRequestIds[requestId] = true;
for (uint256 i = 0; i < length; ) {
payable(recipients[i]).sendValue(amounts[i]);
emit GasSent(requestId, msg.sender, recipients[i], amounts[i]);
unchecked {
++i;
}
}
emit BatchGasSent(requestId, msg.sender, length, totalAmount);
}
function withdrawNative(address payable to, uint256 amount)
external
onlyOwner
nonReentrant
{
if (to == address(0)) revert InvalidWithdrawAddress();
if (amount == 0) revert InvalidWithdrawAmount();
to.sendValue(amount);
emit NativeWithdrawn(to, amount);
}
function recoverERC20(address token, address to, uint256 amount)
external
onlyOwner
nonReentrant
{
if (token == address(0) || to == address(0)) revert InvalidWithdrawAddress();
if (amount == 0) revert InvalidWithdrawAmount();
IERC20(token).safeTransfer(to, amount);
emit ERC20Recovered(token, to, amount);
}
}
部署合约成功,可以在区块链浏览器查看

第8步:编写充值合约脚本 fundContract.ts
在 scripts/ 目录下新建文件 fundContract.ts
typescript
import { network } from "hardhat";
import { parseEther, formatEther } from "viem";
async function main() {
const conn = await network.connect();
const [deployer] = await conn.viem.getWalletClients();
const publicClient = await conn.viem.getPublicClient();
const contractAddress = process.env.CONTRACT_ADDRESS as `0x${string}`;
const fundAmount = parseEther(process.env.FUND_AMOUNT_ETH || "0.05");
console.log("充值地址(deployer):", deployer.account.address);
console.log("合约地址:", contractAddress);
console.log("充值金额:", formatEther(fundAmount), "BNB");
const balanceBefore = await publicClient.getBalance({ address: contractAddress });
console.log("充值前合约余额:", formatEther(balanceBefore), "BNB");
const txHash = await deployer.sendTransaction({
to: contractAddress,
value: fundAmount,
});
console.log("\n交易已发送,tx hash:", txHash);
console.log("等待交易确认...");
await publicClient.waitForTransactionReceipt({ hash: txHash });
const balanceAfter = await publicClient.getBalance({ address: contractAddress });
console.log("充值后合约余额:", formatEther(balanceAfter), "BNB");
console.log("\n充值成功!");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

第9步:编写批量发送脚本 batchSendGas.ts
在 scripts/ 目录下新建文件 batchSendGas.ts
typescript
import { network } from "hardhat";
import { parseEther, formatEther, toHex, pad } from "viem";
async function main() {
const conn = await network.connect();
const walletClients = await conn.viem.getWalletClients();
const publicClient = await conn.viem.getPublicClient();
const sponsor = walletClients[1];
const contractAddress = process.env.CONTRACT_ADDRESS as `0x${string}`;
const recipients: `0x${string}`[] = [
process.env.RECIPIENT_1 as `0x${string}`,
process.env.RECIPIENT_2 as `0x${string}`,
];
const amounts = [
parseEther(process.env.AMOUNT_1_ETH || "0.001"),
parseEther(process.env.AMOUNT_2_ETH || "0.002"),
];
const requestId = pad(toHex(Date.now()), { size: 32 });
console.log("Sponsor 地址:", sponsor.account.address);
console.log("合约地址:", contractAddress);
console.log("收款地址:", recipients);
console.log("金额:", amounts.map(a => formatEther(a) + " BNB"));
console.log("requestId:", requestId);
const contractBalanceBefore = await publicClient.getBalance({ address: contractAddress });
console.log("\n批量转账前合约余额:", formatEther(contractBalanceBefore), "BNB");
const contract = await conn.viem.getContractAt("GasSponsor", contractAddress);
const txHash = await contract.write.batchSendGas(
[requestId, recipients, amounts],
{ account: sponsor.account }
);
console.log("\n交易已发送,tx hash:", txHash);
console.log("等待确认...");
await publicClient.waitForTransactionReceipt({ hash: txHash });
const contractBalanceAfter = await publicClient.getBalance({ address: contractAddress });
console.log("批量转账后合约余额:", formatEther(contractBalanceAfter), "BNB");
console.log("\n批量补 gas 成功!");
console.log("请去 BscScan 查看交易详情:");
console.log(`https://testnet.bscscan.com/tx/${txHash}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

第10步:编写验证调用脚本 verifyUnauthorized.ts
npx hardhat run scripts/verifyUnauthorized.ts --network bscTestnet
typescript
import { network } from "hardhat";
import { parseEther, toHex, pad } from "viem";
async function main() {
const conn = await network.connect();
const walletClients = await conn.viem.getWalletClients();
const publicClient = await conn.viem.getPublicClient();
const unauthorized = walletClients[2];
const contractAddress = process.env.CONTRACT_ADDRESS as `0x${string}`;
const recipients: `0x${string}`[] = [
process.env.RECIPIENT_1 as `0x${string}`,
];
const amounts = [parseEther("0.001")];
const requestId = pad(toHex(Date.now()), { size: 32 });
console.log("未授权地址:", unauthorized.account.address);
console.log("合约地址:", contractAddress);
console.log("尝试用未授权地址调用 batchSendGas...\n");
try {
const contract = await conn.viem.getContractAt("GasSponsor", contractAddress);
const txHash = await contract.write.batchSendGas(
[requestId, recipients, amounts],
{ account: unauthorized.account }
);
console.log("tx hash:", txHash);
await publicClient.waitForTransactionReceipt({ hash: txHash });
console.log("❌ 验证失败:未授权地址调用成功了,权限控制有问题!");
} catch (err: any) {
const message = err?.message || "";
if (message.includes("NotSponsor") || message.includes("revert")) {
console.log("✅ 验证通过:未授权地址调用被合约拒绝");
console.log("错误类型: NotSponsor(合约权限控制生效)");
} else {
console.log("⚠️ 发生了未预期的错误:");
console.log(message.slice(0, 300));
}
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

第11步:编写脚本 sign7702.ts
npx hardhat run scripts/sign7702.ts --network bscTestnet
typescript
import {
createWalletClient,
createPublicClient,
http,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { bscTestnet } from "viem/chains";
async function main() {
const rpc = process.env.BSC_TESTNET_RPC!;
const contractAddress = process.env.CONTRACT_ADDRESS as `0x${string}`;
// 用户小明:签委托书(PRIVATE_KEY 对应的钱包)
const userAccount = privateKeyToAccount(`0x${process.env.PRIVATE_KEY}`);
// Sponsor:构造并发送 type-4 交易(SPONSOR_PRIVATE_KEY 对应的钱包)
const sponsorAccount = privateKeyToAccount(`0x${process.env.SPONSOR_PRIVATE_KEY}`);
const userWalletClient = createWalletClient({
account: userAccount,
chain: bscTestnet,
transport: http(rpc),
});
const sponsorWalletClient = createWalletClient({
account: sponsorAccount,
chain: bscTestnet,
transport: http(rpc),
});
const publicClient = createPublicClient({
chain: bscTestnet,
transport: http(rpc),
});
console.log("用户地址(小明):", userAccount.address);
console.log("Sponsor 地址:", sponsorAccount.address);
console.log("委托合约地址:", contractAddress);
// 第一步:用户签委托书(离线,不花 gas)
console.log("\n第一步:用户签 EIP-7702 委托书...");
const authorization = await userWalletClient.signAuthorization({
contractAddress,
});
console.log("委托书签名完成:");
console.log(" chainId:", authorization.chainId);
console.log(" nonce:", authorization.nonce);
console.log(" contractAddress:", (authorization as any).address ?? (authorization as any).contractAddress);
// 第二步:Sponsor 构造 type-4 交易,把委托书塞进去发出去
console.log("\n第二步:Sponsor 发送 type-4 交易...");
const txHash = await sponsorWalletClient.sendTransaction({
authorizationList: [authorization],
to: userAccount.address,
data: "0x",
});
console.log("交易已发送!");
console.log("tx hash:", txHash);
console.log("等待确认...");
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
console.log("交易确认,区块:", receipt.blockNumber.toString());
console.log("交易类型:", receipt.type);
console.log("\n✅ EIP-7702 授权链路验证完成!");
console.log("请去 BscScan txnAuthList 查看授权记录:");
console.log("https://testnet.bscscan.com/txnAuthList");
console.log("\n或直接查看这笔交易:");
console.log(`https://testnet.bscscan.com/tx/${txHash}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});


总结
本文围绕 EIP-7702 的实际使用场景,完成了一套基于 BSC Testnet 的批量代付 Gas 基础能力验证。首先,从 EIP-7702 的核心价值出发,明确了它并不是单纯的"批量转账方案",而是让普通 EOA 钱包在授权后临时具备智能合约账户的执行能力,从而支持 Sponsor 代付 gas、批量调用和交易体验优化等能力。
在实现层面,本文完成了 Hardhat 项目初始化、BSC Testnet 网络配置、环境变量管理以及 OpenZeppelin 依赖安装,并编写了 GasSponsor.sol 合约,实现了仅指定 Sponsor 地址可调用的批量补 gas 逻辑。同时,配套完成了部署脚本、合约充值脚本、批量发送脚本和未授权调用验证脚本,对"只有 Sponsor 能执行批量补 gas"这一权限控制要求进行了链上验证。
在 EIP-7702 链路验证部分,本文进一步使用 viem 编写了授权脚本,完成了用户离线签署授权信息、Sponsor 构造并发送 type-4 交易的完整流程,并在 BSC Testnet 上验证了授权交易上链结果。这一步说明,EIP-7702 的授权通路本身是可以跑通的,也为后续实现真正的"用户无 gas 参与批量业务操作"打下了基础。