Alert Source Discuss
⚠️ Draft Standards Track: ERC

ERC-7613: Puppet Proxy Contract

A proxy that, if called by its deployer, delegates to an implementation specified in calldata.

Authors Igor Żuk (@CodeSandwich)
Created 2024-02-04
Discussion Link https://ethereum-magicians.org/t/eip-7613-puppet-proxy-contract/18482

Abstract

A puppet is a contract that, when called, acts like an empty account. It doesn’t do anything and it has no API, except when it is called by the address that deployed it. In that case, it delegates the call to the address passed to it in calldata. This gives the deployer the ability to execute any logic they want in the context of the puppet.

Motivation

A puppet can be used as an alternative account of its deployer. It has a different address, so it has a separate set of asset balances. This enables sophisticated accounting, e.g. each user of a protocol can get their own address where assets can be sent and stored. The user may call the protocol contract, which in turn will deploy a new puppet and consider it assigned to the user. If the puppet is deployed under a predictable address, e.g. by using the user’s address as the CREATE2 salt, the puppet may not even need to be deployed before funds are sent to its address. From now on the protocol will consider all the assets sent to the puppet as owned by the user. If the protocol needs to move the funds out from the puppet address, it can call the puppet ordering it to delegate to a function transferring the assets to arbitrary addresses, or making arbitrary calls triggering approved transfers to other contracts.

Puppets can be used as an alternative to approved transfers when loading funds into the protocol. Any contract and any wallet can transfer the funds to the puppet address assigned to the user without making any approvals or calling the protocol contracts. Funds can be loaded across multiple transactions and potentially from multiple sources. To funnel funds from another protocol, there’s no need for integration in the 3rd party contracts as long as they are capable of transferring funds to an arbitrary address. Wallets limited to plain ERC-20 transfers and stripped of any web3 functionality can be used to load funds into the protocol. The users of the fully featured wallets don’t need to sign opaque calldata blobs that may be harmful or approve the protocol to take their tokens, they only need to make a transfer, which is a simple process with a familiar UX. When the funds are already stored in the puppet assigned to the user, somebody needs to call the protocol so it’s notified that the funds were loaded. Depending on the protocol and its API this call may or may not be permissionless potentially making the UX even more convenient with gasless transactions or 3rd parties covering the gas cost. Some protocols don’t need the users to specify what needs to be done with the loaded funds or they allow the users to configure that in advance. Most of the protocols using approved transfers to load funds may benefit from using the puppets.

The puppet’s logic doesn’t need to be ever upgraded. To change its behavior the deployer needs to change the address it passes to the puppet to delegate to or the calldata it passes for delegation. The entire fleet of puppets deployed by a single contract can be upgraded by upgrading the contract that deployed them, without using beacons. A nice trick is that the deployer can make the puppet delegate to the address holding the deployer’s own logic, so the puppet’s logic is encapsulated in the deployer’s.

A puppet is unable to expose any API to any caller except the deployer. If a 3rd party needs to be able to somehow make the puppet execute some logic, it can’t be requested by directly calling the puppet. Instead, the deployer needs to expose a function that if called by the 3rd parties, will call the puppet, and make it execute the desired logic. Mechanisms expecting contracts to expose some APIs don’t work with puppet, e.g. ERC-721’s safeTransfers.

This standard defines the puppet as a blob of bytes used as creation code, which enables integration with many frameworks and codebases written in variety of languages. The specific tooling is outside of the scope of this standard, but it should be easy to create the libraries and helpers necessary for usage in practice. All the implementations will be interoperable because they will be creating identical puppets and if CREATE2 is used, they will have deterministic addresses predictable by all implementations.

Because the puppet can be deployed under a predictable address despite having no fixed logic, in some cases it can be used as a CREATE3 alternative. It can be also used as a full replacement of the CREATE3 factory by using a puppet deployed using CREATE2 to deploy arbitrary code using plain CREATE.

Deploying a new puppet is almost as cheap as deploying a new clone proxy. Its whole deployed bytecode is 66 bytes, and its creation code is 62 bytes. Just like clone proxy, it can be deployed using just the Solidity scratch space in memory. The cost to deploy a puppet is 45K gas, only 4K more than a clone. Because the bytecode is not compiled, it can be reliably deployed under a predictable CREATE2 address regardless of the compiler version.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.

To delegate, the deployer must prepend the calldata with an ABI-encoded address to delegate to. All the data after the address will be passed verbatim as the delegation calldata. If the caller isn’t the deployer, the calldata is shorter than 32 bytes, or it doesn’t start with an address left-padded with zeros, the puppet doesn’t do anything. This lets the deployer make a plain native tokens transfer to the puppet, it will have an empty calldata, and the puppet will accept the transfer without delegating.

The puppet is deployed with this creation code:

0x604260126D60203D3D3683113D3560A01C17733D3360147F331817604057823603803D943D373D3D355AF43D82803E903D91604057FD5BF36034525252F3

The bytecode breakdown:

// The creation code.
// [code 1] and [code 2] are parts of the deployed code,
// placed respectively before and after the deployer's address.
// | Opcode used    | Hex value     | Stack content after executing
// Code size and offset in memory
// | PUSH1          | 60 42         | 66
// | PUSH1          | 60 12         | 18 66
// The code before the deployer's address and where it's stored in memory
// | PUSH14         | 6D [code 1]   | [code 1] 18 66
// | RETURNDATASIZE | 3D            | 0 [code 1] 18 66
// The deployer's address and where it's stored in memory
// | CALLER         | 33            | [deployer] 0 [code 1] 18 66
// | PUSH1          | 60 14         | 20 [deployer] 0 [code 1] 18 66
// The code after the deployer's address and where it's stored in memory
// | PUSH32         | 7F [code 2]   | [code 2] 20 [deployer] 0 [code 1] 18 66
// | PUSH1          | 60 34         | 52 [code 2] 20 [deployer] 0 [code 1] 18 66
// Return the entire code
// | MSTORE         | 52            | 20 [deployer] 0 [code 1] 18 66
// | MSTORE         | 52            | 0 [code 1] 18 66
// | MSTORE         | 52            | 18 66
// | RETURN         | F3            |

// The deployed code.
// `deployer` is the deployer's address.
// | Opcode used    | Hex value     | Stack content after executing
// Push some constants
// | PUSH1          | 60 20         | 32
// | RETURNDATASIZE | 3D            | 0 32
// | RETURNDATASIZE | 3D            | 0 0 32
// Do not delegate if calldata shorter than 32 bytes
// | CALLDATASIZE   | 36            | [calldata size] 0 0 32
// | DUP4           | 83            | 32 [calldata size] 0 0 32
// | GT             | 11            | [do not delegate] 0 0 32
// Do not delegate if the first word of calldata is not a zero-padded address
// | RETURNDATASIZE | 3D            | 0 [do not delegate] 0 0 32
// | CALLDATALOAD   | 35            | [first word] [do not delegate] 0 0 32
// | PUSH1          | 60 A0         | 160 [first word] [do not delegate] 0 0 32
// | SHR            | 1C            | [first word upper bits] [do not delegate] 0 0 32
// | OR             | 17            | [do not delegate] 0 0 32
// Do not delegate if not called by the deployer
// | PUSH20         | 73 [deployer] | [deployer] [do not delegate] 0 0 32
// | CALLER         | 33            | [sender] [deployer] [do not delegate] 0 0 32
// | XOR            | 18            | [sender not deployer] [do not delegate] 0 0 32
// | OR             | 17            | [do not delegate] 0 0 32
// Skip to the return if should not delegate
// | PUSH1          | 60 40         | [success branch] [do not delegate] 0 0 32
// | JUMPI          | 57            | 0 0 32
// Calculate the payload size
// | DUP3           | 82            | 32 0 0 32
// | CALLDATASIZE   | 36            | [calldata size] 32 0 0 32
// | SUB            | 03            | [payload size] 0 0 32
// Copy the payload from calldata
// | DUP1           | 80            | [payload size] [payload size] 0 0 32
// | RETURNDATASIZE | 3D            | 0 [payload size] [payload size] 0 0 32
// | SWAP5          | 94            | 32 [payload size] [payload size] 0 0 0
// | RETURNDATASIZE | 3D            | 0 32 [payload size] [payload size] 0 0 0
// | CALLDATACOPY   | 37            | [payload size] 0 0 0
// Delegate call
// | RETURNDATASIZE | 3D            | 0 [payload size] 0 0 0
// | RETURNDATASIZE | 3D            | 0 0 [payload size] 0 0 0
// | CALLDATALOAD   | 35            | [delegate to] 0 [payload size] 0 0 0
// | GAS            | 5A            | [gas] [delegate to] 0 [payload size] 0 0 0
// | DELEGATECALL   | F4            | [success] 0
// Copy return data
// | RETURNDATASIZE | 3D            | [return size] [success] 0
// | DUP3           | 82            | 0 [return size] [success] 0
// | DUP1           | 80            | 0 0 [return size] [success] 0
// | RETURNDATACOPY | 3E            | [success] 0
// Return
// | SWAP1          | 90            | 0 [success]
// | RETURNDATASIZE | 3D            | [return size] 0 [success]
// | SWAP2          | 91            | [success] 0 [return size]
// | PUSH1          | 60 40         | [success branch] [success] 0 [return size]
// | JUMPI          | 57            | 0 [return size]
// | REVERT         | FD            |
// | JUMPDEST       | 5B            | 0 [return size]
// | RETURN         | F3            |

Rationale

The main goals of the puppet design are low cost and modularity. It should be cheap to deploy and cheap to interact with. The contract should be self-contained, simple to reason about, and easy to use as an architectural building block.

The puppet behavior could be implemented fairly easily in Solidity with some inline Yul for delegation. This would make the bytecode much larger and more expensive to deploy. It would also be different depending on the compiler version and configuration, so deployments under predictable addresses using CREATE2 would be trickier.

A workaround for the problems with the above solution could be to use the clone proxy pattern to deploy copies of the puppet implementation. It would make the cost to deploy each puppet a little lower than deploying the bytecode proposed in this document, and the addresses of the clones would be predictable when deploying using CREATE2. The downside is that now there would be 1 extra delegation for each call, from the clone proxy to the puppet implementation address, which costs gas. The architecture of such solution is also more complicated with more contracts involved, and it requires the initialization step of deploying the puppet implementation before any clone can be deployed. The initialization step limits the CREATE2 address predictability because the creation code of the clone proxy includes the implementation address, which affects the deployment address.

Another alternative is to use the beacon proxy pattern. Making a Solidity API call safely is a relatively complex procedure that takes up a non-trivial space in the bytecode. To lower the cost of the puppets, the beacon proxy probably should be used with the clone proxy, which would be even more complicated and more expensive to use than the above solutions. Querying a beacon for the delegation address is less flexible than passing it in calldata, it requires updating the state of the beacon to change the address.

Backwards Compatibility

No backward compatibility issues found.

The puppet bytecode doesn’t use PUSH0, because many chains don’t support it yet.

Test Cases

Here are the tests verifying that the bytecode and the reference implementation library are working as expected, using the Foundry test tools:

pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol";
import {Puppet} from "src/Puppet.sol";

contract Logic {
    string public constant ERROR = "Failure called";

    fallback(bytes calldata data) external returns (bytes memory) {
        return abi.encode(data);
    }

    function success(uint256 arg) external payable returns (address, uint256, uint256) {
        return (address(this), arg, msg.value);
    }

    function failure() external pure {
        revert(ERROR);
    }
}

contract PuppetTest is Test {
    address puppet = Puppet.deploy();
    address logic = address(new Logic());

    function logicFailurePayload() internal view returns (bytes memory) {
        return Puppet.delegationCalldata(logic, abi.encodeWithSelector(Logic.failure.selector));
    }

    function call(address target, bytes memory data) internal returns (bytes memory) {
        return call(target, data, 0);
    }

    function call(address target, bytes memory data, uint256 value)
        internal
        returns (bytes memory)
    {
        (bool success, bytes memory returned) = target.call{value: value}(data);
        require(success, "Unexpected revert");
        return returned;
    }

    function testDeployDeterministic() public {
        bytes32 salt = keccak256("Puppet");
        address newPuppet = Puppet.deployDeterministic(salt);
        assertEq(
            newPuppet, Puppet.predictDeterministicAddress(salt, address(this)), "Invalid address"
        );
        assertEq(
            newPuppet, Puppet.predictDeterministicAddress(salt), "Invalid address when no deployer"
        );
        assertEq(newPuppet.code, puppet.code, "Invalid code");
    }

    function testPuppetDelegates() public {
        uint256 arg = 1234;
        bytes memory data = abi.encodeWithSelector(Logic.success.selector, arg);
        bytes memory payload = Puppet.delegationCalldata(logic, data);
        uint256 value = 5678;

        bytes memory returned = call(puppet, payload, value);

        (address thisAddr, uint256 receivedArg, uint256 receivedValue) =
            abi.decode(returned, (address, uint256, uint256));
        assertEq(thisAddr, puppet, "Invalid delegation context");
        assertEq(receivedArg, arg, "Invalid argument");
        assertEq(receivedValue, value, "Invalid value");
    }

    function testPuppetDelegatesWithEmptyCalldata() public {
        bytes memory payload = Puppet.delegationCalldata(logic, "");
        bytes memory returned = call(puppet, payload);
        bytes memory data = abi.decode(returned, (bytes));
        assertEq(data.length, 0, "Delegated with non-empty calldata");
    }

    function testPuppetBubblesRevertPayload() public {
        vm.expectRevert(bytes(Logic(logic).ERROR()));
        call(puppet, logicFailurePayload());
    }

    function testPuppetDoesNothingForNonDeployer() public {
        vm.prank(address(1234));
        call(puppet, logicFailurePayload());
    }

    function testCallingWithCalldataShorterThan32BytesDoesNothing() public {
        address delegateTo = address(uint160(1234) << 8);
        bytes memory payload = abi.encodePacked(bytes31(bytes32(uint256(uint160(delegateTo)))));
        vm.mockCallRevert(delegateTo, "", "Logic called");
        call(puppet, payload);
    }

    function testCallingWithDelegationAddressOver20BytesDoesNothing() public {
        bytes memory payload = logicFailurePayload();
        payload[11] = 0x01;
        call(puppet, payload);
    }

    function testCallingPuppetDoesNothing() public {
        // Forge the calldata, so if puppet uses it to delegate, it will run `Logic.failure`
        uint256 forged = uint256(uint160(address(this))) << 32;
        forged |= uint32(Logic.failure.selector);
        bytes memory payload = abi.encodeWithSignature("abc(uint)", forged);
        call(puppet, payload);
    }

    function testTransferFromDeployerToPuppet() public {
        uint256 amt = 123;
        payable(puppet).transfer(amt);
        assertEq(puppet.balance, amt, "Invalid balance");
    }

    function testTransferToPuppet() public {
        uint256 amt = 123;
        address sender = address(456);
        payable(sender).transfer(amt);
        vm.prank(sender);
        payable(puppet).transfer(amt);
        assertEq(puppet.balance, amt, "Invalid balance");
    }
}

Reference Implementation

The puppet bytecode is explained in the specification section. Here’s the example helper library:

library Puppet {
    bytes internal constant CREATION_CODE =
        hex"604260126D60203D3D3683113D3560A01C17733D3360147F33181760405782"
        hex"3603803D943D373D3D355AF43D82803E903D91604057FD5BF36034525252F3";
    bytes32 internal constant CREATION_CODE_HASH = keccak256(CREATION_CODE);

    /// @notice Deploy a new puppet.
    /// @return instance The address of the puppet.
    function deploy() internal returns (address instance) {
        bytes memory creationCode = CREATION_CODE;
        assembly {
            instance := create(0, add(creationCode, 32), mload(creationCode))
        }
        require(instance != address(0), "Failed to deploy the puppet");
    }

    /// @notice Deploy a new puppet under a deterministic address.
    /// @param salt The salt to use for the deterministic deployment.
    /// @return instance The address of the puppet.
    function deployDeterministic(bytes32 salt) internal returns (address instance) {
        bytes memory creationCode = CREATION_CODE;
        assembly {
            instance := create2(0, add(creationCode, 32), mload(creationCode), salt)
        }
        require(instance != address(0), "Failed to deploy the puppet");
    }

    /// @notice Calculate the deterministic address for a puppet deployment made by this contract.
    /// @param salt The salt to use for the deterministic deployment.
    /// @return predicted The address of the puppet.
    function predictDeterministicAddress(bytes32 salt) internal view returns (address predicted) {
        return predictDeterministicAddress(salt, address(this));
    }

    /// @notice Calculate the deterministic address for a puppet deployment.
    /// @param salt The salt to use for the deterministic deployment.
    /// @param deployer The address of the deployer of the puppet.
    /// @return predicted The address of the puppet.
    function predictDeterministicAddress(bytes32 salt, address deployer)
        internal
        pure
        returns (address predicted)
    {
        bytes32 hash = keccak256(abi.encodePacked(hex"ff", deployer, salt, CREATION_CODE_HASH));
        return address(uint160(uint256(hash)));
    }

    function delegationCalldata(address delegateTo, bytes memory data)
        internal
        pure
        returns (bytes memory payload)
    {
        return abi.encodePacked(bytes32(uint256(uint160(delegateTo))), data);
    }
}

Security Considerations

The bytecode is made to resemble clone proxy’s wherever it makes sense to simplify auditing.

ABI-encoding the delegation address protects the deployer from being tricked by a 3rd party into calling the puppet and making it delegate to an arbitrary address. Such scenario would only be possible if the deployer called on the puppet a function with the selector 0x00000000, which as of now doesn’t come from any reasonably named function.

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Igor Żuk (@CodeSandwich), "ERC-7613: Puppet Proxy Contract [DRAFT]," Ethereum Improvement Proposals, no. 7613, February 2024. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-7613.