以太坊进阶操作 - 合约调用、地址预测、发送与接收 ETH

本文主要关注以太坊智能合约的进阶操作,包括合约的四种调用方式、合约的创建、地址预测、发送 eth 的三种方法、接收 eth 的三种不同方法等内容。通过 solidity 代码配合 foundry 进行测试。适合新手小白学习

合约调用

与传统编程语言直接的调用类似,一个合约可用通过调用来与另外的合约交互。

  1. 源码(接口、地址)调用

    • 被调用合约
      contract Callee {
      uint public x;
      function setX(uint _x) public {
      x = _x;
      }
      function getX() public view returns (uint) {
      return x;
      }
      }
    • 调用者合约
interface ICallee {
    function setX(uint _x) external;
    function getX() external view returns (uint);
}
contract Caller {
    function callSetX(address calleeAddress, uint _x) public {
        ICallee(calleeAddress).setX(_x);
    }
    function callGetX(address calleeAddress) public view returns (uint) {
        return ICallee(calleeAddress).getX();
    }
}
  • foundry 测试合约
contract CallerTest is Test {
    Callee public callee;
    Caller public caller;

    function setUp() public {
        callee = new Callee();
        caller = new Caller();
    }

    function testCallSetAndGetX() public {
        // 设置 x 为 123
        caller.callSetX(address(callee), 123);

        // 获取 x 的值
        uint value = caller.callGetX(address(callee));

        assertEq(value, 123, "x should be 123");
    }
}
  • 测试结果解析
    • 通过部署一个 Callee 合约和一个 Caller 合约,Caller 通过 ICallee(calleeAddress)包裹一个地址来直接进行调用。此处若不使用接口直接使用实现类来调用也可实现。

image.png

  1. Call

    • Calladdress 类型的低级成员函数,可以用来和其他合约进行交互。

    • Call 适合用于不知道对方合约源代码的情况下,进行发起调用。仅需要知道对方合约地址和调用的函数名即可。

    • 官方推荐使用 Call 来发送 ETH,可以触发目标合约的 fallbackreceive 函数。

    • 官方不推荐使用 Call 来调用目标合约,因为在调用不安全合约时,相当于将主动权交给他。(推荐使用接口进行调用)除非在不知道对方源码和接口的情况下。

    • 调用者 和接口调用类似,Callee 合约和测试合约无须改变,改变 Caller 合约。

contract Caller {
    function callSetX(address calleeAddress, uint _x) public {
        (bool success, ) = calleeAddress.call(
            abi.encodeWithSignature("setX(uint256)", _x)
        );
        require(success, "setX call failed");
    }
}
  • 调用者 测试合约无须改变,直接执行测试。可以看出,Caller 中是通过 (bool success, ) = calleeAddress.call(abi.encodeWithSignature("setX(uint256)", _x)); 来发起调用的,其中,calleeAddress 为目标合约地址,setX(uint256) 为目标函数名和参数类型,_x 为实际参数。

image.png

  1. Static Call

    • 静态调用也是一种低级调用的方式,相比于 Call 来说,它只用于只读函数(prueview)来进行调用,不会引起状态的更改。
    • 调用者:与 Call 类似,静态调用也只需更改 Caller 合约。
contract Caller {
    function callGetX(address calleeAddress) public view returns (uint) {
        (bool success, bytes memory data) = calleeAddress.staticcall(
            abi.encodeWithSignature("getX()")
        );
        require(success, "getX call failed");
        return abi.decode(data, (uint));
    }
}
  1. foundry 测试: 在 callGet 函数中,我们将 call 改为 staticcall 即可完成静态调用的发起。其他部分和普通 call 一致。

image.png

  1. Delegate Call

    delegatecallcall 类似,也是地址的低级成员函数。其中 delegate 的含义是 ”委托“,主要用在代理合约中。

image.png

  • 如图所示,通过 delegatecall 调用的即为代理合约,他和普通 call 的区别是:
    • 普通 call 的状态变量各自都是独立的,修改 B 的状态变量不会引起 A 的状态变量更改。而 delegatecall 的状态变量修改时,修改的其实是代理合约A 的状态变量 v
    • 普通 callmsg.sender 是调用他的合约,而 delegatecallmsg.sender 是代理合约 Amsg.sender。可以理解为:逻辑合约B 所执行的操作实际上在代理合约 A 上执行的,逻辑合约只负责行为的抽象。
  • 调用委托调用只需要将 call 换成 delegatecall 即可。
  • 代理合约:

    contract Proxy {
    // 存储结构必须和 Logic 完全一致
    uint public num;
    
    function delegateSetNum(address logic, uint _num) public {
        (bool success, ) = logic.delegatecall(
            abi.encodeWithSignature("setNum(uint256)", _num)
        );
        require(success, "delegatecall failed");
    }
    }
    • 逻辑合约
      
      contract Logic {
      uint public num;

    function setNum(uint _num) public { num = _num; } }

    
    - **foundry 测试合约**:
contract DelegateCallTest is Test {
    Logic public logic;
    Proxy public proxy;

    function setUp() public {
        logic = new Logic();
        proxy = new Proxy();
    }

    function testDelegateCallSetNum() public {
        proxy.delegateSetNum(address(logic), 999);

        // proxy 的 num 应该更新了(不是 logic)
        assertEq(proxy.num(), 999, "Proxy's num should be updated to 999");

        // logic 的 num 仍然是 0
        assertEq(logic.num(), 0, "Logic's num should remain 0");
    }
}

image.png

Multi Call

multiCall 是合约调用的另一种方式,支持一次交易中执行多个函数调用,可以降低 gas 费并提高调用效率。

  • 方便:一次性调用合约中的多个函数。
  • 节约 gas:多个交易合并成一个交易执行。
  • 原子性:可以使一笔交易中执行所有操作,要么全部执行,要么全部不执行。且可通过返回的参数来手动控制成功失败是否回滚。
  • multicall 合约:
contract Multicall {
    struct Call {
        address target;
        bytes data;
    }

    function multicall(Call[] calldata calls) external returns (bytes[] memory results) {
        results = new bytes[](calls.length);

        for (uint i = 0; i < calls.length; i++) {
            (bool success, bytes memory result) = calls[i].target.call(calls[i].data);
            require(success, "Call failed");
            results[i] = result;
        }
    }
}
  • 被调用合约
contract DemoContract {
    function getOne() external pure returns (uint256) {
        return 1;
    }

    function getTwo() external pure returns (uint256) {
        return 2;
    }
}
  • foundry 测试合约
contract MulticallTest is Test {
    Multicall public multicall;
    DemoContract public demo;

    function setUp() public {
        multicall = new Multicall();
        demo = new DemoContract();
    }

    function testMulticall() public {
        // 准备 calldata
        bytes memory call1 = abi.encodeWithSelector(demo.getOne.selector);
        bytes memory call2 = abi.encodeWithSelector(demo.getTwo.selector);

        Multicall.Call[] memory calls  = new Multicall.Call[](2);
        calls[0] = Multicall.Call(address(demo), call1);
        calls[1] = Multicall.Call(address(demo), call2);

        bytes[] memory results = multicall.multicall(calls);

        uint256 res1 = abi.decode(results[0], (uint256));
        uint256 res2 = abi.decode(results[1], (uint256));

        assertEq(res1, 1);
        assertEq(res2, 2);
    }
}
  • 可以看出,在 multicall 案例中,调用的底层实现方式仍然是 call()。也就是说,multicall 并不是额外的调用优化,而是在设计层面上封装了循环来 call

  • 那为什么在循环中调用多次 call 会比一次一次调用 call 要节省 gas

  • 多个单次 call:用户在前端发起交易。每笔都进入 EVM,需要单独消耗 gas。(这里测试采用 call 来模拟 EOA 账号发送两次 Transaction 的过程)

target1.call(data1);
target2.call(data2);
target3.call(data3);
  • 循环 call:在合约层面上封装了循环 call。实际上,是合约内的 call,属于 message call 而不是 transaction。共用合约上下文,故能减少 gas。实际上这里循环和 call 两次的效果是一致的,只是采用循环可以更加灵活调用。
function multicall(Call[] calldata calls) external returns (bytes[] memory results) {
    results = new bytes[](calls.length);
    for (uint i = 0; i < calls.length; i++) {
        (bool success, bytes memory result) = calls[i].target.call(calls[i].data);
        require(success, "Call failed");
        results[i] = result;
    }
}

image.png

合约创建

  1. Create

    在以太坊上,EOA 账户可以创建合约,合约账户也能创建合约。创建合约的方式有两种,分别是 createcreate2,两者都是 EVM 提供给我们的底层操作码(opcode)。但在上层也进行了封装,通过 new 关键字即可使用。

Contract x = new Contract{value: _value}(params)
  • Contract 为合约名,_value 为需要发送的 ETH 数量,params 为构造函数的参数。
  1. 工厂合约创建新合约
contract Foo {
    uint256 public age;
    constructor(uint256 _age) {
        age = _age;
    }
}

contract Factory {
    function deploy(uint256 _age) external returns (address) {
        Foo foo = new Foo(_age); // 使用 `CREATE` 指令创建新合约
        return address(foo);
    }
}
  1. foundry测试合约

contract FactoryTest is Test {
    Factory public factory;

    function setUp() public {
        factory = new Factory();
    }

    function testCreatesNewFoo() public {
        uint256 inputAge = 18;

        address fooAddr = factory.deploy(inputAge);

        // 检查合约是否被成功部署
        assertTrue(fooAddr.code.length > 0, "Contract code should exist");

        // 调用 Foo 的 age() 验证构造函数赋值
        uint256 age = Foo(fooAddr).age();
        assertEq(age, inputAge);
    }
}

可以看出,创建出来的合约地址为:0x104fBc016F4bb334D775a19E8A6510109AC63E00 。但如果多次创建,合约的地址会发生变化,合约地址会不一致。 image.png

  1. Create2

    使用 create2 指令可以实现固定地址、预测地址的功能。实际上,Uniswap 创建币对合约的时候就是使用的 create2 指令。

    1. 创建合约
contract Foo {
    uint256 public age;
    constructor(uint256 _age) {
        age = _age;
    }
}

bytes32 constant SALT = 0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef;
contract Factory {
    function deploy(uint256 _age) external returns (address) {
//      Foo foo = new Foo(_age); // 使用 `CREATE` 指令创建新合约

        Foo foo = new Foo{salt: SALT}(_age); // CREATE2 指令

        return address(foo);
    }
}
  1. foundry 测试合约
contract FactoryTest is Test {
    Factory public factory;

    function setUp() public {
        factory = new Factory();
    }

    function testDeployWithCreate2() public {
        uint256 inputAge = 42;
        address deployed = factory.deploy(inputAge);

        // 强转成 Foo,读取 age 是否正确
        uint256 age = Foo(deployed).age();
        assertEq(age, inputAge);
    }
}

image.png

  1. 预测地址

    function testForecastAddress() public {
        uint256 inputAge = 42;
        bytes32 salt = factory.SALT();
        bytes memory bytecode = abi.encodePacked(
            type(Foo).creationCode,
            abi.encode(inputAge)
        );

        // 计算预测地址
        bytes32 hash = keccak256(
            abi.encodePacked(
                bytes1(0xff),
                address(factory),
                salt,
                keccak256(bytecode)
            )
        );
        address predicted = address(uint160(uint256(hash)));

        // 部署并获取真实地址
        address deployed = factory.deploy(inputAge);

        assertEq(deployed, predicted, "CREATE2 address mismatch");

        // 验证 age 值
        uint256 age = Foo(deployed).age();
        assertEq(age, inputAge);
    }
  • 通过使用 keccak256 来对 0xffaddresssaltinitcode来进行 hash,得出一个预测的地址。与合约部署的真是地址进行比较,可以看出,两者一致。 image.png

    1. ❓Create 和 Create2 是怎么保证不同链上地址一致的?

      • create 是怎么创建地址的
  • create 是怎么创建地址的?

    新地址 = hash(创建者地址, nonce)

  • create2 是怎么创建地址的

    新地址 = hash("0xFF",创建者地址, salt, initcode)

    其中 0xFF 是常数。salt 是盐值,用于影响新合约地址,initcode 为新合约的创建码。可通过 type(MyContract).creationCode 来获取。

    
    - ❓**怎么确保不同链地址一致**?
       - `create` 无法保证地址一致,因为 `create` 创建为两个参数,一个是 `address` 一个是 `nonce` ,而不同链上的 `nonce` 是不一样的。
       - `create2` 可以保证地址一致,`create2` 有四个参数:`0xFF`,`address`、`salt`、`initcode`。只需保证 `salt` 和 `initcode` 是一致的即可在不同链上创建出相同的地址。

发送和接收 ETH

  1. 发送 ETH

    1. Call call()没有 gas 限制,最为灵活,也是官方推荐的发送 ETH 的方式。

    2. Transfer transfer()2300 gas 限制,发送失败会自动 revert 交易。

    3. Send send()2300 gas限制,发送失败不会自动 revert,几乎没人用它。

  2. 接收 ETH

    solidity 中有三种函数可以支持接受 ETH。分别是receive()fallback() ,还有一种是普通函数。

    1. Receive() receive()msg.data 为空,且合约中写有 receive() 会触发。

    2. Fallback() fallback()msg.data有值,或没有 receive() ,或调用可支付的普通函数函数不存在的时候会触发。

    3. 普通函数 普通函数可以通过带有 payable 关键字来修饰,说明这个函数是可以接收 ETH 的。

    • 发送接收 ETH 合约:
contract PayableReceiver {
    event Received(address sender, uint256 amount, string functionType);

    receive() external payable {
    }

    fallback() external payable {
    }

}

contract DeployAndSendETH is Script {
    function run() external {
        vm.startBroadcast();

        PayableReceiver receiver = new PayableReceiver();

        // 1. 使用 call(无 data) -> 触发 receive
        (bool successCall, ) = address(receiver).call{value: 1 ether}("");
        require(successCall, "call failed");

        // 2. 使用 transfer -> 触发 receive
        payable(address(receiver)).transfer(1 ether);

        // 3. 使用 send -> 触发 receive
        bool successSend = payable(address(receiver)).send(1 ether);
        require(successSend, "send failed");

        // 4. 使用 call + data -> 触发 fallback
        (bool successFallback, ) = address(receiver).call{value: 1 ether}("0x1234");
        require(successFallback, "call with data (fallback) failed");

        vm.stopBroadcast();
    }
}
  • foundry 测试:
    1. 启动 anvil 测试网: anvil

image.png

  1. 使用 foundry script 部署合约进行发送 ETH 测试:
forge script script/money/DeployAndSendETH.sol \
  --rpc-url http://127.0.0.1:8545 \
  --broadcast \
  --private-key 'your-private-key' --tc DeployAndSendETH -vvvv

image.png

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
shawn_shaw
shawn_shaw
web3潜水员、技术爱好者、web3钱包开发工程师、欢迎交流工作机会。欢迎骚扰:vx:cola_ocean