本文主要关注以太坊智能合约的进阶操作,包括合约的四种调用方式、合约的创建、地址预测、发送 eth 的三种方法、接收 eth 的三种不同方法等内容。通过 solidity 代码配合 foundry 进行测试。适合新手小白学习
与传统编程语言直接的调用类似,一个合约可用通过调用来与另外的合约交互。
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();
}
}
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
是 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");
}
}
(bool success, ) = calleeAddress.call(abi.encodeWithSignature("setX(uint256)", _x));
来发起调用的,其中,calleeAddress
为目标合约地址,setX(uint256)
为目标函数名和参数类型,_x
为实际参数。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));
}
}
callGet
函数中,我们将 call
改为 staticcall
即可完成静态调用的发起。其他部分和普通 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");
}
}
multiCall
是合约调用的另一种方式,支持一次交易中执行多个函数调用,可以降低 gas
费并提高调用效率。
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;
}
}
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
,属于 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;
}
}
在以太坊上,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);
}
}
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
。但如果多次创建,合约的地址会发生变化,合约地址会不一致。
使用 create2
指令可以实现固定地址、预测地址的功能。实际上,Uniswap
创建币对合约的时候就是使用的 create2
指令。
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);
}
}
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 是怎么创建地址的?
新地址 = 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` 是一致的即可在不同链上创建出相同的地址。
Call
call()
没有 gas
限制,最为灵活,也是官方推荐的发送 ETH
的方式。
Transfer
transfer()
有 2300
gas
限制,发送失败会自动 revert
交易。
Send
send()
有 2300 gas
限制,发送失败不会自动 revert
,几乎没人用它。
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();
}
}
anvil
测试网:
anvil
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
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!