简单实现一个通用型薅羊毛合约

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

  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 更加便利

  • 缺点:

    • 合约代码明显更加复杂,总体的开发量并没有减少