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

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

什么是空投?


每一个新的 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>
  );
}

效果如下:

image.png

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


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>
  );
}

效果如下:

image.png

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

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

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



相关文章
|
11天前
|
前端开发 JavaScript API
(前端3D模型开发)网页三维CAD中加载和保存STEP模型
本文介绍了如何使用`mxcad3d`库在网页上实现STEP格式三维模型的导入与导出。首先,通过官方教程搭建基本项目环境,了解核心对象如MxCAD3DObject、Mx3dDbDocument等的使用方法。接着,编写了加载和保存STEP模型的具体代码,包括HTML界面设计和TypeScript逻辑实现。最后,通过运行项目验证功能,展示了从模型加载到保存的全过程。此外,`mxcad3d`还支持多种其他格式的三维模型文件操作。
|
4天前
|
机器学习/深度学习 前端开发 算法
婚恋交友系统平台 相亲交友平台系统 婚恋交友系统APP 婚恋系统源码 婚恋交友平台开发流程 婚恋交友系统架构设计 婚恋交友系统前端/后端开发 婚恋交友系统匹配推荐算法优化
婚恋交友系统平台通过线上互动帮助单身男女找到合适伴侣,提供用户注册、个人资料填写、匹配推荐、实时聊天、社区互动等功能。开发流程包括需求分析、技术选型、系统架构设计、功能实现、测试优化和上线运维。匹配推荐算法优化是核心,通过用户行为数据分析和机器学习提高匹配准确性。
22 3
|
16天前
|
前端开发 安全 JavaScript
2025年,Web3开发学习路线全指南
本文提供了一条针对Dapp应用开发的学习路线,涵盖了Web3领域的重要技术栈,如区块链基础、以太坊技术、Solidity编程、智能合约开发及安全、web3.js和ethers.js库的使用、Truffle框架等。文章首先分析了国内区块链企业的技术需求,随后详细介绍了每个技术点的学习资源和方法,旨在帮助初学者系统地掌握Dapp开发所需的知识和技能。
2025年,Web3开发学习路线全指南
|
23天前
|
存储 前端开发 JavaScript
如何在项目中高效地进行 Web 组件化开发
高效地进行 Web 组件化开发需要从多个方面入手,通过明确目标、合理规划、规范开发、加强测试等一系列措施,实现组件的高效管理和利用,从而提高项目的整体开发效率和质量,为用户提供更好的体验。
27 7
|
24天前
|
前端开发 JavaScript 搜索推荐
HTML与CSS在Web组件化中的核心作用及前端技术趋势
本文探讨了HTML与CSS在Web组件化中的核心作用及前端技术趋势。从结构定义、语义化到样式封装与布局控制,两者不仅提升了代码复用率和可维护性,还通过响应式设计、动态样式等技术增强了用户体验。面对兼容性、代码复杂度等挑战,文章提出了相应的解决策略,强调了持续创新的重要性,旨在构建高效、灵活的Web应用。
32 6
|
28天前
|
前端开发 数据处理 Android开发
Flutter前端开发中的调试技巧与工具使用方法,涵盖调试的重要性、基本技巧如打印日志与断点调试、常用调试工具如Android Studio/VS Code调试器和Flutter Inspector的介绍
本文深入探讨了Flutter前端开发中的调试技巧与工具使用方法,涵盖调试的重要性、基本技巧如打印日志与断点调试、常用调试工具如Android Studio/VS Code调试器和Flutter Inspector的介绍,以及具体操作步骤、常见问题解决、高级调试技巧、团队协作中的调试应用和未来发展趋势,旨在帮助开发者提高调试效率,提升应用质量。
45 8
|
1天前
|
前端开发 搜索推荐 安全
陪玩系统架构设计陪玩系统前后端开发,陪玩前端设计是如何让人眼前一亮的?
陪玩系统的架构设计、前后端开发及前端设计是构建吸引用户、功能完善的平台关键。架构需考虑用户需求、技术选型、安全性等,确保稳定性和扩展性。前端可选用React、Vue或Uniapp,后端用Spring Boot或Django,数据库结合MySQL和MongoDB。功能涵盖用户管理、陪玩者管理、订单处理、智能匹配与通讯。安全性方面采用SSL加密和定期漏洞扫描。前端设计注重美观、易用及个性化推荐,提升用户体验和平台粘性。
12 0
|
27天前
|
开发框架 搜索推荐 数据可视化
Django框架适合开发哪种类型的Web应用程序?
Django 框架凭借其强大的功能、稳定性和可扩展性,几乎可以适应各种类型的 Web 应用程序开发需求。无论是简单的网站还是复杂的企业级系统,Django 都能提供可靠的支持,帮助开发者快速构建高质量的应用。同时,其活跃的社区和丰富的资源也为开发者在项目实施过程中提供了有力的保障。
|
27天前
|
开发框架 JavaScript 前端开发
TypeScript 是一种静态类型的编程语言,它扩展了 JavaScript,为 Web 开发带来了强大的类型系统、组件化开发支持、与主流框架的无缝集成、大型项目管理能力和提升开发体验等多方面优势
TypeScript 是一种静态类型的编程语言,它扩展了 JavaScript,为 Web 开发带来了强大的类型系统、组件化开发支持、与主流框架的无缝集成、大型项目管理能力和提升开发体验等多方面优势。通过明确的类型定义,TypeScript 能够在编码阶段发现潜在错误,提高代码质量;支持组件的清晰定义与复用,增强代码的可维护性;与 React、Vue 等框架结合,提供更佳的开发体验;适用于大型项目,优化代码结构和性能。随着 Web 技术的发展,TypeScript 的应用前景广阔,将继续引领 Web 开发的新趋势。
36 2
|
27天前
|
缓存 前端开发 JavaScript
JavaScript前端路由的实现原理及其在单页应用中的重要性,涵盖前端路由概念、基本原理、常见实现方式
本文深入解析了JavaScript前端路由的实现原理及其在单页应用中的重要性,涵盖前端路由概念、基本原理、常见实现方式(Hash路由和History路由)、优点及挑战,并通过实际案例分析,帮助开发者更好地理解和应用这一关键技术,提升用户体验。
66 1
下一篇
DataWorks