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:
- 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.
- 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:
- The CREATE2 seeds are generated in such a way that they cannot be front-run.
- The owner cannot be changed during the
DELEGATECALL
operation. - 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