Skip to content

Commit df80654

Browse files
hannahjinclaude
andcommitted
Remove empty executionData early-return from MoiraiDelegate; guard in PolicyManager
Post-audit design: PolicyManager now gates _execute behind a non-empty executionData check in installWithSignature and replaceWithSignature. Policies can therefore assume onExecute is always a genuine execution intent and no longer need the empty-data no-op guard themselves. - PolicyManager.installWithSignature: only calls _execute when executionData.length > 0 - PolicyManager.replaceWithSignature: same guard - MoiraiDelegate._onExecute: remove `if (executionData.length == 0) return` early-return; update NatSpec - execute.t.sol: remove obsolete test_isExecuted_remainsFalse_afterEmptyDataNoOp and stale comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 35d4af3 commit df80654

3 files changed

Lines changed: 17 additions & 26 deletions

File tree

src/PolicyManager.sol

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -282,14 +282,15 @@ contract PolicyManager is EIP712, ReentrancyGuard {
282282

283283
/// @notice Installs a policy using an account signature over the binding, optionally followed by an execution.
284284
///
285-
/// @dev The signature only authorizes the binding (not the execution). Any `executionData` provided, including empty
286-
/// bytes, is forwarded to the policy's execute hook, which MUST enforce its own execution authorization semantics.
285+
/// @dev The signature only authorizes the binding (not the execution). If `executionData` is non-empty it is
286+
/// forwarded to the policy's execute hook, which MUST enforce its own execution authorization semantics.
287+
/// Empty `executionData` is treated as install-only — the execute hook is not invoked.
287288
///
288289
/// @param binding Policy binding parameters authorized by the account (includes `policyConfig`).
289290
/// @param userSig ERC-6492-compatible signature by `binding.account` over the install typed digest:
290291
/// `_hashTypedData(keccak256(abi.encode(INSTALL_POLICY_TYPEHASH, policyId, deadline)))`.
291292
/// @param deadline Optional timestamp (seconds). If non-zero, the signature is invalid after this deadline.
292-
/// @param executionData Policy-defined per-execution payload (may be empty; the policy decides how to handle it).
293+
/// @param executionData Policy-defined per-execution payload. If empty, no execution is triggered.
293294
///
294295
/// @return policyId Deterministic policy identifier derived from the binding.
295296
function installWithSignature(
@@ -306,7 +307,9 @@ contract PolicyManager is EIP712, ReentrancyGuard {
306307

307308
_install(binding);
308309

309-
_execute(binding.policy, policyId, binding.policyConfig, executionData, msg.sender);
310+
if (executionData.length > 0) {
311+
_execute(binding.policy, policyId, binding.policyConfig, executionData, msg.sender);
312+
}
310313
return policyId;
311314
}
312315

@@ -376,7 +379,7 @@ contract PolicyManager is EIP712, ReentrancyGuard {
376379
/// @param userSig ERC-6492-compatible signature by `payload.newBinding.account` over the replacement typed digest:
377380
/// `_hashTypedData(keccak256(abi.encode(REPLACE_POLICY_TYPEHASH, account, oldPolicy, oldPolicyId, keccak256(oldPolicyConfig), newPolicyId, deadline)))`.
378381
/// @param deadline Optional timestamp (seconds). If non-zero, the signature is invalid after this deadline.
379-
/// @param executionData Policy-defined per-execution payload (may be empty; the policy decides how to handle it).
382+
/// @param executionData Policy-defined per-execution payload. If empty, no execution is triggered.
380383
///
381384
/// @return newPolicyId Deterministic policy identifier for the new binding.
382385
function replaceWithSignature(
@@ -405,7 +408,9 @@ contract PolicyManager is EIP712, ReentrancyGuard {
405408

406409
_replace(payload);
407410

408-
_execute(payload.newBinding.policy, newPolicyId, payload.newBinding.policyConfig, executionData, msg.sender);
411+
if (executionData.length > 0) {
412+
_execute(payload.newBinding.policy, newPolicyId, payload.newBinding.policyConfig, executionData, msg.sender);
413+
}
409414

410415
return newPolicyId;
411416
}

src/policies/MoiraiDelegate.sol

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,22 +159,20 @@ contract MoiraiDelegate is SingleExecutorPolicy {
159159
/// @dev Validates config hash, checks single-execution guard, enforces time-lock and/or executor signature,
160160
/// then returns the configured calldata as the account call.
161161
///
162-
/// When `executionData` is empty the function returns early without touching `executed[policyId]`.
163-
/// This is an intentional no-op: it does NOT consume the one-shot execution lock. Callers who want
164-
/// to trigger the policy must supply non-empty `executionData`.
162+
/// When this hook is called the intention is always a genuine execution — the `PolicyManager` ensures
163+
/// `onExecute` is only invoked when execution is explicitly requested.
165164
///
166-
/// For delay-only policies (`executor == address(0)`), the content of `executionData` is
167-
/// ignored entirely — only its non-zero length is checked. Any non-empty bytes trigger execution
168-
/// once the time-lock is met. The actual call parameters (`target`, `value`, `callData`) are always
169-
/// taken from `policyConfig`, not from `executionData`.
165+
/// For executor-required policies (`executor != address(0)`), `executionData` is decoded to extract
166+
/// the executor signature. For delay-only policies (`executor == address(0)`), `executionData` is never
167+
/// inspected — only the time-lock matters. The actual call parameters (`target`, `value`, `callData`)
168+
/// are always taken from `policyConfig`, not from `executionData`.
170169
function _onExecute(
171170
bytes32 policyId,
172171
address account,
173172
bytes calldata policyConfig,
174173
bytes calldata executionData,
175174
address caller
176175
) internal override whenNotPaused returns (bytes memory accountCallData, bytes memory postCallData) {
177-
if (executionData.length == 0) return (accountCallData, postCallData);
178176
_requireConfigHash(policyId, policyConfig);
179177

180178
(SingleExecutorConfig memory singleExecutorConfig, bytes memory specificConfig) =

test/unit/policies/MoiraiDelegate/execute.t.sol

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ contract ExecuteTest is MoiraiDelegateTestBase {
3030

3131
vm.warp(unlockTime);
3232

33-
// For delay-only, executionData must be non-empty to bypass the early-return guard.
3433
assertFalse(policy.executed(policyId));
3534
policyManager.execute(address(policy), policyId, config, bytes("0x01"));
3635
assertTrue(policy.executed(policyId));
@@ -108,16 +107,6 @@ contract ExecuteTest is MoiraiDelegateTestBase {
108107
assertTrue(policy.executed(policyId));
109108
}
110109

111-
/// @notice isExecuted remains false after a no-op execute call with empty executionData.
112-
function test_isExecuted_remainsFalse_afterEmptyDataNoOp() public {
113-
bytes memory config = _buildMoiraiConfig(0, executor);
114-
bytes32 policyId = _buildAndInstall(config, 0);
115-
116-
assertFalse(policy.executed(policyId));
117-
policyManager.execute(address(policy), policyId, config, bytes(""));
118-
assertFalse(policy.executed(policyId));
119-
}
120-
121110
// =============================================================
122111
// Reverts
123112
// =============================================================
@@ -142,7 +131,6 @@ contract ExecuteTest is MoiraiDelegateTestBase {
142131
bytes memory config = _buildMoiraiConfig(unlockTime, address(0));
143132
bytes32 policyId = _buildAndInstall(config, 0);
144133

145-
// Must pass non-empty executionData to reach the time-lock check.
146134
vm.expectRevert(
147135
abi.encodeWithSelector(MoiraiDelegate.BeforeUnlockTimestamp.selector, block.timestamp, unlockTime)
148136
);

0 commit comments

Comments
 (0)