Solidity学习之时间锁

什么是时间锁

在合约中有一种时间锁设计,它的作用是延迟执行某个操作。比如在金库合约中,转出的方法必须要通过时间锁去调用,那么在转账发起之后,会经过一段指定时间才能执行。

假设合约owner的私钥被盗,那么即使黑客想要转出资金,也必须等待一定的时间,这时合约持有者就可以采取一定的措施去减少损失。

实现逻辑

首先为了保证合约内部的敏感方法无法被直接调用,而是必须经过时间锁,那么就需要设计一个修饰器,这个修饰器下的函数的调用方必须是合约本身,这就避免了外部调用。

然后是时间锁的实现,时间锁的结构是一个map,第一次使用时间锁是将某个调用放入到map中,第二次使用则将map中的调用取出并执行。

那么map的key是什么呢,它由target、value、signature、data、executeTime几个字段组成,代表的含义是

在executeTime的时候,由合约去调用target的方法,方法的签名为signature,参数为data,并携带value的native token

这代表调用方和合约达成了约定,当executeTime时间到的时候,合约就要允许调用方用约定好的参数去执行约定好的方法,所以这是一个提前约定的机制。

因为有executeTime的存在,可以保证key是唯一的。

因此时间锁有两个方法:addTransaction2QueueexecuteTransaction

具体实现

成员变量

contract TimeClock  {
    address public admin;
    uint public delay;
    uint public constant GRACE_PERIOD = 7 days; // 交易过期时间
    

    mapping (bytes32=>bool) public queuedTransactions;
	  
	  constructor(uint delay_) {
	    admin = msg.sender;
	    delay = delay_;
		 }
}

admin是合约的控制方,保证了其他人无法使用时间锁,但无法避免admin地址被盗。

delay是时间锁的锁定时间,交易的执行时间必须大于当前时间+delay

GRACE_PERIOD是过期机制,为了避免任务长期不执行带来的风险,属于防御机制,如果超过约定好的executeTime+GRACE_PERIOD,任务失效无法再被执行。

queuedTransactions就是存放任务的队列。

事件

    event ExecuteTransaction(bytes32 indexed txHash, address indexed target, uint value, string signature, bytes data, uint executeTime);
    
    event CancelTransaction(bytes32 indexed txHash, address indexed target, uint value, string signature, bytes data, uint executeTime);
    
    event NewTransaction(bytes32 indexed txHash, address indexed target, uint value, string signature, bytes data, uint executeTime);

修饰器

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

  modifier onlyTimeclock {
      require(msg.sender == address(this), "caller not timeclock");
      _;
  }

onlyOwner用来控制队列方法,保证只有合约的owner才能调用。

onlyTimeclock则用来控制实际要执行的逻辑,保证这些逻辑都在时间锁的控制之下。

函数

控制队列有三个函数:add,cancel和execute

  function addTransaction2Queue(address target, uint value, string memory signature, bytes memory data, uint256 executeTime) public onlyOwner returns (bytes32) {
      bytes32 txHash = getTxHash(target, value, signature, data, executeTime);
      require(!queuedTransactions[txHash], "transaction already queued");
      require(executeTime >  getBlockTimestamp() + delay, "estimated transaction must satisfy delay");
      require(msg.value > value, "value not enough");
      
      queuedTransactions[txHash] = true;
      emit NewTransaction(txHash, target, value, signature, data, executeTime);
      return txHash;
  }
    function cancelTransaction(address target, uint256 value, string memory signature, bytes memory data, uint256 executeTime) public onlyOwner{
    // 计算交易的唯一识别符:一堆东西的hash
    bytes32 txHash = getTxHash(target, value, signature, data, executeTime);
    // 检查:交易在时间锁队列中
    require(queuedTransactions[txHash], "Timelock::cancelTransaction: Transaction hasn't been queued.");
    // 将交易移出队列
    queuedTransactions[txHash] = false;

    emit CancelTransaction(txHash, target, value, signature, data, executeTime);
}

add和cancel方法都比较简单,主要就是条件的检查,然后做了一个map的插入移除操作。

考虑到复用性,就把getTxHash和getBlockTimestamp方法都抽出来了。

function getTxHash(address target, uint value, string memory signature, bytes memory data, uint executeTime) public pure returns (bytes32) {
    return keccak256(abi.encode(target, value, signature, data, executeTime));
}
function getBlockTimestamp() public view  returns (uint256) {
    return block.timestamp;
}

最后是execute方法:

  function executeTransaction(address target, uint value, string memory signature, bytes memory data, uint256 executeTime) external payable onlyOwner returns (bool) {
      bytes32 txHash = getTxHash(target, value, signature, data, executeTime);
      require(queuedTransactions[txHash], "transaction not valid");
      queuedTransactions[txHash] = false;
      require(getBlockTimestamp() > executeTime, "executed time not reached");
      require(getBlockTimestamp() < executeTime + GRACE_PERIOD, "grace period over");
      bytes memory callData;
      if (bytes(signature).length == 0) {
          callData =  data;
      } else {
          callData = abi.encodePacked(bytes4(keccak256(bytes(signature))), data);
      }
      (bool success, ) = target.call{value:msg.value}(callData);
      require(success, "execution failed");
      emit ExecuteTransaction(txHash, target, value, signature, data, executeTime);
      return true;
  }

其中的逻辑判断表示:如果没有指定函数的话,那直接用data,会触发目标合约的fallback()receive()逻辑;如果有的话则发起函数调用。

此时callData的封装使用了abi.encodePacked而非abi.encodeWithSignature或是abi.encodeWithSelector,这是因为调用方法的参数是不定的,已经被封装成bytes的data了,所以selector也只能手动封装。

函数选择器 = keccak256("函数名(参数类型列表)") 的前 4 个字节,即bytes4(keccak256(bytes(signature)))

最后写一个方法用来模拟:

function changeAdmin(address newAdmin) public onlyTimeclock {
    admin = newAdmin;
    emit ChangeOwner(newAdmin);
}

调用的时候需要手动做一下data的encode,比如我传入的是地址

0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2,那么data就是

0x000000000000000000000000ab8483f64d9c6d1ecf9b849ae677dd3315835cb2

posted @ 2025-07-27 19:45  Felix07  阅读(53)  评论(0)    收藏  举报