Step 3: Deploy Vault
The vault is the custody address that will be attached to the investor identity in the DS registry. The VaultRegistrar only stores the vault's address — it never calls into the vault, never inspects its bytecode, and does not require any specific interface. You are free to design the vault contract however your protocol needs.
What "vault" actually means to the registrar
VaultRegistrar.registerVault(vault, investor, deadline, sig) does exactly one thing with the vault address: it calls
registry.addWallet(vault, investorIdOf(investor))
so that, from the DS registry's perspective, the vault address now resolves to the same investor as the investor wallet. That's it. There is no constructor enforcement, no onERC721Received-style hook, no operator role on the vault itself.
The registry binding is what makes step 5 work — DST transfers into an unregistered vault will revert at the token-compliance layer, so the registration must happen before the first transfer.
Two deployment patterns
Pick the one that fits your protocol shape.
Pattern A — Inline factory deploy (recommended for most protocols)
The factory deploys the vault on first deposit() and registers it in the same transaction. This is how MockDeFiProtocol.sol in bc-vault-registrar does it, and it's the pattern the examples/vault-factory page is built around.
function deposit(uint256 amount, uint256 deadline, bytes calldata signature) external {
address vault = investorVaults[msg.sender];
if (vault == address(0)) {
vault = address(new MinimalVault(address(this)));
investorVaults[msg.sender] = vault;
vaultRegistrar.registerVault(vault, msg.sender, deadline, signature);
emit VaultCreated(msg.sender, vault);
}
IERC20(dsToken).transferFrom(msg.sender, vault, amount);
emit Deposit(msg.sender, vault, amount);
}
Properties:
- One on-chain transaction for first-time investors (deploy + register + fund).
- Repeat depositors skip the deploy and re-use the existing vault.
- The factory is the operator and must hold
OPERATOR_ROLEonVaultRegistrar. - The
signaturefield is reusable across many deposits because the standing-permission model does not consume the nonce.
Pattern B — Pre-deployed standalone vault
Deploy the vault contract independently (Hardhat task, Foundry script, or any other tool), then call registerVault() separately. Useful when:
- You need the vault address to exist before collecting the investor signature (e.g. CREATE2 with a known salt).
- The vault is non-trivial enough to want its own deploy pipeline.
- You want a one-vault-per-investor-per-strategy layout that the factory pattern would not produce naturally.
Sketch:
# 1. Deploy vault
npx hardhat run scripts/deploy-minimal-vault.ts --network sepolia
# → prints vault address: 0xVault
# 2. Collect investor EIP-712 signature off-chain (see step 4)
# 3. Register the vault
npx hardhat register-vault \
--registrar 0xRegistrar \
--vault 0xVault \
--investor 0xInvestor \
--deadline $(($(date +%s)+600)) \
--signature 0xSig... \
--network sepolia
You can also use CREATE2 to compute the vault address ahead of time and pre-sign against it:
bytes32 salt = keccak256(abi.encodePacked(investor, strategyId));
address predicted = address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xff),
address(factory),
salt,
keccak256(abi.encodePacked(type(MinimalVault).creationCode, abi.encode(address(this))))
)))));
The investor signs against predicted, you deploy with the same salt, and the address matches.
Minimal vault contract
This is the smallest useful vault for the sandbox. It is also what bc-vault-registrar's MockVault.sol looks like.
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
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);
}
}
Notes:
- No
deposit()function — the vault is a passive recipient. Funds arrive via plain ERC-20transfer/transferFrom. withdrawis the only state-changing function and is gated to the operator (your factory).- No reentrancy concerns because there is no external call other than the ERC-20 transfer.
- Free-form: you can extend it with strategy hooks, allowance management, multi-asset support, ERC-4626 conformance, etc. The registrar does not care.
Required vault interface
There is none. The registrar never calls back into the vault. If you want to be a good citizen for downstream tooling, expose a way to query the operator and a way to withdraw — but neither is enforced.
If a downstream protocol wants to gate access on registration, it can call vaultRegistrar.isRegistered(vault, investor) instead of trusting the vault contract itself.
Operator checklist
- You decided on Pattern A (inline) or Pattern B (pre-deployed).
- If Pattern A: your factory holds
OPERATOR_ROLEand contains thevaultRegistrar.registerVault(...)call. - If Pattern B: your vault is deployed and you have its address ready for step 4.
- You have the factory/protocol contract address that the investor must encode as
operatorin their EIP-712 signature in step 4.