Skip to content

feat(account-modules): Add CoSignerValidator for server co-signing#57

Open
pcarranzav wants to merge 7 commits intomainfrom
pcv/cosigner-validator
Open

feat(account-modules): Add CoSignerValidator for server co-signing#57
pcarranzav wants to merge 7 commits intomainfrom
pcv/cosigner-validator

Conversation

@pcarranzav
Copy link
Member

Summary

Adds CoSignerValidator - a new ERC-7579 validator module that enables 2-of-2 co-signing (agent key + server key) for smart account operations.

Changes

  • CoSignerValidator.sol: ERC-7579 validator requiring dual signatures
  • ICoSignerValidator.sol: Interface definition
  • CoSignerValidator.t.sol: 32 comprehensive tests (all passing)
  • DeployCoSignerValidator.s.sol: CREATE2 deployment script
  • CalculateStorageSlot.s.sol: Added CoSignerValidator namespace calculation

Architecture

  • Per-account admin trust model (accounts choose which admin to trust)
  • Global co-signer sets per admin (enables instant rotation across all accounts)
  • Supports both validateUserOp (ERC-4337) and isValidSignatureWithSender (ERC-1271)
  • ERC-7201 namespaced storage for upgrade safety

Deployment

  • Predicted deterministic address: 0x972F1d56Ec65248f57CF1204b2F012a3a2d7f82B
  • Deploy to Base Sepolia first for testing
  • Same address on Base Mainnet via CREATE2

Testing

cd solidity/account-modules
forge test --match-contract CoSignerValidatorTest -vv

All 32 tests passing ✅

Next Steps

  • Deploy to Base Sepolia
  • Verify on Basescan
  • Test on-chain (admin adds coSigner, validate signatures)
  • Deploy to Base Mainnet

Related

  • Spec: (in main repo) tmp/server_cosigning_spec.md
  • Implementation plan: (in main repo) tmp/server_cosigning_implementation_plan.md

🤖 Generated with Claude Code

pcarranzav and others added 4 commits March 4, 2026 18:24
- ERC-7579 validator requiring agent key + server co-signer
- Per-account admin trust model with global co-signer sets
- Supports both validateUserOp and isValidSignatureWithSender
- ERC-7201 namespaced storage for upgrade safety

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 32 tests covering installation, validation, key management
- Tests dual signature validation (UserOp and ERC-1271 paths)
- Tests admin/coSigner rotation with grace period
- Tests multi-account scenarios
- All tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Uses Safe Singleton Deployer for deterministic CREATE2 deployment
- Same address across all chains
- Includes predict() function to compute address before deployment
- Predicted address: 0x972F1d56Ec65248f57CF1204b2F012a3a2d7f82B

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@pcarranzav pcarranzav marked this pull request as ready for review March 12, 2026 21:11
@matiasedgeandnode matiasedgeandnode self-requested a review March 13, 2026 13:26
Copy link
Member

@tmigone tmigone left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

left some comments!

address account = msg.sender;

// Prevent double initialization
require(!$.accountInitialized[account], "Already initialized");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

absolute nit, but custom errors have been really nice for us debugging horizon issues

{
CoSignerStorage storage $ = _getStorage();

// Decode signature as (bytes agentSig, bytes coSignerSig)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably worth putting the signature decodification and validation into an internal fn, this function and the previous one pretty much duplicate this code

* @notice ERC-7579 validator requiring dual signatures (agent key + server co-signer)
* @dev Provides server-enforced spend limits and programmable policies for agent operations
*/
contract CoSignerValidator is ICoSignerValidator, ERC7579ValidatorBase, ReentrancyGuard {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReentrancyGuard is never used in the contract

/**
* @notice Called when module is uninstalled for an account
*/
function onUninstall(bytes calldata) external override(IModule) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we probably want to emit some events here like AgentKeyRemoved

* @dev Can only be called by the account itself
* @param key The agent key address to add
*/
function addAgentKey(address key) external {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about batched ops for adding/removing agent keys. Probably adding multiuple keys at once wont be used much but removing them sounds more plausible? Worth considering.

* @param account The account address
* @return The admin address
*/
function getAdmin(address account) external view returns (address) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be worth adding a view function to do account -> co-signer? isValidCoSignerForAccount() or something like that, otherwise it's a couple of contract calls i think.

@matiasedgeandnode
Copy link
Collaborator

Seems like agent+co-signer can currently call setAdmin() and addAgentKey()/removeAgentKey(). A compromised agent + rogue server could change admin to an attacker-controlled address, then add their own co-signer = full takeover?

Suggestion: Add an owner role (set on install) that guards config changes:

function setAdmin(address newAdmin, bytes calldata ownerSig) external {
    bytes32 hash = keccak256(abi.encode(account, "setAdmin", newAdmin, nonce++));
    require($.owners[account].contains(ECDSA.recover(hash, ownerSig)), "Not owner");
    // ...
}

Include addOwner() / removeOwner() so owners can manage themselves.


// ============ Constructor ============

constructor() {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used?

require($.accountInitialized[account], "Not initialized");

$.agentKeys[account].remove(key);
emit AgentKeyRemoved(account, key);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will emit even if $.agentKeys[account].remove(key); is falsy?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh nice one, .remove() does not fail but returns false if the key does not exist no?

function removeCoSigner(address coSigner) external {
CoSignerStorage storage $ = _getStorage();
$.coSigners[msg.sender].remove(coSigner);
emit CoSignerRemoved(msg.sender, coSigner);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

@pcarranzav
Copy link
Member Author

Seems like agent+co-signer can currently call setAdmin() and addAgentKey()/removeAgentKey(). A compromised agent + rogue server could change admin to an attacker-controlled address, then add their own co-signer = full takeover?

I think this is a necessary aspect of how this works. Agent + co-signer can execute user ops and sign arbitrary messages, so they could already add/remove owners on the OwnableValidator, drain the account, etc. The server is being trusted to add any necessary guardrails to the agent key. It's essentially a 2/2 multisig, so if both keys are compromised the account is compromised.

@matiasedgeandnode
Copy link
Collaborator

I think this is a necessary aspect of how this works. Agent + co-signer can execute user ops and sign arbitrary messages, so they could already add/remove owners on the OwnableValidator, drain the account, etc.

Right, I missed that. Thanks for clarifying.

pcarranzav and others added 2 commits March 13, 2026 12:10
- Switch to custom errors for better debugging (CoSignerValidator_ prefix)
- Remove ReentrancyGuard (not used)
- Remove empty constructor
- Extract _validateDualSignature() internal helper (DRY)
- Add events in onUninstall (emit AgentKeyRemoved for each key)
- Check remove() return value before emitting events
- Add batched operations: addAgentKeys() and removeAgentKeys()
- Add isValidCoSignerForAccount() helper view function
- Update tests for custom errors
- Add tests for batched operations and helper function

All 35 tests passing ✅

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add CoSignerValidatorInstalled event in onInstall
- Add CoSignerValidatorUninstalled event in onUninstall
- Update tests to expect new events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@pcarranzav pcarranzav requested a review from tmigone March 14, 2026 13:01
Copy link
Collaborator

@matiasedgeandnode matiasedgeandnode left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a test for uninstall → reinstall flow? Also the same address can be registered as both an agent key and a co-signer, but maybe this is fine?

Another nice test would be a test for duplicate agent key in onInstall.

In fact, wouldn't it make sense to have a private helper for _addAgentKeys that onInstall, addAgentKey, and addAgentKeys can call? And the same for _removeAgentKeys

error CoSignerValidator_AlreadyInitialized();
error CoSignerValidator_NotInitialized();
error CoSignerValidator_InvalidAddress();
error CoSignerValidator_InvalidAgentKey();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CoSignerValidator_InvalidAgentKey and CoSignerValidator_InvalidCoSigner are declared but never used anywhere?

address key = keys[i];
require(key != address(0), CoSignerValidator_InvalidAddress());
$.agentKeys[account].add(key);
emit AgentKeyAdded(account, key);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will emit even if key was already there? Is that intentional?


CoSignerStorage storage $ = _getStorage();
$.coSigners[msg.sender].add(coSigner);
emit CoSignerAdded(msg.sender, coSigner);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will emit even if coSigner was already there? Is that intentional?

address key = initialAgentKeys[i];
require(key != address(0), CoSignerValidator_InvalidAddress());
$.agentKeys[account].add(key);
emit AgentKeyAdded(account, key);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

/**
* @notice Called when module is uninstalled for an account
*/
function onUninstall(bytes calldata) external override(IModule) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe exit early with an error if not installed?

* @dev msg.sender is the admin adding to their own co-signer set
* @param coSigner The co-signer address to add
*/
function addCoSigner(address coSigner) external {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe require($.accountInitialized[account], CoSignerValidator_NotInitialized()); here and in removeCoSigner?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not really, this is for admins, not for accounts

Contract changes:
- Remove unused errors (InvalidAgentKey, InvalidCoSigner)
- Extract _addAgentKey/_removeAgentKey private helpers (DRY for onInstall,
  addAgentKey, addAgentKeys, removeAgentKey, removeAgentKeys)
- Check .add() return value - only emit AgentKeyAdded if actually added
- Check .add() return value in addCoSigner - only emit if actually added
- Add require(accountInitialized) guard in onUninstall (exit early)

New tests:
- Uninstall -> reinstall flow
- Duplicate agent key in onInstall (deduplicates via EnumerableSet)
- Duplicate addAgentKey does not emit
- Duplicate addCoSigner does not emit
- onUninstall reverts if not initialized

All 40 tests passing ✅

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Collaborator

@matiasedgeandnode matiasedgeandnode left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants