Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / src / cre / AirlockReceiver.sol
src/cre/AirlockReceiver.sol170 lines · 8526 bytes
1// SPDX-License-Identifier: MIT
2pragma solidity 0.8.37;
3
4import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
5import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
6import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
7
8import {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.
12interface 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.
35contract 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}