Minimal Vault Factory
This example is the smallest useful operator-side starting point: deploy a vault, register it, and let the operator move DST into the vault. It mirrors the shape of MockDeFiProtocol.sol and MockVault.sol in the bc-vault-registrar repo, with everything that isn't strictly required removed.
You can deploy this in a fresh Hardhat or Foundry project; nothing in the code requires the bc-vault-registrar repo at compile time beyond the IVaultRegistrar interface.
Solidity
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IVaultRegistrar {
function registerVault(
address vaultAddress,
address investorWalletAddress,
uint256 deadline,
bytes calldata signature
) external;
}
/// @notice Per-investor custody bucket. Free-form on purpose: the registrar
/// only stores the address. Withdraw is operator-gated.
contract MinimalVault {
address public immutable operator;
constructor(address _operator) {
operator = _operator;
}
modifier onlyOperator() {
require(msg.sender == operator, "ONLY_OPERATOR");
_;
}
function withdraw(address token, address to, uint256 amount) external onlyOperator {
IERC20(token).transfer(to, amount);
}
}
/// @notice Inline factory: deploys a per-investor MinimalVault on first
/// deposit, registers it with VaultRegistrar in the same tx, then
/// pulls DS tokens from the investor into the vault.
contract MinimalVaultFactory {
IVaultRegistrar public immutable registrar;
IERC20 public immutable dsToken;
mapping(address investor => address vault) public vaultOf;
event VaultDeployed(address indexed investor, address indexed vault);
event Deposited(address indexed investor, address indexed vault, uint256 amount);
event Withdrawn(address indexed investor, address indexed vault, uint256 amount);
constructor(address _registrar, address _dsToken) {
registrar = IVaultRegistrar(_registrar);
dsToken = IERC20(_dsToken);
}
/// @notice Deploys (if needed) and registers the investor's vault.
/// @dev Idempotent: if the vault already exists for `investor`,
/// this is a no-op and the existing vault is returned.
function deployAndRegisterVault(
address investor,
uint256 deadline,
bytes calldata signature
) public returns (address vault) {
vault = vaultOf[investor];
if (vault == address(0)) {
vault = address(new MinimalVault(address(this)));
vaultOf[investor] = vault;
registrar.registerVault(vault, investor, deadline, signature);
emit VaultDeployed(investor, vault);
}
}
/// @notice Bootstraps custody for the investor and pulls DS tokens in.
/// @dev Investor must have previously approved this contract for
/// at least `amount` DS tokens.
function deposit(
address investor,
uint256 amount,
uint256 deadline,
bytes calldata signature
) external {
address vault = deployAndRegisterVault(investor, deadline, signature);
dsToken.transferFrom(investor, vault, amount);
emit Deposited(investor, vault, amount);
}
/// @notice Returns DS tokens from the investor's vault to the investor.
/// @dev Anyone can call, but funds always flow to `investor`. Restrict
/// in production based on your strategy's lifecycle.
function withdraw(address investor, uint256 amount) external {
address vault = vaultOf[investor];
require(vault != address(0), "NO_VAULT");
MinimalVault(vault).withdraw(address(dsToken), investor, amount);
emit Withdrawn(investor, vault, amount);
}
}
Critical: the factory itself must hold OPERATOR_ROLE
This is the most common gotcha for developers building off the example. registerVault() recovers the operator from the EIP-712 signature and runs as msg.sender of the call — they must match.
When you deploy MinimalVaultFactory, the factory contract address is the one that calls registrar.registerVault(...). Therefore:
- Deploy the factory.
- Have the registrar admin call
vaultRegistrar.addOperator(factoryAddress)on every chain you intend to use. - The investor's EIP-712 signature must encode
operator = factoryAddress(not the deployer EOA, not any backend wallet).
Without step 2 you'll get OPERATOR_ROLE errors. With the wrong address in step 3 you'll get InvalidInvestorSignature.
Hardhat deploy task
Modeled on bc-vault-registrar/tasks/deploy-mock-defi-protocol.ts. Drop this into tasks/deploy-minimal-vault-factory.ts in your project:
import { task } from "hardhat/config";
task("deploy-minimal-vault-factory", "Deploys MinimalVaultFactory")
.addParam("registrar", "VaultRegistrar proxy address")
.addParam("dstoken", "DS token address (must match registrar.token())")
.addFlag("verify", "Verify on the block explorer")
.setAction(async ({ registrar, dstoken, verify }, hre) => {
const Factory = await hre.ethers.getContractFactory("MinimalVaultFactory");
const factory = await Factory.deploy(registrar, dstoken);
await factory.waitForDeployment();
const factoryAddress = await factory.getAddress();
console.log("MinimalVaultFactory deployed to:", factoryAddress);
console.log("Next: have the registrar admin call addOperator(", factoryAddress, ")");
if (verify) {
await hre.run("verify:verify", {
address: factoryAddress,
constructorArguments: [registrar, dstoken],
});
}
});
Run it:
npx hardhat deploy-minimal-vault-factory \
--registrar 0xVaultRegistrar \
--dstoken 0xDSToken \
--network sepolia \
--verify
Then add the factory as an operator:
# from bc-vault-registrar (admin only)
npx hardhat add-operator \
--registrar 0xVaultRegistrar \
--operator 0xMinimalVaultFactory \
--network sepolia
Constructor arguments
| Arg | Source | Notes |
|---|---|---|
_registrar | vaultRegistrar proxy address | Must be the proxy, not the implementation |
_dsToken | IVaultRegistrar(_registrar).token() | Read this off-chain before deploying so the two cannot diverge |
Gas notes
- First deposit per investor: deploy
MinimalVault(~150k gas) +registerVault()registry write (~100k gas) +transferFrom(~50k gas). Roughly 300–400k gas total depending on chain. - Subsequent deposits per investor: just the
transferFrom. Roughly 50k gas. - Withdraw: one external call to the vault +
transfer. Roughly 50–80k gas.
What this example does NOT do
This is the smallest viable shape. Production deployments will need at least:
- Access control on who can trigger
deposit/withdraw(the example lets anyone deposit on behalf of any investor that has approved the factory). - A clear vault ownership model — who can drain a vault in an emergency?
- Strategy adapters with bounded allowances.
- Slippage and asset accounting if the strategy touches USDC.
- Reentrancy guards on
deposit/withdrawonce the strategy involves external calls. - Off-chain monitoring of
VaultRegisteredandOperatorPermissionInvalidatedso a relay knows when to stop using a cached signature.
Production-shaped reference
If you want a more featured starting point, look at bc-vault-registrar/contracts/mock/MockDeFiProtocol.sol. It is still a mock, but it includes:
- One vault per investor with explicit
investorVaultsmapping VaultCreated/Deposit/WithdraweventsclearVault/clearMyVaultadmin escape hatches- A more careful separation between deploy, register, and deposit phases
Next
See Deposit Flow for the off-chain TypeScript that drives this contract end-to-end.