Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/policies/TimelockPolicy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ import {
* @title TimelockPolicy
* @notice A policy module that enforces time-delayed execution of transactions for enhanced security
* @dev Users must first create a proposal, wait for the timelock delay, then execute
*
* SECURITY: Signer Trust Assumption
* This policy trusts whichever signer module is configured on the permission.
* It does NOT independently verify who signed the UserOp — that responsibility
* belongs to the signer module (e.g., ECDSASigner, WeightedECDSASigner).
* The signer validates the signature; this policy only enforces the timelock.
*
* SECURITY: Nonce Isolation
* Proposals are keyed by keccak256(account, keccak256(callData), nonce).
* The nonce here is the full ERC-4337 nonce (192-bit key | 64-bit sequence).
* Each permission has a distinct nonce key, so proposals under different
* permissions are naturally isolated — a proposal created under permission A
* cannot be executed under permission B.
*
* SECURITY: Guardian Design
* The guardian is a CANCELLATION-ONLY role. It cannot create or execute proposals.
* The guardian is scoped per (policyId, wallet) — a guardian for one policy/wallet
* pair cannot cancel proposals belonging to another pair. Guardian is set at install
* time and persists until uninstall. Setting guardian to address(0) disables the
* guardian feature, meaning only the account itself can cancel proposals.
*/
contract TimelockPolicy is PolicyBase, IStatelessValidator, IStatelessValidatorWithSender {
enum ProposalStatus {
Expand Down
6 changes: 6 additions & 0 deletions src/signers/ECDSASigner.sol
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ contract ECDSASigner is SignerBase, IStatelessValidator, IStatelessValidatorWith
: SIG_VALIDATION_FAILED_UINT;
}

/// @notice Validate an ERC-1271 signature
/// @dev The `sender` parameter (requesting protocol) is intentionally unused.
/// This signer authenticates the SIGNER (owner), not the requesting protocol.
/// WARNING: Because sender is ignored, any protocol can request signature
/// validation. If you need to restrict which protocols can request signatures,
/// pair this signer with a CallerPolicy.
function checkSignature(bytes32 id, address sender, bytes32 hash, bytes calldata sig)
external
view
Expand Down
15 changes: 15 additions & 0 deletions src/signers/WeightedECDSASigner.sol
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ contract WeightedECDSASigner is EIP712, SignerBase, IStatelessValidator, IStatel
return _validateUserOpSignature(id, userOp, userOpHash, userOp.signature, msg.sender);
}

/// @notice Validate an ERC-1271 signature
/// @dev The `sender` parameter (requesting protocol) is intentionally unused.
/// This signer authenticates the SIGNERS (guardians), not the requesting protocol.
/// WARNING: Because sender is ignored, any protocol can request signature
/// validation. If you need to restrict which protocols can request signatures,
/// pair this signer with a CallerPolicy.
function checkSignature(bytes32 id, address, bytes32 hash, bytes calldata sig)
external
view
Expand Down Expand Up @@ -138,6 +144,15 @@ contract WeightedECDSASigner is EIP712, SignerBase, IStatelessValidator, IStatel
/**
* @notice Internal function to validate user operation signatures
* @dev Shared logic for both installed and stateless validator modes
*
* SECURITY: Split Signature Scheme
* The first N-1 signatures verify a proposalHash (EIP-712 typed data covering
* account, id, callData, and nonce). The last signature MUST verify the full
* userOpHash to bind the complete UserOp (including gas fields).
* This prevents a scenario where guardians approve a proposal but an attacker
* manipulates gas parameters in the final UserOp.
* A double-counting check ensures a guardian who signed both the proposalHash
* and userOpHash only has their weight counted once.
*/
function _validateUserOpSignature(
bytes32 id,
Expand Down
6 changes: 6 additions & 0 deletions src/validators/ECDSAValidator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ contract ECDSAValidator is IValidator, IHook, IStatelessValidator, IStatelessVal
: SIG_VALIDATION_FAILED_UINT;
}

/// @notice Validate an ERC-1271 signature
/// @dev The `sender` parameter (requesting protocol) is intentionally unused.
/// This validator authenticates the SIGNER (owner), not the requesting protocol.
/// WARNING: Because sender is ignored, any protocol can request signature
/// validation. If you need to restrict which protocols can request signatures,
/// pair this validator with a CallerPolicy.
function isValidSignatureWithSender(address, bytes32 hash, bytes calldata sig)
external
view
Expand Down
9 changes: 6 additions & 3 deletions test/btt/Timelock.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -323,18 +323,21 @@ contract TimelockTest is Test {
assertEq(uint256(status), uint256(TimelockPolicy.ProposalStatus.Pending), "Proposal should be Pending");
}

function test_GivenNoopCalldataAndSignatureShorterThan65Bytes()
function test_GivenNoopCalldataAndSignature64BytesWithZeroCallDataLength()
external
whenCallingCheckUserOpPolicyToCreateProposal
{
// it should return SIG_VALIDATION_FAILED
// A 64-byte all-zeros signature decodes as callDataLength=0, proposalNonce=0.
// This passes the length check (sig.length >= 64 + 0) and creates a valid
// proposal with empty calldata and nonce 0.
bytes memory shortSig = new bytes(64);
PackedUserOperation memory userOp = _createNoopUserOp(WALLET, shortSig);

vm.prank(WALLET);
uint256 result = timelockPolicy.checkUserOpPolicy(POLICY_ID, userOp);

assertEq(result, SIG_VALIDATION_FAILED, "Should fail with short signature");
// Returns 0 (proposal created successfully) not SIG_VALIDATION_FAILED
assertEq(result, 0, "64-byte sig with zero callDataLength creates a valid proposal");
}

function test_GivenNoopCalldataAndSignatureClaimsMoreDataThanAvailable()
Expand Down