/prb-proxy

Proxy contract to compose transactions on behalf of the owner

Primary LanguageTypeScriptThe UnlicenseUnlicense

PRBProxy Coverage Status Styled with Prettier Commitizen Friendly license: Unlicense

Proxy contract to compose Ethereum transactions on behalf of the owner. Think of this as a smart wallet that enables the execution of multiple contract calls in one transaction. Externally owned accounts (EOAs) do not have this feature; they are limited to interacting with only one contract per transaction.

  • Forwards calls with DELEGATECALL
  • Uses CREATE2 to deploy the proxies at deterministic addresses
  • Employs a permission table to allow third-party accounts to call target contracts on behalf of the owner
  • Reverts with custom errors instead of reason strings
  • Well-documented via NatSpec comments
  • Thoroughly tested with Hardhat and Waffle

Background

The idea of a proxy contract has been popularized by DappHub, a team of developers who helped create the decentralized stablecoin DAI. DappHub created DSProxy, which grew to become the de facto proxy contract for developers who need to execute multiple contract calls in one transaction. For example, Maker, Balancer, and DeFi Saver all use DSProxy.

The catch is that it got in years. The Ethereum development ecosystem is much different today compared to 2017, when DSProxy was originally developed. The Solidity compiler has been significantly improved, new OPCODES have been added to the EVM, and development environments like Hardhat make writing smart contracts a breeze.

PRBProxy is a modern version of DSProxy, a "DSProxy 2.0", if you will. PRBProxy still uses DELEGATECALL to forwards contract calls, though it employs the high-level instruction rather than inline assembly, which makes the code easier to understand. All in all, there are two major improvements:

  1. PRBProxy is deployed with CREATE2, unlike DSProxy which is deployed with CREATE. This enables clients to deterministically compute the address of the proxy contract ahead of time.
  2. A PRBProxy user can give permission to third-party accounts to call target contracts on their behalf.

DSProxy has a target contract caching functionality. Talking to the Maker team, I was told that this feature didn't really pick up steam. Thus I decided not to include it in PRBProxy, making the bytecode smaller.

On the security front, I made three enhancements:

  1. The CREATE2 seeds are generated in such a way that they cannot be front-run.
  2. The owner cannot be changed during the DELEGATECALL operation.
  3. A minimum gas reserve is saved in storage such that the proxy does not become unusable if EVM opcode gas costs change in the future.

A noteworthy knock-on effect of using CREATE2 is that it eliminates the risk of a chain reorg overriding the owner of the proxy. With DSProxy, one has to wait for a few blocks to be mined before one can assume the contract to be safe to use. With PRBProxy, there is no such risk. It is even safe to send funds to the proxy before it is deployed.

Although I covered a lot here, I barely scratched the surface on proxy contracts. Maker's developer guide Working with DSProxy dives deep into how to compose contract calls. For the explanation given herein, that guide applies to PRBProxy as well; just keep in mind the differences between the two.

Install

With yarn:

$ yarn add prb-proxy ethers@5

Or npm:

$ npm install prb-proxy ethers@5

The trailing package is ethers.js, the only peer dependency of prb-proxy.

Usage

Contracts

As an end user, you don't have to deploy the contracts by yourself.

To deploy your own proxy, you can use the registry at the address below. In fact, this is the recommended approach.

Contract Address
PRBProxyRegistry 0xE29bCc91E088733a584FfCa4013d258957BfCe60
PRBProxyFactory 0xc3b9b328b2F1175C4FcE1C441ebC58b573920db0

Supported Chains

The address of the contracts are the same on all supported chains.

  • Ethereum Mainnet
  • Polygon Mainnet
  • Binance Smart Chain Mainnet
  • Fantom
  • Ethereum Goerli Testnet
  • Ethereum Kovan Testnet
  • Ethereum Rinkeby Testnet
  • Ethereum Ropsten Testnet

Code Snippets

All snippets are written in TypeScript. It is assumed that you run them in a local Hardhat project. Familiarity with Ethers and TypeChain is also requisite.

Check out my solidity-template for a boilerplate that combines Hardhat, Ethers and TypeChain.

Target Contract

You need a "target" contract to do anything meaningful with PRBProxy. This is basically a collection of stateless scripts. Below is an example for a target that performs a basic ERC-20 transfer.

Note that this is just a dummy example. In the real-world, you would do more complex work, e.g. interacting with a DeFi protocol.

Code Snippet
// SPDX-License-Identifier: Unlicense
pragma solidity >=0.8.4;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract TargetERC20Transfer {
  function transferTokens(
    IERC20 token,
    uint256 amount,
    address to,
    address recipient
  ) external {
    // Transfer tokens from user to PRBProxy.
    token.transferFrom(msg.sender, to, amount);

    // Transfer tokens from PRBProxy to specific recipient.
    token.transfer(recipient, amount);
  }
}

Compute Proxy Address

The prb-proxy package exports a helper function computeProxyAddress that can compute the address of a PRBProxy before it is deployed. The function takes two arguments: deployer and seed. The first is the EOA you sign the Ethereum transaction with. The second requires an explanation.

Neither PRBProxyFactory nor PRBProxyRegistry lets users provide a custom CREATE2 salt when deploying a proxy. Instead, the factory contract maintains a mapping between tx.origin accounts and some bytes32 seeds, each of which starts at 0x00 and grows linearly from there. If you wonder I used tx.origin, that's because it prevents front-running the CREATE2 salt.

PRBProxyFactory increments the value of the seed each time a new proxy is deployed. To get hold of the next seed that the factory will use, you can query the constant function getNextSeed. Putting it all together:

Code Snippet
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { task } from "hardhat/config";
import { PRBProxyFactory, computeProxyAddress, getPRBProxyFactory } from "prb-proxy";

task("compute-proxy-address").setAction(async function (_, { ethers }) {
  const signers: SignerWithAddress[] = await ethers.getSigners();

  // Load PRBProxyFactory as an ethers.js contract.
  const factory: PRBProxyFactory = getPRBProxyFactory(signers[0]);

  // Load the next seed. "signers[0]" is assumed to be the proxy deployer.
  const nextSeed: string = await factory.getNextSeed(signers[0].address);

  // Deterministically compute the address of the PRBProxy.
  const address: string = computeProxyAddress(signers[0].address, nextSeed);
});

Deploy Proxy

Code Snippet

It is recommended to deploy the proxy via the PRBProxyRegistry contract. The registry guarantees that an owner can have only one proxy at a time.

import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { task } from "hardhat/config";
import { PRBProxyRegistry, getPRBProxyRegistry } from "prb-proxy";

task("deploy-proxy").setAction(async function (_, { ethers }) {
  const signers: SignerWithAddress[] = await ethers.getSigners();

  // Load PRBProxyRegistry as an ethers.js contract.
  const registry: PRBProxyRegistry = getPRBProxyRegistry(signers[0]);

  // Call contract function "deploy" to deploy a PRBProxy belonging to "msg.sender".
  const tx = await registry.deploy();

  // Wait for a block confirmation.
  await tx.wait(1);
});

Get Current Proxy

Before deploying a new proxy, you may need to know if the account owns one already.

Code Snippet
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { task } from "hardhat/config";
import { PRBProxyRegistry, getPRBProxyRegistry } from "prb-proxy";

task("get-current-proxy").setAction(async function (_, { ethers }) {
  const signers: SignerWithAddress[] = await ethers.getSigners();

  // Load PRBProxyRegistry as an ethers.js contract.
  const registry: PRBProxyRegistry = getPRBProxyRegistry(signers[0]);

  // Query the address of the current proxy. "signers[0]" is assumed to be the proxy owner.
  const currentProxy: string = await registry.getCurrentProxy(signers[0].address);
});

Execute Composite Call

This section assumes that you already own a PRBProxy and that you compiled and deployed the TargetERC20Transfer contract in a local Hardhat project.

Code Snippet
import type { BigNumber } from "@ethersproject/bignumber";
import { parseUnits } from "@ethersproject/units";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { task } from "hardhat/config";
import { PRBProxy, getPRBProxy } from "prb-proxy";

import { TargetERC20Transfer__factory } from "../types/factories/TargetERC20Transfer__factory";
import type { TargetERC20Transfer } from "../types/TargetERC20Transfer";

task("execute-composite-call").setAction(async function (_, { ethers }) {
  const signers: SignerWithAddress[] = await ethers.getSigners();

  // Load the PRBProxy as an ethers.js contract.
  const prbProxyAddress: string = "0x...";
  const prbProxy: PRBProxy = getPRBProxy(prbProxyAddress, signers[0]);

  // Load the TargetERC20Transfer as an ethers.js contract.
  const targetAddress: string = "0x...";
  const target: TargetERC20Transfer = TargetERC20Transfer__factory.connect(targetAddress, signers[0]);

  // Encode the target contract call as calldata.
  const tokenAddress: string = "0x...";
  const amount: BigNumber = parseUnits("100", 18); // assuming the token has 18 decimals
  const recipient: string = signers[1].address;
  const data: string = target.interface.encodeFunctionData("transferTokens", [tokenAddress, amount, recipient]);

  // Execute the composite call.
  const receipt = await prbProxy.execute(targetAddress, data, { gasLimit });
});

Gas Efficiency

It costs 577,443 gas to deploy a PRBProxy, whereas in the case of DSProxy the cost is 596,198 gas. That's a slight reduction in deployment costs, but every little helps.

The execute function in PRBProxy costs a bit more than its equivalent in DSProxy. This is because of the additional safety checks, but the lion's share of the gas cost when calling execute is due to the logic in the target contract.

Security

While I set a high bar for code quality and test coverage, you shouldn't assume that this project is completely safe to use. The contracts have not been audited by a security researcher.

Caveat Emptor

This is experimental software and is provided on an "as is" and "as available" basis. I do not give any warranties and will not be liable for any loss, direct or indirect through continued use of this codebase.

Contact

If you discover any security issues, you can report them via Keybase.

Related Efforts

  • ds-proxy - DappHub's proxy, which powers the Maker protocol.
  • wand - attempt to build DSProxy 2.0, started by one of the original authors of DSProxy.
  • dsa-contracts - InstaDapp's DeFi Smart Accounts.

Contributing

Feel free to dive in! Open an issue, start a discussion or submit a PR.

Pre Requisites

You will need the following software on your machine:

In addition, familiarity with Solidity, TypeScript and Hardhat is requisite.

Set Up

Install the dependencies:

$ yarn install

Then, create a .env file and follow the .env.example file to add the requisite environment variables. Now you can start making changes.

License

Unlicense © Paul Razvan Berg