Web3年入百万的“空投”到底是什么?教你开发批量转账的空投DApp(涵盖前端、智能合约)

13,043 阅读11分钟

什么是空投?

每一个新的 DApp 上线,第一件要做的事就是收集用户。

而收集用户的方式有哪些呢?原则上很简单:提供利益、提供价值。

像上一篇文章中介绍的“水龙头”就是一种收集用户的方式。

但水龙头是用户主动向项目方领取的,门槛比较低。而且黏性不高,用户领完币仍然可能不会使用你的 DApp。就像打卡一样。

空投是一种门槛相对更高一点的玩法。它是项目方主动给用户提供奖励。一般来说,空投的奖励会比水龙头高多了。

只有早期参与 DApp 的活跃用户,才会得到项目方的空投。空投奖励通常是以代币的形式。

那些整天在群里问「群主,有没有空投推荐?」的人,就是撸空投的。他们会参与多个 DApp,然后不停地交互。以此来得到项目方的空投。

但仅靠使用 DApp 是不够的,项目方为了保证真实活跃,会对空投玩法设置一些条件。比如必须要加入 discord、必须绑定推特和邮箱注册。

所以这就形成了一套账户,也就是常说的空投四件套:地址、discord、推特、邮箱。

空投经济,不仅养活了项目方、养活了撸空投的人。还顺便养活了一帮专门卖空投项目的人、专门养号卖号的人,这就是一个商业模式的闭环。

但依我个人经验来看,空投没有那么好撸。运气成分占很大一部分。有人忙活了一年,几乎所有空闲时间都花在撸空投上了,结果一年到头也就赚个三五万。而有人可能只参与了几个空投项目,就赚了几十万。

那怎么判断一个空投项目是否值得撸呢?注意以下几点:

  • 首先项目方必须明确表示会有空投,或者强烈暗示。
  • 项目必须有潜在价值,垃圾项目没必要撸。大部分项目都只能等代币上了交易所才能赚到钱,否则就是空气币。
  • 参与项目的门槛相对要高,如果门槛低撸空投的人太多,分不到多少代币。

我并不建议大家撸空投,因为水太深了。

尽管如今还有大量的人不懂 web3 的种种概念。但墙内和墙外是两个世界,我看到的 web3 空投领域,已经发展地非常成熟了。老玩家已经玩起了各种黑科技,比如使用麒麟同步器批量操作、使用Hubstudio 处理浏览器指纹、使用 MaxProxy 做 IP 代理、使用接码平台接码等等。在效率方面几乎完全碾压普通玩家。但这些对空投大师来说也只是基操,还有更离谱的黑科技,我就不多讲了。总之,普通玩家和资深玩家的差距,已经不是在一个位面上了。

重申一遍,我不建议普通人撸空投,我也不会推荐任何空投项目。

Web3 中的绝大多数项目,基本上只有两大核心。一是营销、一是技术。

说白了,空投和水龙头一样,都是一种营销手段。

我们了解完空投的概念和玩法,下面就开始讲技术。

批量转账合约设计

空投的需求,其实就是一个批量转账。所以我们要设计一个批量转账的合约。

批量转账有两种方式,第一种是 N 对 1,第二种是 N 对 N。

转账这件事,就只有三个要素。谁转的?转给谁?转多少?

谁转的?很明显就是项目方。

转给谁?一堆合约地址。应该是一个地址类型的数组。

转多少?可以是同一个金额,也可以每个地址的金额不一样。

现在我们了解了需求,并设计好了玩法,接下来我们开始具体实现。

智能合约实现

扣除模式

在调用者方面,我们可以设计两种模式。

  1. 第一种是仅合约发布者可以发起空投,空投的代币需要合约发起者先转入空投合约,由空投合约进行发起。
  2. 第二种是任何人都可以发起空投,空投的代币由合约调用者直接支出。如果是任何人都可以空投的话,可以收取一定的手续费。

发放模式

在代币发放方面也有两种方式。

  1. 第一种是多个地址对应一个转账金额。
  2. 第二张是一个地址对应一个转账金额。

基于以上两种视角的不同模式,所以合约可以有两种实现。

仅合约拥有者可调用版本

合约实现如下:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC20.sol";

contract Airdrop {
    IERC20 public tokenContract; // 代币合约
    address public owner; // 合约发布者

    constructor(address _tokenContractAddress) {
        tokenContract = IERC20(_tokenContractAddress);
        owner = msg.sender;
    }

    // 空投代币,多个地址对应一个数量
    function oneToMany(address[] memory _to, uint256 _amount) public {
        // 只有合约发布者可以调用
        require(msg.sender == owner, "Only the owner can airdrop tokens");
        // 验证合约中的代币数量是否足够
        uint256 totalAmount = _amount * _to.length;
        require(
            tokenContract.balanceOf(address(this)) >= totalAmount,
            "Not enough tokens in the contract"
        );
        // 空投代币
        for (uint256 i = 0; i < _to.length; i++) {
            tokenContract.transfer(_to[i], _amount);
        }
    }

    // 空投代币,一个地址对应一个数量
    function oneToOne(address[] memory _to, uint256[] memory _amount) public {
        // 只有合约发布者可以调用
        require(msg.sender == owner, "Only the owner can airdrop tokens");
        // 验证数组长度是否相等
        require(
            _to.length == _amount.length,
            "The length of the two arrays must be the same"
        );
        // 验证合约中的代币是否足够
        uint256 totalAmount = 0;
        for (uint256 i = 0; i < _amount.length; i++) {
            totalAmount += _amount[i];
        }
        require(
            tokenContract.balanceOf(address(this)) >= totalAmount,
            "Not enough tokens in the contract"
        );
        // 空投代币
        for (uint256 i = 0; i < _to.length; i++) {
            tokenContract.transfer(_to[i], _amount[i]);
        }
    }
}

核心逻辑仅仅是一个 for 循环调用。同时需要检查余额是否足够;地址数量与金额数量是否匹配等。

代码中有详细的注释,不需要多做解释。

任何人可调用版本(含手续费)

合约实现如下:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./IERC20.sol";

// 任何人都可以调用,但是需要支付手续费
contract AirdropFree {
    IERC20 public tokenContract; // 代币合约
    address public owner; // 合约发布者
    address private _marketingWalletAddress; // 营销钱包地址,用于收取手续费
    uint256 private _feeRate = 10; // 手续费比例,单位:万分之一

    constructor(
        address _tokenContractAddress,
        address _marketingWallet,
        uint256 _fee
    ) {
        tokenContract = IERC20(_tokenContractAddress);
        _marketingWalletAddress = _marketingWallet;
        _feeRate = _fee;
        owner = msg.sender;
    }

    // 空投代币,多个地址对应一个数量
    function oneToMany(address[] memory _to, uint256 _amount) public {
        uint256 totalAmount = _amount * _to.length;
        // 计算手续费
        uint256 fee = (totalAmount * _feeRate) / 10000;
        // 增加手续费
        totalAmount += fee;
        // 验证调用者的代币数量是否足够
        require(
            tokenContract.balanceOf(msg.sender) >= totalAmount,
            "Not enough tokens in the address"
        );
        // 检查调用者授权数量是否足够
        require(
            tokenContract.allowance(msg.sender, address(this)) >= totalAmount,
            "Not enough tokens approved"
        );
        // 空投代币
        for (uint256 i = 0; i < _to.length; i++) {
            tokenContract.transferFrom(msg.sender, _to[i], _amount);
        }
        // 转移手续费
        tokenContract.transferFrom(msg.sender, _marketingWalletAddress, fee);
    }

    // 空投代币,一个地址对应一个数量
    function oneToOne(address[] memory _to, uint256[] memory _amount) public {
        // 验证数组长度是否相等
        require(
            _to.length == _amount.length,
            "The length of the two arrays must be the same"
        );
        // 计算总数量
        uint256 totalAmount = 0;
        // 计算手续费
        uint256 fee = 0;
        for (uint256 i = 0; i < _amount.length; i++) {
            totalAmount += _amount[i];
            fee += (_amount[i] * _feeRate) / 10000;
        }
        // 增加手续费
        totalAmount += fee;
        // 验证调用者的代币数量是否足够
        require(
            tokenContract.balanceOf(msg.sender) >= totalAmount,
            "Not enough tokens in the address"
        );
        // 检查调用者授权数量是否足够
        require(
            tokenContract.allowance(msg.sender, address(this)) >= totalAmount,
            "Not enough tokens approved"
        );
        // 空投代币
        for (uint256 i = 0; i < _to.length; i++) {
            tokenContract.transferFrom(msg.sender, _to[i], _amount[i]);
        }
        // 转移手续费
        tokenContract.transferFrom(msg.sender, _marketingWalletAddress, fee);
    }
}

核心逻辑仅仅是一个 for 循环调用。同时需要检查余额是否足够;地址数量与金额数量是否匹配等。但这里附带了手续费的计算与抽取逻辑。

代码中有详细的注释,不需要多做解释。

合约测试

先来测试仅合约拥有者可调用版本:

const NoahToken = artifacts.require("NoahToken");
const Airdrop = artifacts.require("Airdrop");

contract("Airdrop", (accounts) => {
  const [alice, bob, carol, dave] = accounts;

  it("oneToMany", async () => {
    // 发 Noah 币,发行 1024 个
    const noahTokenInstance = await NoahToken.new('noah', 'NOAH', 0, '1024', { from: alice });
    // 发空投合约
    const airdropInstance = await Airdrop.new(noahTokenInstance.address, { from: alice });
    // 给空投合约转账 100 个 Noah 币
    const airdropTotalAmount = 100;
    await noahTokenInstance.transfer(airdropInstance.address, airdropTotalAmount, { from: alice });
    // 给 3 个账户发空投,每个账户 10 个 Noah 币
    const amount = 10;
    await airdropInstance.oneToMany([bob, carol, dave], amount, { from: alice });
    // 检查 3 个账户的 Noah 币数量
    const bobBalance = await noahTokenInstance.balanceOf(bob);
    const carolBalance = await noahTokenInstance.balanceOf(carol);
    const daveBalance = await noahTokenInstance.balanceOf(dave);
    assert.equal(bobBalance.toString(), amount);
    assert.equal(carolBalance.toString(), amount);
    assert.equal(daveBalance.toString(), amount);
    // 检查空投合约的 Noah 币数量
    const airdropBalance = await noahTokenInstance.balanceOf(airdropInstance.address);
    assert.equal(airdropBalance.toString(), airdropTotalAmount - 3 * amount);
  });

  it("oneToOne", async () => {
    // 发 Noah 币,发行 1024 个
    const noahTokenInstance = await NoahToken.new('noah', 'NOAH', 0, '1024', { from: alice });
    // 发空投合约
    const airdropInstance = await Airdrop.new(noahTokenInstance.address, { from: alice });
    // 给空投合约转账 100 个 Noah 币
    const airdropTotalAmount = 100;
    await noahTokenInstance.transfer(airdropInstance.address, airdropTotalAmount, { from: alice });
    // 给 3 个账户发空投,bob 10 个,carol 15 个,dave 20 个
    const amounts = [10, 15, 20];
    await airdropInstance.oneToOne([bob, carol, dave], amounts, { from: alice });
    // 检查 3 个账户的 Noah 币数量
    const bobBalance = await noahTokenInstance.balanceOf(bob);
    const carolBalance = await noahTokenInstance.balanceOf(carol);
    const daveBalance = await noahTokenInstance.balanceOf(dave);
    assert.equal(bobBalance.toString(), amounts[0]);
    assert.equal(carolBalance.toString(), amounts[1]);
    assert.equal(daveBalance.toString(), amounts[2]);
    // 检查空投合约的 Noah 币数量
    const airdropBalance = await noahTokenInstance.balanceOf(airdropInstance.address);
    assert.equal(airdropBalance.toString(), airdropTotalAmount - amounts.reduce((a, b) => a + b));
  });
});

再来测试任何人都可以空投,但需要收取手续费的版本。和上面的主要区别是手续费的计算。

const NoahToken = artifacts.require("NoahToken");
const AirdropFree = artifacts.require("AirdropFree");

contract("AirdropFree", (accounts) => {
  const [alice, bob, carol, dave] = accounts;

  it("oneToMany", async () => {
    // 发 Noah 币,发行 10240000 个
    const noahTokenInstance = await NoahToken.new('noah', 'NOAH', 0, '10240000', { from: alice });
    // 发空投合约 设置营销收款账户为 dave;手续费为万分之 10(0.001)
    const airdropInstance = await AirdropFree.new(noahTokenInstance.address, dave, 10, { from: alice });
    // 授权空投合约可以操作 10000 个 Noah 币
    await noahTokenInstance.approve(airdropInstance.address, 10000, { from: alice });
    // 给 2 个账户发空投,每个账户 1000 个 Noah 币
    const amount = 1000;
    await airdropInstance.oneToMany([bob, carol], amount, { from: alice });
    // 检查 2 个账户的 Noah 币数量
    const bobBalance = await noahTokenInstance.balanceOf(bob);
    const carolBalance = await noahTokenInstance.balanceOf(carol);
    assert.equal(bobBalance.toString(), amount, "bob balance is not 1000");
    assert.equal(carolBalance.toString(), amount, "carol balance is not 1000");
    const daveBalance = await noahTokenInstance.balanceOf(dave);
    const fee = amount * 2 * 0.001// 手续费
    // 检查 dave 的营销收款是否正确
    assert.equal(daveBalance.toString(), fee, "dave balance is not 2");
    const airdropTotalAmount = amount * 2 + fee;// 空投总费用
    // 检查 alice 的 Noah 币数量
    const aliceBalance = await noahTokenInstance.balanceOf(alice);
    assert.equal(aliceBalance.toString(), 10240000 - airdropTotalAmount, "alice balance is not 10237998");
  });

  it("oneToOne", async () => {
    // 发 Noah 币,发行 10240000 个
    const noahTokenInstance = await NoahToken.new('noah', 'NOAH', 0, '10240000', { from: alice });
    // 发空投合约
    const airdropInstance = await AirdropFree.new(noahTokenInstance.address, dave, 10, { from: alice });
    // 授权空投合约可以操作 10000 个 Noah 币
    await noahTokenInstance.approve(airdropInstance.address, 10000, { from: alice });
    // 给 2 个账户发空投,bob 1000 个,carol 2000 个
    const amounts = [1000, 2000];
    await airdropInstance.oneToOne([bob, carol], amounts, { from: alice });
    // 检查 2 个账户的 Noah 币数量
    const bobBalance = await noahTokenInstance.balanceOf(bob);
    const carolBalance = await noahTokenInstance.balanceOf(carol);
    assert.equal(bobBalance.toString(), amounts[0], "bob balance is not 10");
    assert.equal(carolBalance.toString(), amounts[1], "carol balance is not 15");
    const fee = amounts[0] * 0.001 + amounts[1] * 0.001// 手续费
    // 检查 dave 的营销收款是否正确
    const daveBalance = await noahTokenInstance.balanceOf(dave);
    assert.equal(daveBalance.toString(), fee, "dave balance is not 3");
    const airdropTotalAmount = amounts[0] + amounts[1] + fee;// 空投总费用
    // 检查 alice 的 Noah 币数量
    const aliceBalance = await noahTokenInstance.balanceOf(alice);
    assert.equal(aliceBalance.toString(), 10240000 - airdropTotalAmount, "alice balance is not 10236997");
  });
});

前端实现

一个数量对一堆地址的版本,我们可以使用一个 textarea 进行输入地址,每个换行符代表一个地址。

支持 Excel 与 txt 导入

当空投用户数量比较多时,在网页中输入的交互方式就捉襟见肘了。这时运营人员通常会使用 Excel 或者 txt 来统计数据。我们可以开发一个 Excel 与 txt 导入的功能。

不要小瞧这种体验功能,一个成功的 DApp 离不开这种细节。

首先安装一些库。

npm i react-dropzone xlsx formik react-icons
  • react-dropzone 用来支持拖拽文件。
  • xlsx 用来解析 Excel。
  • formik 用来做表单控制和校验。
  • react-icons 用来做图标库。

然后是实现具体的细节。

txt 的解析非常简单,我们自己操作即可。

Excel 的解析就需要依靠 xlsx 了。

首先实现一个导入组件。

function ImportExcel({ onImported }: { onImported: (data: any) => void }) {
  const onDrop = useCallback((acceptedFiles: File[]) => {
    const file = acceptedFiles[0];
    if (file.type === "text/plain") {
      const reader = new FileReader();
      reader.onabort = () => console.log("file reading was aborted");
      reader.onerror = () => console.log("file reading has failed");
      reader.onload = () => {
        const text = reader.result as string;
        onImported(text);
      };
      reader.readAsText(file);
      return;
    }

    const reader = new FileReader();
    reader.onabort = () => console.log("file reading was aborted");
    reader.onerror = () => console.log("file reading has failed");
    reader.onload = () => {
      const binaryStr = reader.result as string;
      const wb = read(binaryStr, { type: "binary" });
      const wsName = wb.SheetNames[0];
      const ws = wb.Sheets[wsName];
      const json = utils.sheet_to_json(ws);
      onImported(json);
    };
    reader.readAsBinaryString(file);
  }, []);
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    multiple: false,
    maxSize: 1024 * 1024,
    accept: {
      "text/csv": [".cvs"],
      "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
        ".xlsx",
      ],
      "application/vnd.ms-excel": [".xls"],
      "text/plain": [".txt"],
    },
  });
  return (
    <div
      {...getRootProps()}
      className="p-4 border-2 border-gray-300 border-dashed rounded-sm cursor-pointer"
    >
      <input {...getInputProps()} />
      {isDragActive ? (
        <p>拖拽文件到这里</p>
      ) : (
        <div>
          <p>拖拽文件到这里,或者点击上传文件</p>
          <p>支持的文件格式:.csv, .xlsx, .xls, .txt</p>
        </div>
      )}
    </div>
  );
}

再来实现一对多的组件,代码如下:

function OneToMany() {
  const [isLoading, setIsLoading] = useState(false);
  const onImported = (data: any) => {
    try {
      if (typeof data === "string") {
        return data;
      } else if (typeof data === "object") {
        const addresses = data
          .map(({ address }: any) => address)
          .filter((address: string) => ethers.utils.isAddress(address))
          .join("\n");
        return addresses;
      }
    } catch (err) {
      toast({
        title: "导入失败",
        status: "error",
      });
      return "";
    }
  };

  const { data } = useSigner();
  const nftContract = useContract({
    ...contract,
    signerOrProvider: data,
  });

  const toast = useToast();

  const airdrop = async (values: { addresses: string; amount: number }) => {
    setIsLoading(true);
    const addressesParam = values.addresses.trim().split("\n");
    const isAddressValid = addressesParam.every((address) =>
      ethers.utils.isAddress(address)
    );
    if (!isAddressValid) {
      toast({
        title: "地址不合法",
        status: "error",
      });
      return;
    }
    try {
      const { wait } = await nftContract?.oneToMany(
        addressesParam,
        values.amount
      );
      const receipt = await wait();
      console.log(receipt, "receipt");
      toast({
        title: "空投成功",
        status: "success",
        isClosable: true,
      });
    } catch (err) {
      console.log(err, "err");
      toast({
        title: "空投失败",
        status: "error",
        isClosable: true,
      });
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="flex flex-col gap-2">
      <Heading>多个地址使用相同的数量进行空投</Heading>
      <Alert>
        <div>
          <div>支持文件导入。</div>
          <ul>
            <li>如果是 txt 类型的文件,需要将地址以换行符分隔。</li>
            <li>
              如果是表格类型的文件,需要将内容放在第一个 sheet
              中,并将第一列命名为
              address。同时保证地址为文本类型,而不是数字类型。
            </li>
          </ul>
        </div>
      </Alert>

      <Formik
        initialValues={{
          addresses: "",
          amount: 0,
        }}
        onSubmit={airdrop}
      >
        {({ errors, touched, handleSubmit, values, setValues }) => (
          <form onSubmit={handleSubmit} className="flex flex-col gap-2">
            <Field
              as={ImportExcel}
              id="excel"
              name="excel"
              onImported={(value: string) => {
                setValues({
                  ...values,
                  addresses: onImported(value),
                });
              }}
            />

            <Alert>
              <ul>
                <li>每行代表一个地址</li>
              </ul>
            </Alert>

            <FormControl isInvalid={!!errors.addresses && touched.addresses}>
              <FormLabel htmlFor="addresses">地址</FormLabel>
              <Field
                as={Textarea}
                id={"addresses"}
                name="addresses"
                placeholder="请输入要转账的地址"
                validate={(value: string) => {
                  let error;
                  if (!value) {
                    error = "地址不能为空";
                  }
                  const addressesParam = value.trim().split("\n");
                  const isAddressValid = addressesParam.every((address) =>
                    ethers.utils.isAddress(address)
                  );
                  if (!isAddressValid) {
                    error = "地址格式不正确";
                  }
                  return error;
                }}
              ></Field>
              <FormErrorMessage>{errors.addresses}</FormErrorMessage>
            </FormControl>
            <FormControl isInvalid={!!errors.amount && touched.amount}>
              <FormLabel htmlFor="amount">数量</FormLabel>
              <Field
                as={Input}
                id={"amount"}
                name="amount"
                type="number"
                min={0}
                placeholder="请输入要转账的代币数量"
                validate={(value: number) => {
                  let error;
                  if (value <= 0) {
                    error = "数量必须大于 0";
                  }
                  return error;
                }}
              ></Field>
              <FormErrorMessage>{errors.amount}</FormErrorMessage>
            </FormControl>
            <AirDropButton
              isLoading={isLoading}
              isDisabled={!values.addresses || !values.amount || !!errors}
            />
          </form>
        )}
      </Formik>
    </div>
  );
}

效果如下:

地址与数量一对一的实现其实非常类似,代码如下:

function OneToOne() {
  const [inputData, setInputData] = useState<
    { address: string; amount: number; isValid?: boolean }[]
  >([]);
  const [address, setAddress] = useState("");
  const [amount, setAmount] = useState(0);
  const addresses = inputData.map(({ address }) => address);
  const amounts = inputData.map(({ amount }) => amount);
  const isValid =
    addresses.every((address) => ethers.utils.isAddress(address)) &&
    amounts.every((amount) => amount > 0) &&
    inputData.length > 0;

  const { config } = usePrepareContractWrite({
    ...contract,
    functionName: "oneToMany",
    args: [addresses, amounts],
    enabled: isValid,
  });

  const { write, data, isError } = useContractWrite(config);

  const {
    isSuccess,
    isLoading,
    isError: isWaitTransactionError,
  } = useWaitForTransaction({
    hash: data?.hash,
  });
  const toast = useToast();
  useEffect(() => {
    if (isWaitTransactionError) {
      toast({
        title: "空投失败",
        status: "error",
        isClosable: true,
      });
    }
    if (isSuccess) {
      toast({
        title: "空投成功",
        status: "success",
        isClosable: true,
      });
    }
  }, [isSuccess, isWaitTransactionError, toast]);

  const onImported = (data: any) => {
    if (typeof data === "string") {
      const result = data.split("\n").map((item) => {
        const [address, amount] = item.split(" ");
        return {
          address,
          amount: Number(amount),
        };
      });
      setInputData(result);
    } else if (typeof data === "object") {
      const result = data
        .filter((item: any) => {
          return (
            item.address &&
            item.amount &&
            ethers.utils.isAddress(item.address) &&
            Number(item.amount) > 0
          );
        })
        .map((item: any) => ({
          address: item.address,
          amount: Number(item.amount),
        }));
      setInputData(result);
    }
  };

  return (
    <div className="flex flex-col gap-2">
      <Heading>单个地址使用不同的数量进行空投</Heading>

      <Alert>
        <div>
          <div>支持文件导入。</div>
          <ul>
            <li>
              如果是 txt
              类型的文件,需要将地址和数量以空格符分隔,每组数据以换行符分隔。
            </li>
            <li>
              如果是表格类型的文件,需要将内容放在第一个 sheet
              中,并将第一列命名为 address,第二列命名为
              amount。同时保证地址为文本类型,而不是数字类型。
            </li>
          </ul>
        </div>
      </Alert>
      <ImportExcel onImported={onImported} />

      <Table>
        <Thead>
          <Tr>
            <Th>地址</Th>
            <Th className="w-28 md:w-40">数量</Th>
          </Tr>
        </Thead>
        <Tbody>
          {inputData.map(({ address, amount }, idx) => (
            <Tr key={idx}>
              <Td>
                <Input
                  value={address}
                  onChange={(e) => {
                    const newData = [...inputData];
                    newData[idx].address = e.target.value;
                    setInputData(newData);
                  }}
                  borderColor={ethers.utils.isAddress(address) ? "" : "red.500"}
                  placeholder="请输入地址"
                ></Input>
              </Td>
              <Td>
                <Input
                  value={amount}
                  onChange={(e) => {
                    const newData = [...inputData];
                    newData[idx].amount = Number(e.target.value);
                    setInputData(newData);
                  }}
                  borderColor={amount > 0 ? "" : "red.500"}
                  placeholder="请输入数量"
                ></Input>
              </Td>
            </Tr>
          ))}
          <Tr>
            <Td>
              <Input
                value={address}
                onChange={(e) => setAddress(e.target.value)}
                placeholder="请输入地址"
              ></Input>
            </Td>
            <Td>
              <Input
                type="number"
                value={amount}
                onChange={(e) => setAmount(Number(e.target.value))}
                placeholder="请输入数量"
              ></Input>
            </Td>
          </Tr>
        </Tbody>
      </Table>

      <div className="flex flex-col gap-2">
        <Button
          onClick={() => {
            setInputData([...inputData, { address, amount }]);
          }}
        >
          添加一列
        </Button>
        <Button
          type="submit"
          color={"white"}
          bg={"pink.400"}
          leftIcon={<Icon as={HiPaperAirplane} className="rotate-90" />}
          isLoading={isLoading}
          isDisabled={isLoading || !isValid}
          onClick={() => write?.()}
        >
          发送
        </Button>
      </div>
    </div>
  );
}

效果如下:

线上体验地址:www.webnext.cloud/

Github 源码地址:github.com/luzhenqian/…

我们是一群立志改变世界的人。而 Web3 是未来世界一大变数,我们想帮助更多人了解并加入 Web3,如果你对 Web3 感兴趣,可以添加我的微信:LZQ20130415,邀你入群,一起沉淀、一起成长、一起拥抱未来。