Skip to main content

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:

  1. Deploy the factory.
  2. Have the registrar admin call vaultRegistrar.addOperator(factoryAddress) on every chain you intend to use.
  3. 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

ArgSourceNotes
_registrarvaultRegistrar proxy addressMust be the proxy, not the implementation
_dsTokenIVaultRegistrar(_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 / withdraw once the strategy involves external calls.
  • Off-chain monitoring of VaultRegistered and OperatorPermissionInvalidated so 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 investorVaults mapping
  • VaultCreated / Deposit / Withdraw events
  • clearVault / clearMyVault admin 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.