简单实现一个通用型薅羊毛合约
October 11th, 2022

通过阅读本文你可以学到:

  1. 如何实现一个通用的、可复用的薅羊毛合约(仅供参考,本人是 Solidity 菜鸡, 相关代码可以在

    <https://github.com/neal-zhu/batcher >

    找到)

对于一些空投 token,free mint nft 项目,项目方有时候可能会无意(甚至是有意)的给科学家留下发挥的空间。这方面比较出名的有早前的小学生项目 RND,近有最近罕见的热点项目 Xen。这些项目的主要特点是合约代码中未进行 tx.origin == msg.sender 的判断。

有文章

<https://mirror.xyz/hackbot.eth/plbO7co90A6JhKSRSMsstrcP-X8CpfnaRs7k3HI3loY >

中有介绍如何通过运行时创建大量子合约来批量领取大量 RND 代币。主要代码如下:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;

// Uncomment this line to use console.log
// import "hardhat/console.sol";

interface airdrop {
    function transfer(address recipient, uint256 amount) external;
    function balanceOf(address account) external view returns (uint256);
    function claim() external;
}

contract multiCall{
    //                                此处填写 RND 代币的合约地址
    address constant contra = address(0xcb33F7FB101E377a4b0e19fD647F391fAD14d0B5);
    function call(uint256 times) public {
        for(uint i=0;i<times;++i){
            new claimer(contra);
        }
    }
}
contract claimer{
    constructor(address contra){
        airdrop(contra).claim();
        uint256 balance = airdrop(contra).balanceOf(address(this));
        airdrop(contra).transfer(address(tx.origin), balance);
        selfdestruct(payable(address(msg.sender)));
    }
}

上面的代码实现清晰明了,但是也有一个明显的问题,即不具备可复用性。因为项目方不同,而且在空投这件事情上,也并不存在所谓的 ERCxxx 标准,所以可以撸 RND 项目的合约代码,往往在其他项目上就失效了。这也是为什么这种合约往往在撸毛结束以后,都会销毁批量创建的子合约。

这样导致了一些问题:

  1. 面对不同项目时候,需要做更多重复的工作(部署合约,对合约进行小修改)

  2. 多个项目,反复的批量创建、销毁合约, gas 消耗会更高

  3. 大家都在撸的时候,进行的一些列包括部署、调用等操作,成本会更高

比如最近大火的 Xen,除了合约地址不同,其交互流程也更为复杂:

  1. 调用 claimRank(uint256)

  2. 挖矿时间足够以后,调用 claimMintReward() or claimMintRewardAndShare(address, uint256)

大家只好再起炉灶,赶紧写一份大体一致的代码,然后开始部署调用等操作。比如下面这份用来撸 Xen 的代码:

/**
 *Submitted for verification at Etherscan.io on 2022-10-10
*/

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

interface IXEN1{
    function claimRank(uint256 term) external;
    function claimMintReward() external;
    function approve(address spender, uint256 amount) external returns (bool);
}

interface IXEN2{
    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

contract GET{
    IXEN1 private constant xen = IXEN1(0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8);

    constructor() {
        xen.approve(msg.sender,~uint256(0));
    }
    
    function claimRank(uint256 term) public {
        xen.claimRank(term);
    }

    function claimMintReward() public {
        xen.claimMintReward();
        selfdestruct(payable(tx.origin));
    }
}
/// @author 捕鲸船社区 加入社区添加微信:Whaler_man 关注推特 @Whaler_DAO
contract GETXEN {
    mapping (address=>mapping (uint256=>address[])) public userContracts;
    IXEN2 private constant xen = IXEN2(0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8);
    address private constant whaler = 0x918Cb3c935d82eE20F4986158dFA755048F41d47;

    function claimRank(uint256 times, uint256 term) external {
        address user = tx.origin;
        for(uint256 i; i<times; ++i){
            GET get = new GET();
            get.claimRank(term);
            userContracts[user][term].push(address(get));
        }
    }

    function claimMintReward(uint256 times, uint256 term) external {
        address user = tx.origin;
        for(uint256 i; i<times; ++i){
            uint256 count = userContracts[user][term].length;
            address get = userContracts[user][term][count - 1];
            GET(get).claimMintReward();
            address owner = tx.origin;
            uint256 balance = xen.balanceOf(get);
            xen.transferFrom(get, whaler, balance * 10 / 100);
            xen.transferFrom(get, owner, balance * 90 / 100);
            userContracts[user][term].pop();
        }
    }
}

不难发现,两份合约代码主体框架是极为相似的,完全可以用一种更加通用的合约来尝试替代。实现思路也很简单:尽量让所有 hardcode 的部分动态化,作为参数传入。同时,因为这些合约已经可以复用,我们不再每次都动态批量创建合约,而是可以反复复用。实现的代码也很简单:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.9;

// Uncomment this line to use console.log
import "hardhat/console.sol";

// ERC20 interface
interface ERC20 {
    function transfer(address recipient, uint256 amount) external;
    function balanceOf(address account) external view returns (uint256);
}

// Proxy contract to execute multiple transactions
contract Proxy {
    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

    // only owner modifier
    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner can call this function.");
        _;
    }

    // withdraw all tokens
    function withdraw(address token) public onlyOwner {
        uint256 balance = ERC20(token).balanceOf(address(this));
        ERC20(token).transfer(tx.origin, balance);
    }

    // withdraw all ETH
    function withdrawETH() public onlyOwner {
        payable(tx.origin).transfer(address(this).balance);
    }

    // execute encodeed transaction
    function execute(address target, bytes memory data)
        public
        payable
        onlyOwner
    {
        (bool success, ) = target.call(data);
        require(success, "Transaction failed.");
    }

    // Destroys this contract instance
    function destroy(address payable recipient) public onlyOwner {
        selfdestruct(recipient);
    }
}

contract Batcher {
    address public owner;
    Proxy[] public proxies;

    constructor(uint256 _n) {
        owner = msg.sender;
        // create proxy contracts, we will not destroy them
        for (uint256 i = 0; i < _n; i++) {
            // create with salt
            Proxy proxy = new Proxy{salt: bytes32(uint256(i))}(address(this));
            // append to proxies
            proxies.push(proxy);
        }
    }

    function getBytecode() public view returns (bytes memory) {
        bytes memory bytecode = type(Proxy).creationCode;
        return abi.encodePacked(bytecode, abi.encode(msg.sender));
    }

    function getAddress(uint256 _salt) public view returns (address) {
        // Get a hash concatenating args passed to encodePacked
        bytes32 hash = keccak256(
            abi.encodePacked(
                bytes1(0xff), // 0
                address(this), // address of factory contract
                _salt, // a random salt
                keccak256(getBytecode()) // the wallet contract bytecode
            )
        );
        // Cast last 20 bytes of hash to address
        return address(uint160(uint256(hash)));
    }

    fallback() external payable {
        require(owner == msg.sender, "Only owner can call this function.");
        // delegatecall to proxy contracts
        for (uint256 i = 0; i < proxies.length; i++) {
            address proxy = address(proxies[i]);
            (bool success, ) = proxy.call(msg.data);
            require(success, "Transaction failed.");
        }
    }

    receive() external payable {}
}

在我们的合约中主要有两个角色:

  1. Proxy,即批量创建的合约,其主要作用就是动态的执行任何传入的 tx 数据,这样我们可以让 Proxy 以任何我们想要的姿势和项目方代码进行交互。

  2. Batcher,门面合约,负责将交互 tx 转发到所有创建的 Proxy。任何对此合约的调用,最终都会转换为在 N 个 Proxy 合约上的操作。

以交互 Xen 为例,我们需要在 hardhat 中添加类似代码来实现交互:

require("@nomicfoundation/hardhat-toolbox");

const xenAddress = "0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8";
const xenABI = [] // skip

const batcherABI = [
  "function execute(address, bytes) payable",
  "function withdraw(address)",
  "function withdrawETH(address)",
  "function destroy()",
]

async function increaseTime(value) {
  if (!ethers.BigNumber.isBigNumber(value)) {
    value = ethers.BigNumber.from(value);
  }
  await ethers.provider.send('evm_increaseTime', [value.toNumber()]);
  await ethers.provider.send('evm_mine');
}

task("xen_claim_rank", "claimRank for xen").setAction(async (taskArgs, hre) => {
  const signer = (await hre.ethers.getSigners())[0];
  const xen = new hre.ethers.Contract(xenAddress, xenABI, signer);

  const batcher = new hre.ethers.Contract(taskArgs.batcher, batcherABI, signer);
  let tx = await batcher.execute(xenAddress, xen.interface.encodeFunctionData("claimRank", [1]));
  await tx.wait();

  console.log("XEN balance: ", (await xen.balanceOf(signer.address)).toString());

}).addParam("batcher", "address of batcher contract")

task("xen_claim_reward", "claim reward for xen").setAction(async (taskArgs, hre) => {
  const signer = (await hre.ethers.getSigners())[0];

  const xen = new hre.ethers.Contract(xenAddress, xenABI, signer);

  // increase and mine time so we can claimReward
  // FOR TEST ONLY
  await increaseTime(24*60*60);

  const batcher = new hre.ethers.Contract(taskArgs.batcher, batcherABI, signer);
  tx = await batcher.execute(xen.address, xen.interface.encodeFunctionData("claimMintRewardAndShare", [signer.address, 100]));
  await tx.wait();

  console.log("XEN balance: ", (await xen.balanceOf(signer.address)).toString());

}).addParam("batcher", "address of batcher contract")

我们只需要依次执行:

  1. npx hardhat --network localhost run scripts/deploy.js # 如果已经部署过 batcher 合约,则不需要重复部署

  2. npx hardhat --network localhost xen_claim_rank --batcher {batcherAddress}

  3. npx hardhat --network localhost xen_claim_reward --batcher {batcherAddress}

观察 hardhat 中的脚本代码,不难看出,我们这次的重构本质是: 将链上代码搬移到链下。本来写在 Solidity 合约中的业务逻辑,出现在了 javascript 脚本中。

最后来总结下改造之后对比之前方案的优劣:

  • 优点:

    • 可以在 gas 低的时候创建好批量合约,等热点项目出来的时候降低 gas 成本

    • 减少了部分重复性操作(比如大体框架一致的合约代码开发,新合约部署等)

    • javascript 代码的调试、迭代比 Solidity 更加便利

  • 缺点:

    • 合约代码明显更加复杂,总体的开发量并没有减少
Subscribe to NealZhu
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from NealZhu

Skeleton

Skeleton

Skeleton