从 0 到 1 实现 EIP-7702 代付能力:BSC 测试网完整实践记录

目录

  • [一、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 参与批量业务操作"打下了基础。