Skip to main content

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.

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_ROLE on VaultRegistrar.
  • The signature field 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-20 transfer / transferFrom.
  • withdraw is 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_ROLE and contains the vaultRegistrar.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 operator in their EIP-712 signature in step 4.

Next step

Step 4: Register Vault