什么是空投?
每一个新的 DApp 上线,第一件要做的事就是收集用户。
而收集用户的方式有哪些呢?原则上很简单:提供利益、提供价值。
像上一篇文章中介绍的“水龙头”就是一种收集用户的方式。
但水龙头是用户主动向项目方领取的,门槛比较低。而且黏性不高,用户领完币仍然可能不会使用你的 DApp。就像打卡一样。
空投是一种门槛相对更高一点的玩法。它是项目方主动给用户提供奖励。一般来说,空投的奖励会比水龙头高多了。
只有早期参与 DApp 的活跃用户,才会得到项目方的空投。空投奖励通常是以代币的形式。
那些整天在群里问「群主,有没有空投推荐?」的人,就是撸空投的。他们会参与多个 DApp,然后不停地交互。以此来得到项目方的空投。
但仅靠使用 DApp 是不够的,项目方为了保证真实活跃,会对空投玩法设置一些条件。比如必须要加入 discord、必须绑定推特和邮箱注册。
所以这就形成了一套账户,也就是常说的空投四件套:地址、discord、推特、邮箱。
空投经济,不仅养活了项目方、养活了撸空投的人。还顺便养活了一帮专门卖空投项目的人、专门养号卖号的人,这就是一个商业模式的闭环。
但依我个人经验来看,空投没有那么好撸。运气成分占很大一部分。有人忙活了一年,几乎所有空闲时间都花在撸空投上了,结果一年到头也就赚个三五万。而有人可能只参与了几个空投项目,就赚了几十万。
那怎么判断一个空投项目是否值得撸呢?注意以下几点:
- 首先项目方必须明确表示会有空投,或者强烈暗示。
- 项目必须有潜在价值,垃圾项目没必要撸。大部分项目都只能等代币上了交易所才能赚到钱,否则就是空气币。
- 参与项目的门槛相对要高,如果门槛低撸空投的人太多,分不到多少代币。
我并不建议大家撸空投,因为水太深了。
尽管如今还有大量的人不懂 web3 的种种概念。但墙内和墙外是两个世界,我看到的 web3 空投领域,已经发展地非常成熟了。老玩家已经玩起了各种黑科技,比如使用麒麟同步器批量操作、使用Hubstudio 处理浏览器指纹、使用 MaxProxy 做 IP 代理、使用接码平台接码等等。在效率方面几乎完全碾压普通玩家。但这些对空投大师来说也只是基操,还有更离谱的黑科技,我就不多讲了。总之,普通玩家和资深玩家的差距,已经不是在一个位面上了。
重申一遍,我不建议普通人撸空投,我也不会推荐任何空投项目。
Web3 中的绝大多数项目,基本上只有两大核心。一是营销、一是技术。
说白了,空投和水龙头一样,都是一种营销手段。
我们了解完空投的概念和玩法,下面就开始讲技术。
批量转账合约设计
空投的需求,其实就是一个批量转账。所以我们要设计一个批量转账的合约。
批量转账有两种方式,第一种是 N 对 1,第二种是 N 对 N。
转账这件事,就只有三个要素。谁转的?转给谁?转多少?
谁转的?很明显就是项目方。
转给谁?一堆合约地址。应该是一个地址类型的数组。
转多少?可以是同一个金额,也可以每个地址的金额不一样。
现在我们了解了需求,并设计好了玩法,接下来我们开始具体实现。
智能合约实现
扣除模式
在调用者方面,我们可以设计两种模式。
- 第一种是仅合约发布者可以发起空投,空投的代币需要合约发起者先转入空投合约,由空投合约进行发起。
- 第二种是任何人都可以发起空投,空投的代币由合约调用者直接支出。如果是任何人都可以空投的话,可以收取一定的手续费。
发放模式
在代币发放方面也有两种方式。
- 第一种是多个地址对应一个转账金额。
- 第二张是一个地址对应一个转账金额。
基于以上两种视角的不同模式,所以合约可以有两种实现。
仅合约拥有者可调用版本
合约实现如下:
// 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,邀你入群,一起沉淀、一起成长、一起拥抱未来。