-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTimeLockRevokeAccount.sol
More file actions
318 lines (293 loc) · 15.6 KB
/
Copy pathTimeLockRevokeAccount.sol
File metadata and controls
318 lines (293 loc) · 15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
/// @title TimeLockRevokeAccount
/// @notice A post-quantum account that makes an authorized action CANCELLABLE by
/// the owner without any party trusted with the funds. This is the
/// reference construction for the cancellability trilemma
/// (internal/paper3-cancellation.md): authorization does not execute
/// immediately, it enters a `delta`-block veto window during which the
/// owner may revoke it by revealing a *pre-designated, unused* one-time
/// leaf. An adversary who controls transaction inclusion can only CENSOR
/// the revoke, so cancellation fails only if the adversary suppresses it
/// for all `delta` blocks (probability beta^delta against a builder of
/// share beta, race-free under fair ordering).
///
/// @dev Corrects the impossibility of docs/GAME.md Section 8 (which held only
/// for immediate-execution bearer instruments): with a latency budget
/// `delta`, a message-bound account achieves public + race-free +
/// trustless cancellation. The price is `delta` latency on every action,
/// which is the trilemma's priced corner and is unpayable for urgent /
/// MEV-sensitive actions.
///
/// Authorization uses the same one-time-secret + Merkle-membership
/// primitive as CommitRevealAccount (docs/SPEC.md): the enqueue is a
/// message-bound aged-commitment reveal (paper 1, so it cannot be
/// redirected to a different action), and the revoke reveals a leaf the
/// enqueue pre-committed as this queue's cancel credential (so it needs
/// no aging of its own and an adversary who copies it from the mempool
/// can only trigger the very cancellation the owner intended). The
/// construction is signature-scheme-agnostic; a message-bound one-time
/// hash signature (docs/SPEC-HASHSIG.md) substitutes for the aged
/// commitment with the same properties and less latency.
contract TimeLockRevokeAccount {
bytes32 internal constant TAG_LEAF = keccak256("QCA/v1/leaf");
bytes32 internal constant TAG_NODE = keccak256("QCA/v1/node");
bytes32 internal constant TAG_ACTION = keccak256("QCA/v1/action");
bytes32 internal constant TAG_COMMIT = keccak256("QCA/v1/commit");
// Sits in the action-hash slot to domain-separate an enqueue commitment from
// any reveal/burn commitment of the base account, so a commitment aged for
// one role can never be opened in another.
bytes32 internal constant TAG_ENQUEUE = keccak256("QCA/v3/enqueue");
bytes32 public root;
uint256 public depth;
/// @notice Blocks an enqueue commitment must age before it opens. This is
/// paper 1's anti-front-running margin for the enqueue itself, and
/// is unrelated to the veto window: it is spent before the action is
/// queued, so it is not part of the cancellation latency.
uint256 public immutable minCommitAge;
/// @notice Blocks after which an enqueue commitment expires.
uint256 public immutable commitTTL;
/// @notice The veto window, in blocks: a queued action executes only at
/// `enqueueBlock + delta` and may be revoked before then. This is
/// the cancellation latency the trilemma prices. Operationally it
/// MUST be at least chain finality, so the queued entry is final
/// before it can execute and a sub-final reorg cannot carry the
/// action past a revoke the owner already intends (the reorg cushion
/// and the cancellation window are the same knob).
uint256 public immutable delta;
/// @notice Nullifier set, keyed by leaf hash H(TAG_LEAF, secret). Shared by
/// enqueue leaves and revoke leaves: every leaf is one-time.
mapping(bytes32 => bool) public usedLeaves;
/// @notice Revoke leaves designated by a live queue but not yet spent. A leaf
/// is reserved at enqueue and released at execute or revoke, so no two
/// live queues can share a revoke credential; without this a leaf used
/// to cancel one queue would silently make every other queue that
/// named it uncancellable (issue 1 of the external review).
mapping(bytes32 => bool) public reservedRevokeLeaves;
/// @notice Enqueue commitment hash => block posted (0 = absent).
mapping(bytes32 => uint256) public commitments;
struct Queued {
bytes32 actionHash; // binds (target, value, data); execute must match
bytes32 revokeLeaf; // H(TAG_LEAF, secret_j): the designated cancel credential
uint256 unlock; // block at/after which execute is allowed
uint256 callGasLimit; // owner-committed execution budget (F1: not caller-chosen)
bool live;
}
/// @notice queueId (= the enqueue leaf hash) => queued entry.
mapping(bytes32 => Queued) public queue;
event Committed(bytes32 indexed commitment);
event Enqueued(bytes32 indexed queueId, bytes32 indexed actionHash, uint256 unlock);
event Executed(bytes32 indexed queueId, bytes32 indexed actionHash, bool success);
event Revoked(bytes32 indexed queueId, bytes32 indexed revokeLeaf);
event RootRotated(bytes32 indexed newRoot, uint256 newDepth);
error CommitmentExists();
error UnknownCommitment();
error CommitmentTooYoung();
error CommitmentExpired();
error LeafAlreadyUsed();
error InvalidProofLength();
error LeafIndexOutOfRange();
error InvalidProof();
error InvalidDepth();
error InvalidWindow();
error QueueExists();
error BadRevokeLeaf();
error NoSuchQueue();
error NotUnlocked();
error WindowClosed();
error ActionMismatch();
error WrongRevokeLeaf();
error NotSelf();
error InsufficientGas();
constructor(bytes32 root_, uint256 depth_, uint256 minCommitAge_, uint256 commitTTL_, uint256 delta_) payable {
_checkDepth(depth_);
if (minCommitAge_ == 0 || commitTTL_ <= minCommitAge_ || delta_ == 0) revert InvalidWindow();
root = root_;
depth = depth_;
minCommitAge = minCommitAge_;
commitTTL = commitTTL_;
delta = delta_;
}
receive() external payable {}
/// @notice Post an enqueue commitment. Permissionless; binds the account, the
/// action, the designated revoke leaf, and the enqueue secret, so a
/// copied commit is either identical (harmless) or useless.
function commitEnqueue(bytes32 c) external {
if (commitments[c] != 0) revert CommitmentExists();
commitments[c] = block.number;
emit Committed(c);
}
/// @notice Open an aged enqueue commitment and QUEUE the action (does not
/// execute it). The owner designates, at commit time, a distinct
/// unused leaf `revokeLeaf` as this queue's cancel credential.
/// @param revokeLeaf H(TAG_LEAF, secret_j) for a distinct unused leaf j; the
/// only credential that can later revoke this queue. Its membership in
/// the current tree is PROVEN here (via revokeLeafIndex + revokeProof),
/// so a queue can never advertise a cancel credential that is not a
/// real leaf and would fail to verify at revoke time (issue 2 of the
/// external review). Because membership is established here, revoke()
/// re-checks only the revealed secret against this stored hash, so it
/// does not depend on the current root and a later rotation cannot
/// strand a live queue's cancellation (issue 3).
/// @param callGasLimit the execution budget bound into the queue and forwarded
/// to the action at execute time. Binding it (F1) closes a
/// gas-starvation grief: because execute is permissionless, a
/// caller-chosen budget would let anyone execute with too little gas so
/// the action OOGs while the queue is consumed. The owner commits the
/// budget here; execute forwards exactly it, regardless of who submits.
function enqueue(
address target,
uint256 value,
bytes calldata data,
uint256 leafIndex,
bytes32 secret,
bytes32 revokeLeaf,
uint256 revokeLeafIndex,
uint256 callGasLimit,
bytes32[] calldata proof,
bytes32[] calldata revokeProof
) external {
bytes32 leafHash = _verifyMembership(leafIndex, secret, proof);
if (usedLeaves[leafHash]) revert LeafAlreadyUsed();
// The designated revoke leaf must be a distinct leaf that is unused, not
// already reserved by another live queue, and a genuine member of the
// current tree. These close review issues 1 (reservation) and 2
// (membership). Only the owner can enqueue, so this is a footgun guard.
if (revokeLeaf == leafHash || usedLeaves[revokeLeaf] || reservedRevokeLeaves[revokeLeaf]) {
revert BadRevokeLeaf();
}
_foldToRoot(revokeLeaf, revokeLeafIndex, depth, root, revokeProof);
bytes32 actionHash = keccak256(abi.encode(TAG_ACTION, target, value, keccak256(data)));
bytes32 c = keccak256(
abi.encode(
TAG_COMMIT,
block.chainid,
address(this),
TAG_ENQUEUE,
actionHash,
revokeLeaf,
leafIndex,
secret,
callGasLimit
)
);
uint256 committedAt = commitments[c];
if (committedAt == 0) revert UnknownCommitment();
if (block.number < committedAt + minCommitAge) revert CommitmentTooYoung();
if (block.number > committedAt + commitTTL) revert CommitmentExpired();
// queueId is the enqueue leaf hash: one queue slot per one-time leaf, so
// it can never collide across the account's life.
if (queue[leafHash].live) revert QueueExists();
usedLeaves[leafHash] = true;
reservedRevokeLeaves[revokeLeaf] = true;
delete commitments[c];
uint256 unlockAt = block.number + delta;
queue[leafHash] = Queued({
actionHash: actionHash,
revokeLeaf: revokeLeaf,
unlock: unlockAt,
callGasLimit: callGasLimit,
live: true
});
emit Enqueued(leafHash, actionHash, unlockAt);
}
/// @notice Execute a queued action after its veto window closes. Permissionless
/// (the design's liveness guarantee: the owner does not need any party
/// to execute). Reverts if the queue was revoked. The execution budget
/// is the owner-committed `q.callGasLimit`, not a caller argument (F1):
/// a submitter who cannot supply that budget reverts BEFORE the queue
/// is consumed, so a low-gas call cannot burn the action.
function executeQueued(bytes32 queueId, address target, uint256 value, bytes calldata data)
external
returns (bool success, bytes memory result)
{
Queued memory q = queue[queueId];
if (!q.live) revert NoSuchQueue();
if (block.number < q.unlock) revert NotUnlocked();
bytes32 actionHash = keccak256(abi.encode(TAG_ACTION, target, value, keccak256(data)));
if (actionHash != q.actionHash) revert ActionMismatch();
// Guarantee the committed budget can reach the action before consuming the
// queue. Revert-before-mutate, exactly as the base account's reveal.
if (gasleft() < q.callGasLimit + q.callGasLimit / 63 + 40_000) revert InsufficientGas();
queue[queueId].live = false;
// The revoke leaf was never spent; release it so it can back a future
// queue (issue 1).
reservedRevokeLeaves[q.revokeLeaf] = false;
(success, result) = target.call{gas: q.callGasLimit, value: value}(data);
emit Executed(queueId, actionHash, success);
}
/// @notice Cancel a queued action before its unlock, by revealing the unused
/// leaf the enqueue designated as this queue's revoke credential.
/// @dev No aging: binding is by the pre-designated `revokeLeaf`, so an
/// adversary who copies this call from the mempool can only revoke the
/// SAME queue (the revealed secret only matches this queue's
/// `revokeLeaf`), which is exactly the owner's intent and therefore
/// harmless. The adversary cannot forge a revoke for a queue whose
/// revoke secret was never revealed (an unused leaf's secret is not
/// public, and finding a preimage of the stored hash is a keccak
/// preimage search), and cannot redirect it to a different queue. Its
/// only move is to censor this call for the whole veto window.
///
/// Membership of the revoke leaf in the tree was PROVEN at enqueue, so
/// revoke re-checks only that the revealed secret hashes to the stored
/// `revokeLeaf`. It therefore does not read the current root, and a
/// rotation between enqueue and revoke cannot strand this cancellation
/// (issue 3).
function revoke(bytes32 queueId, bytes32 revokeSecret) external {
Queued memory q = queue[queueId];
if (!q.live) revert NoSuchQueue();
if (block.number >= q.unlock) revert WindowClosed();
bytes32 leafHash = keccak256(abi.encode(TAG_LEAF, revokeSecret));
if (leafHash != q.revokeLeaf) revert WrongRevokeLeaf();
if (usedLeaves[leafHash]) revert LeafAlreadyUsed();
// Spend the revoke leaf, release its reservation, and cancel the queue.
// The queued action can never execute (executeQueued requires q.live).
usedLeaves[leafHash] = true;
reservedRevokeLeaves[leafHash] = false;
queue[queueId].live = false;
delete queue[queueId];
emit Revoked(queueId, leafHash);
}
/// @notice Rotate to a new tree; only via a queued+executed self-call. The
/// break-glass response to seed exposure.
function rotate(bytes32 newRoot, uint256 newDepth) external {
if (msg.sender != address(this)) revert NotSelf();
_checkDepth(newDepth);
root = newRoot;
depth = newDepth;
emit RootRotated(newRoot, newDepth);
}
/// @dev Spend-path membership against the current tree: derive the leaf hash
/// from the secret and fold it to the current root.
function _verifyMembership(uint256 leafIndex, bytes32 secret, bytes32[] calldata proof)
internal
view
returns (bytes32 leafHash)
{
leafHash = keccak256(abi.encode(TAG_LEAF, secret));
_foldToRoot(leafHash, leafIndex, depth, root, proof);
}
/// @dev Fold a leaf HASH (no secret needed) up its Merkle path and require it
/// reaches `root_` under depth `d_`. Used to prove a designated revoke
/// leaf is a real member of the tree at enqueue, without revealing its
/// secret.
function _foldToRoot(bytes32 leafHash, uint256 leafIndex, uint256 d_, bytes32 root_, bytes32[] calldata proof)
internal
pure
{
if (proof.length != d_) revert InvalidProofLength();
if (leafIndex >= (1 << d_)) revert LeafIndexOutOfRange();
bytes32 node = leafHash;
uint256 idx = leafIndex;
for (uint256 i = 0; i < d_; ++i) {
node = idx & 1 == 0
? keccak256(abi.encode(TAG_NODE, node, proof[i]))
: keccak256(abi.encode(TAG_NODE, proof[i], node));
idx >>= 1;
}
if (node != root_) revert InvalidProof();
}
function _checkDepth(uint256 d) internal pure {
if (d == 0 || d > 32) revert InvalidDepth();
}
}