Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: account delegation via callFrom #1358

Closed
alvrs opened this issue Aug 24, 2023 · 5 comments · Fixed by #1364
Closed

Proposal: account delegation via callFrom #1358

alvrs opened this issue Aug 24, 2023 · 5 comments · Fixed by #1364

Comments

@alvrs
Copy link
Member

alvrs commented Aug 24, 2023

Abstract

This proposal introduces a modular mechanism allowing accounts to delegate other accounts to execute functions on their behalf. By modularizing the delegation logic, we can cater to various use cases, from simple unlimited delegations to complex, conditional ones.

Design Goals

  • Enable accounts to authorize other accounts for delegated function calls.
  • Provide flexibility for various delegation types and restrictions.
  • Aim for minimal complexity in the core protocol to maintain simplicity and modularity.

Sample use cases

  1. Session accounts
    • Allow main accounts to delegate a burner wallet with restricted permissions.
    • Set conditions such as the number of calls, specific function access, or duration of the delegation.
    • More use case specific restrictions could be “allow delegation for the duration of this match”
  2. Trusted Forwarder**: [EIP-2771](https://eips.ethereum.org/EIPS/eip-2771)**, where users can authorize their preferred forwarder.
  3. Atomic Operations: Like ERC20's approve + transferFrom mechanism.
    • Ex. 1: Let a user delegate a module to call registerHook without transferring namespace ownership.
    • Ex. 2: Flash lend an army in an onchain strategy game.
  4. Modular Systems: Build higher-level abstractions using pre-existing systems. Refer to Modular systems / system to system calls #319

Proposed solution

Overview

  • An account can register a delegation in the world.
  • The delegation can be specific to a particular caller, or general to all callers who wish to call functions on behalf of the account.
  • The delegation can be unlimited or limited. Limited delegations require an associated contract which validate’s a caller’s attempt to perform an actions on behalf of the account.
  • Delegated actions are executed via the callFrom entry point.

Implementation

  • Any contract implementing the DelegationCheck interface can be associated with a limited delegation.

    interface DelegationCheck {
    	function check(address grantor, bytes32 systemId, bytes funcSelectorAndArgs) returns (bool);
    }
    
    contract World {
    	function registerLimitedDelegation(address grantee, address delegationCheck) {
    		Delegations.set({ grantor: msg.sender, grantee: grantee, delegation: delegationCheck });
    	}
    	
    	function revokeDelegation(address grantee) {
    		Delegations.deleteRecord({ grantor: msg.sender, grantee: grantee });
    	}
    }
  • For efficiency, a constant (address(1)) can represent unlimited delegations, circumventing the need for contract calls in this case.

    address constant UNLIMITED_DELEGATION = address(1);
    
    contract World {
    	function registerUnlimitedDelegation(address grantee) {
    		Delegations.set({ grantor: msg.sender, grantee: grantee, delegation: UNLIMITED_DELEGATION });
    	}
    }
  • Once a delegation is registered, the “grantee” can call functions on behalf of the “grantor” using callFrom. The world checks the validity of a delegation by calling the delegation check system registered for this grantee/grantor. There may be a different delegation check contract for each grantee, and/or a default delegation check for each grantor.

    contract World {
    	function callFrom(address from, bytes32 systemId, bytes funcSelectorAndArgs) {
    		// Check if there is an explicit delegation check set for the msg.sender
    		Delegation explicitDelegation = Delegations.wrap(Delegations.get({ grantor: from, grantee: msg.sender }));
    		if(explicitDelegation.check(from, msg.sender, systemId, funcSelectorAndArgs)) {
    			// forward the call with `from` as `msgSender`
    		}
    	
    		// Check if `from` has a fallback delegation check set
    		Delegation fallbackDelegation = Delegation.wrap(Delegations.get({ grantor: from, grantee: address(0) }));
    		if(fallbackDelegation.check(from, msg.sender, systemId, funcSelectorAndArgs)) {
    			// forward the call with `from` as `msgSender`
    		}
    		
    		revert NoDelegationFound();
    	}
    }
    
    type Delegation is address;
    using DelegationInstance for Delegation;
    
    library DelegationInstance {
    	function exists(address self) returns (bool) {
    		return self != address(0);
    	}
    
    	function isUnlimited(address self) returns (bool) {
    		return self == UNLIMITED_DELEGATION;
    	}
    
    	function isLimited(address self) returns (bool) {
    		return exists(self) && !isUnlimited(self);
    	}
    
    	function check(address self, address grantor, address grantee, bytes32 systemId, bytes funcSelectorAndArgs) returns (bool) {
    		// Early return if there is no valid delegation
    		if(!exists(self)) return false;
    
    		// Early return if there is an unlimited delegation
    		if(isUnlimited()) return true;
    
    		// Check the delegation if it's a limited delegation
    		// Note: this is pseudo-code; in reality we'd use a library that appends `msg.sender` to the calldata so `msgSender()` works in the system
    		try DelegationCheck(self).check(grantor, systemId, funcSelectorAndArgs) returns (bool success) {
    			return success;
    		} catch {
    			return false;
    		}
    	}
    }
  • With this setup in place delegation types such as limiting the number of calls can be added via a module.

    // The DecayingDelegationModule installs a DecayingDelegationSystem and DecayingDelegationTable in the world. It allows users to create delegations that are limited in the number of calls.
    
    // Like other systems, this system is only ever called by the World contract it reads/writes to/from.
    // (It can be called by anyone, but always reads from and writes to the caller)
    contract DecayingDelegationSystem is System, DelegationCheck {
    	function check(address grantee, bytes32 systemId, bytes funcSelectorAndArgs) returns (bool) {
    		uint256 numCallsLeft = DecayingDelegationTable.getNumCalls(_msgSender(), grantee, systemId, keccak256(funcSelectorAndArgs));
    		if(numCallsLeft == 0) {
    			revert NoCallsLeft();
    		}
    		DecayingDelegationTable.set(_msgSender(), grantee, systemId, funcSelectorAndArgs, numCallsLeft - 1);
    		return true;
    	}
    
    	function initializeDelegation(address grantee, bytes32 systemId, bytes funcSelectorAndArgs, uint256 numCalls) {
    		DecayingDelegationTable.set({ 
    			grantor: _msgSender(),
    			grantee: grantee,
    			funcSelectorAndArgsHash: keccak256(funcSelectorAndArgs),
    			numCalls: numCalls
    		});
    	}
    }
    • We can add similar systems for all primitive delegation times (e.g. approve delegation until block number), and more advanced ones like “approve until the current match is finished”
    • A user can create a new delegation using this system by setting this system as the delegation check and calling the system to set the number of calls. The UX of this can be improved using libraries (for the client and contracts).
    world.registerLimitedDelegation(grantee, decayingDelegationSystem);
    world.DecayingDelegationSystem_initializeDelegation(grantee, systemId, funcSelectorAndArgs, numCalls);
    • We could also extend the registerLimitedDelegation function to include a parameter for arbitrary bytes, which are passed to the system’s init function. This would simplify the UX of creating and initializing a limited delegation
    function registerLimitedDelegation(address grantee, address delegationCheck, bytes args) {
    	// Store the delegation check contract as before
    	Delegations.set({ grantor: msg.sender, grantee: grantee, delegation: delegationCheck });
    
    	// Initialize the delegation
    	delegationCheck.call(abi.encodePacked(DelegationCheck.init.selector, args, msg.sender));
    }

Related issues

@holic
Copy link
Member

holic commented Aug 24, 2023

I'm curious about what I imagine will be the most common use case: account delegation from EOA to burner wallet for an indefinite period of time.

I assume this path would mean extra gas via CALL per action/transaction of the burner wallet? Are there ways we can reduce that or is this design already accounting for that?

@alvrs
Copy link
Member Author

alvrs commented Aug 24, 2023

account delegation from EOA to burner wallet for an indefinite period of time.

if the delegation is not limited to certain functions, the UNLIMITED_DELEGATION can be used and no additional CALL would be required. If it should be limited to certain functions it would have to happen via a CALL to a delegation check in the current design. There is some overhead in that, but there are simply too many variants of limitations to hardcode some of them in the core function (eg. limit to all functions of a namespace, limit to all functions of a system, limit to a specific function, limit to a specific function with specific arguments). If the check happens via a CALL the respective delegation check system can just perform the checks that are actually required, which saves some gas over doing more checks in the core function that might not be relevant for the specific type of limitation.

@holic
Copy link
Member

holic commented Aug 24, 2023

Ohh got it! I glossed over the unlimited delegation bit. 👌

@ludns
Copy link
Member

ludns commented Aug 24, 2023

from your perspective, what would be the most "ERC-4337" native way of doing this?
The answer might be that there can't be any overlap and we have to roll everything on our own (apart from the trusted forwarder standard) and that's ok. But I am curious if there ways to re-use some of the infrastructure, like bundlers.
Have you took a look at the spec?

@alvrs
Copy link
Member Author

alvrs commented Aug 28, 2023

from your perspective, what would be the most "ERC-4337" native way of doing this?
The answer might be that there can't be any overlap and we have to roll everything on our own (apart from the trusted forwarder standard) and that's ok. But I am curious if there ways to re-use some of the infrastructure, like bundlers.
Have you took a look at the spec?

In my mind this proposal is mostly orthogonal to ERC-4337. There are some overlapping use cases (e.g. “session wallets” could also be implemented as a type of 4337 account), but other use cases are different. Most notably the use case of temporary approvals for specific function calls isn’t really a design goal of 4337, while it’s one of the core design goals of callFrom (i.e. support “atomic swaps” with any system akin to ERC-20’s approve/transferFrom pattern, or allow modules to set up stuff in your name, or allow a system to call other lower level systems in your name). It would definitely be possible to create a 4337 account that implements logic to temporarily delegate access to certain functions to other accounts, but it wouldn’t be the most straightforward solution, and it would be outside the MUD protocol, so other protocol features couldn’t build on top of it.

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 a pull request may close this issue.

3 participants