src/cre/AirlockReceiver.sol170 lines · 8526 bytes
| 1 | // SPDX-License-Identifier: MIT |
| 2 | pragma solidity 0.8.37; |
| 3 | |
| 4 | import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; |
| 5 | import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; |
| 6 | import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; |
| 7 | |
| 8 | import {IReceiver} from "./IReceiver.sol"; |
| 9 | |
| 10 | /// @notice The AirlockAdmin calls this receiver makes: as a guardian (veto, attachReason) and as the clearance |
| 11 | /// oracle (clear), plus the digest every verdict is bound to. |
| 12 | interface IAirlockAdminOracle { |
| 13 | function veto(uint256 id, bytes32 ruleHash) external; |
| 14 | function attachReason(uint256 id, bytes32 reportHash) external; |
| 15 | function clear(uint256 id, bytes32 evidenceHash) external; |
| 16 | function proposalDigest(uint256 id) external view returns (bytes32); |
| 17 | } |
| 18 | |
| 19 | /// @title AirlockReceiver |
| 20 | /// @notice Turns a Chainlink CRE report into an AirlockAdmin verdict. It is both the AirlockAdmin clearance oracle |
| 21 | /// and one of its guardians: the CRE workflow simulates every proposal and writes a report either way, |
| 22 | /// `Clear` when every check passes (the proposal may run after its cooldown) or `Veto` when one fails |
| 23 | /// (it never runs). |
| 24 | /// @dev Authentication, in order: |
| 25 | /// 1. `msg.sender` must be the configured forwarder and, when configured, the report metadata must name the |
| 26 | /// expected workflow id and owner. With the production KeystoneForwarder the metadata is backed by DON |
| 27 | /// signatures. |
| 28 | /// 2. The report must still be fresh (`block.number <= validUntilBlock`). |
| 29 | /// 3. The report carries an EIP-712 signature by `attester` (a CRE secret held by the workflow). This keeps |
| 30 | /// the simulation path safe: the MockKeystoneForwarder used by `cre workflow simulate` is permissionless |
| 31 | /// and does not verify metadata, so without it anyone could route a fake verdict through the mock. |
| 32 | /// 4. The signed `proposalDigest` must equal `AirlockAdmin.proposalDigest(proposalId)`, so a verdict about |
| 33 | /// one proposal's content can never clear (or veto) a proposal with other content under the same id. |
| 34 | /// A verdict can only stop a pending proposal or let it through its second door; it can never move funds. |
| 35 | contract AirlockReceiver is IReceiver, EIP712 { |
| 36 | bytes32 public constant VERDICT_TYPEHASH = keccak256( |
| 37 | "Verdict(address airlockAdmin,uint8 action,uint256 proposalId,bytes32 proposalDigest,bytes32 ruleHash,bytes32 evidenceHash,uint64 validUntilBlock)" |
| 38 | ); |
| 39 | |
| 40 | uint8 public constant ACTION_VETO = 0; |
| 41 | uint8 public constant ACTION_CLEAR = 1; |
| 42 | |
| 43 | IAirlockAdminOracle public immutable airlockAdmin; |
| 44 | address public immutable forwarder; |
| 45 | address public immutable attester; |
| 46 | /// @notice Zero means "not checked" (simulation, where the mock forwarder does not vouch for metadata). |
| 47 | bytes32 public immutable expectedWorkflowId; |
| 48 | /// @notice Zero means "not checked". |
| 49 | address public immutable expectedWorkflowOwner; |
| 50 | |
| 51 | event Verdict( |
| 52 | uint256 indexed proposalId, uint8 indexed action, bytes32 ruleHash, bytes32 evidenceHash, bytes32 workflowId |
| 53 | ); |
| 54 | |
| 55 | error ZeroAddress(); |
| 56 | error InvalidSender(address sender, address expected); |
| 57 | error InvalidMetadata(); |
| 58 | error InvalidWorkflowId(bytes32 got, bytes32 expected); |
| 59 | error InvalidWorkflowOwner(address got, address expected); |
| 60 | error ReportExpired(uint64 validUntilBlock, uint256 currentBlock); |
| 61 | error InvalidAttestation(address signer); |
| 62 | error InvalidAction(uint8 action); |
| 63 | error RuleOnClear(bytes32 ruleHash); |
| 64 | error ProposalMismatch(); |
| 65 | |
| 66 | constructor( |
| 67 | address airlockAdmin_, |
| 68 | address forwarder_, |
| 69 | address attester_, |
| 70 | bytes32 expectedWorkflowId_, |
| 71 | // forge-lint: disable-next-line(missing-zero-check) |
| 72 | address expectedWorkflowOwner_ // zero = not checked |
| 73 | ) EIP712("AirlockReceiver", "1") { |
| 74 | if (airlockAdmin_ == address(0) || forwarder_ == address(0) || attester_ == address(0)) revert ZeroAddress(); |
| 75 | airlockAdmin = IAirlockAdminOracle(airlockAdmin_); |
| 76 | forwarder = forwarder_; |
| 77 | attester = attester_; |
| 78 | expectedWorkflowId = expectedWorkflowId_; |
| 79 | expectedWorkflowOwner = expectedWorkflowOwner_; |
| 80 | } |
| 81 | |
| 82 | /// @inheritdoc IReceiver |
| 83 | /// @dev `report` = abi.encode(uint8 action, uint256 proposalId, bytes32 proposalDigest, bytes32 ruleHash, |
| 84 | /// bytes32 evidenceHash, uint64 validUntilBlock, bytes signature). `action` 0 = Veto, 1 = Clear (whose |
| 85 | /// `ruleHash` must be zero). `evidenceHash` is the keccak256 of the workflow's canonical evidence JSON. |
| 86 | /// Accepted while block.number <= validUntilBlock; the workflow sets it from the proposal's readyBlock (or |
| 87 | /// the current block, whichever is later) plus a grace, so validity needs no clock. |
| 88 | function onReport(bytes calldata metadata, bytes calldata report) external { |
| 89 | if (msg.sender != forwarder) revert InvalidSender(msg.sender, forwarder); |
| 90 | bytes32 workflowId = _checkWorkflowMetadata(metadata); |
| 91 | |
| 92 | ( |
| 93 | uint8 action, |
| 94 | uint256 proposalId, |
| 95 | bytes32 digest, |
| 96 | bytes32 ruleHash, |
| 97 | bytes32 evidenceHash, |
| 98 | uint64 validUntilBlock, |
| 99 | bytes memory signature |
| 100 | ) = abi.decode(report, (uint8, uint256, bytes32, bytes32, bytes32, uint64, bytes)); |
| 101 | if (block.number > validUntilBlock) revert ReportExpired(validUntilBlock, block.number); |
| 102 | address signer = ECDSA.recover( |
| 103 | verdictDigest(action, proposalId, digest, ruleHash, evidenceHash, validUntilBlock), signature |
| 104 | ); |
| 105 | if (signer != attester) revert InvalidAttestation(signer); |
| 106 | if (action > ACTION_CLEAR) revert InvalidAction(action); |
| 107 | if (action == ACTION_CLEAR && ruleHash != bytes32(0)) revert RuleOnClear(ruleHash); |
| 108 | if (digest != airlockAdmin.proposalDigest(proposalId)) revert ProposalMismatch(); |
| 109 | |
| 110 | // Emitted before the state-changing calls (undone if AirlockAdmin reverts); the only call before it is the |
| 111 | // proposalDigest view. |
| 112 | // forge-lint: disable-next-line(reentrancy-events) |
| 113 | emit Verdict(proposalId, action, ruleHash, evidenceHash, workflowId); |
| 114 | if (action == ACTION_VETO) { |
| 115 | airlockAdmin.veto(proposalId, ruleHash); |
| 116 | airlockAdmin.attachReason(proposalId, evidenceHash); |
| 117 | } else { |
| 118 | airlockAdmin.clear(proposalId, evidenceHash); |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | /// @notice The EIP-712 digest the attester signs (domain "AirlockReceiver", version "1", this chain, this |
| 123 | /// receiver). |
| 124 | function verdictDigest( |
| 125 | uint8 action, |
| 126 | uint256 proposalId, |
| 127 | bytes32 proposalDigest, |
| 128 | bytes32 ruleHash, |
| 129 | bytes32 evidenceHash, |
| 130 | uint64 validUntilBlock |
| 131 | ) public view returns (bytes32) { |
| 132 | return _hashTypedDataV4( |
| 133 | keccak256( |
| 134 | abi.encode( |
| 135 | VERDICT_TYPEHASH, |
| 136 | address(airlockAdmin), |
| 137 | action, |
| 138 | proposalId, |
| 139 | proposalDigest, |
| 140 | ruleHash, |
| 141 | evidenceHash, |
| 142 | validUntilBlock |
| 143 | ) |
| 144 | ) |
| 145 | ); |
| 146 | } |
| 147 | |
| 148 | function domainSeparator() external view returns (bytes32) { |
| 149 | return _domainSeparatorV4(); |
| 150 | } |
| 151 | |
| 152 | function supportsInterface(bytes4 interfaceId) external pure returns (bool) { |
| 153 | return interfaceId == type(IReceiver).interfaceId || interfaceId == type(IERC165).interfaceId; |
| 154 | } |
| 155 | |
| 156 | /// @dev Metadata layout from the KeystoneForwarder: workflowId(32) | workflowName(10) | workflowOwner(20) | |
| 157 | /// reportId(2). Returns the workflow id (zero when the metadata is too short and nothing is checked). |
| 158 | function _checkWorkflowMetadata(bytes calldata metadata) internal view returns (bytes32 workflowId) { |
| 159 | if (metadata.length >= 32) workflowId = bytes32(metadata[0:32]); |
| 160 | bytes32 wantId = expectedWorkflowId; |
| 161 | address wantOwner = expectedWorkflowOwner; |
| 162 | if (wantId == bytes32(0) && wantOwner == address(0)) return workflowId; |
| 163 | if (metadata.length < 62) revert InvalidMetadata(); |
| 164 | if (wantId != bytes32(0) && workflowId != wantId) revert InvalidWorkflowId(workflowId, wantId); |
| 165 | if (wantOwner != address(0)) { |
| 166 | address gotOwner = address(bytes20(metadata[42:62])); |
| 167 | if (gotOwner != wantOwner) revert InvalidWorkflowOwner(gotOwner, wantOwner); |
| 168 | } |
| 169 | } |
| 170 | } |