合约调用
与传统编程语言直接的调用类似,一个合约可用通过调用来与另外的合约交互。 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)
包裹一个地址来直接进行调用。此处若不使用接口直接使用实现类来调用也可实现。
- 通过部署一个
-
Call
-
Call
是address
类型的低级成员函数,可以用来和其他合约进行交互。 -
Call
适合用于不知道对方合约源代码的情况下,进行发起调用。仅需要知道对方合约地址和调用的函数名即可。 -
官方推荐使用
Call
来发送ETH
,可以触发目标合约的fallback
和receive
函数。 -
官方不推荐使用
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
为实际参数。
-
Static Call
- 静态调用也是一种低级调用的方式,相比于
Call
来说,它只用于只读函数(prue
、view
)来进行调用,不会引起状态的更改。 - 调用者:与
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));
}
}
- foundry 测试:
在
callGet
函数中,我们将call
改为staticcall
即可完成静态调用的发起。其他部分和普通call
一致。
-
Delegate Call
delegatecall
和call
类似,也是地址的低级成员函数。其中delegate
的含义是 ”委托“,主要用在代理合约中。
- 如图所示,通过
delegatecall
调用的即为代理合约,他和普通 call
的区别是:
- 普通 call
的状态变量各自都是独立的,修改 B
的状态变量不会引起 A
的状态变量更改。而 delegatecall
的状态变量修改时,修改的其实是代理合约A
的状态变量 v
。
- 普通 call
的 msg.sender
是调用他的合约,而 delegatecall
的 msg.sender
是代理合约 A
的 msg.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");
}
}
### 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;
}
}
合约创建
-
Create
在以太坊上,EOA
账户可以创建合约,合约账户也能创建合约。创建合约的方式有两种,分别是create
和create2
,两者都是EVM
提供给我们的底层操作码(opcode
)。但在上层也进行了封装,通过new
关键字即可使用。
Contract x = new Contract{value: _value}(params)
-
Contract
为合约名,_value
为需要发送的ETH
数量,params
为构造函数的参数。 -
工厂合约创建新合约
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);
}
}
- 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
。但如果多次创建,合约的地址会发生变化,合约地址会不一致。
3. ### 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);
}
}
- 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);
}
}
-
预测地址
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
来对0xff
、address
、salt
、initcode
来进行hash
,得出一个预测的地址。与合约部署的真是地址进行比较,可以看出,两者一致。 -
❓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
-
发送 ETH
-
Call
call()
没有gas
限制,最为灵活,也是官方推荐的发送ETH
的方式。 -
Transfer
transfer()
有2300
gas
限制,发送失败会自动revert
交易。 -
Send
send()
有2300 gas
限制,发送失败不会自动revert
,几乎没人用它。
-
-
接收 ETH
solidity
中有三种函数可以支持接受ETH
。分别是receive()
和fallback()
,还有一种是普通函数。 -
Receive()
receive()
在msg.data
为空,且合约中写有receive()
会触发。 -
Fallback()
fallback()
在msg.data
有值,或没有receive()
,或调用可支付的普通函数函数不存在的时候会触发。 -
普通函数 普通函数可以通过带有 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 测试:
- 启动
anvil
测试网:anvil
2. 使用
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