Skip to main content

EIP-712

VaultRegistrar uses EIP-712 typed-data signatures for the standing-permission model: an investor signs once, and an operator may register one or more vaults under the investor's identity until the signature deadline expires or the investor explicitly revokes it. This page documents the exact domain, type, and digest construction so you can produce, debug, and verify signatures off-chain.

Standardization status

The IVaultRegistrar interface is the subject of an active ERC proposal:

The interface, EIP-712 type, and standing-permission model documented here are intended to track that proposal. Behavior in this implementation may move slightly as the ERC stabilizes; the verbatim runtime values are always whatever DOMAIN_SEPARATOR() and eip712Domain() return on-chain.

Domain

name = "VaultRegistrar"
version = "1"
chainId = <target chain>
verifyingContract = <VaultRegistrar proxy address>

These values are set inside __EIP712_init("VaultRegistrar", "1") during initialize(address). They are also accessible at runtime:

function DOMAIN_SEPARATOR() external view returns (bytes32);
function eip712Domain() external view returns (
bytes1 fields,
string memory name,
string memory version,
uint256 chainId,
address verifyingContract,
bytes32 salt,
uint256[] memory extensions
);

The frontend mirrors these constants in bc-partners-sandbox-fe/src/config/vault-registrar.config.ts:

export const vaultRegistrarConfig = {
defaultDeadlineSeconds: 600,
refetchIntervalMs: 30_000,
eip712Domain: {
name: "VaultRegistrar",
version: "1",
},
eip712Types: {
RegisterVault: [
{ name: "investor", type: "address" },
{ name: "operator", type: "address" },
{ name: "token", type: "address" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
};

The RegisterVault type

keccak256(
"RegisterVault(address investor,address operator,address token,uint256 nonce,uint256 deadline)"
)

The contract stores this value as REGISTER_TYPEHASH. When you build the digest, you must use the exact field order shown above.

Why each field exists

FieldPurpose
investorBinds the consent to one investor wallet — replay-proof against swapping the investor address.
operatorBinds the consent to one operator address. The investor cannot be tricked into authorizing a different protocol. Must equal msg.sender of registerVault at submission time.
tokenBinds the consent to one DS token deployment. Prevents reuse if the operator is granted a role on a different VaultRegistrar for a different token.
noncePer-(investor, operator) revocation switch. Read with operatorNonce(investor, operator).
deadlineHard upper bound on the signature's validity. Recommended: 10 minutes (the frontend default).

Standing-permission model

This contract intentionally does not increment the nonce on a successful registerVault. That makes one valid signature usable for:

  • a single vault registration, or
  • many vault registrations under the same (investor, operator) pair

until either:

  • block.timestamp > deadline, or
  • the investor calls invalidateOperatorPermission(operator) (which increments _operatorNonces[investor][operator] by 1, invalidating every previously issued signature in that pair).

This is a deliberate design choice: it lets a factory deposit on behalf of the same investor repeatedly without re-prompting for a wallet signature each time, while still giving the investor a single revoke button.

Producing a signature in viem

import { signTypedData } from "viem";

const nonce = await publicClient.readContract({
address: VAULT_REGISTRAR,
abi: vaultRegistrarAbi,
functionName: "operatorNonce",
args: [investorAddress, operatorAddress],
});

const token = await publicClient.readContract({
address: VAULT_REGISTRAR,
abi: vaultRegistrarAbi,
functionName: "token",
});

const deadline = BigInt(Math.floor(Date.now() / 1000) + 600);

const signature = await investorWalletClient.signTypedData({
domain: {
name: "VaultRegistrar",
version: "1",
chainId,
verifyingContract: VAULT_REGISTRAR,
},
types: {
RegisterVault: [
{ name: "investor", type: "address" },
{ name: "operator", type: "address" },
{ name: "token", type: "address" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
},
primaryType: "RegisterVault",
message: {
investor: investorAddress,
operator: operatorAddress,
token,
nonce,
deadline,
},
});

This is exactly what bc-partners-sandbox-fe/src/hooks/useSignVaultPermission.ts does under the hood, so the frontend hook is a working reference if you need to debug.

Producing a signature in ethers v6

const nonce = await registrar.operatorNonce(investorAddress, operatorAddress);
const token = await registrar.token();
const deadline = BigInt(Math.floor(Date.now() / 1000) + 600);

const domain = {
name: "VaultRegistrar",
version: "1",
chainId,
verifyingContract: VAULT_REGISTRAR,
};

const types = {
RegisterVault: [
{ name: "investor", type: "address" },
{ name: "operator", type: "address" },
{ name: "token", type: "address" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};

const message = {
investor: investorAddress,
operator: operatorAddress,
token,
nonce,
deadline,
};

const signature = await investor.signTypedData(domain, types, message);

The viem and ethers paths produce identical signatures for the same inputs. If they don't, your inputs differ.

Worked example

This section walks the digest through by hand so you can debug a InvalidInvestorSignature() revert. The on-chain check is equivalent to:

1. typeHash = keccak256("RegisterVault(address investor,address operator,address token,uint256 nonce,uint256 deadline)")
2. structHash = keccak256(abi.encode(typeHash, investor, operator, token, nonce, deadline))
3. domainSep = keccak256(abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("VaultRegistrar"),
keccak256("1"),
chainId,
verifyingContract
))
4. digest = keccak256(abi.encodePacked(0x1901, domainSep, structHash))
5. recovered = ecrecover(digest, v, r, s)
6. require(recovered == investorWalletAddress)

To debug from a frontend, compute domainSep against the on-chain DOMAIN_SEPARATOR() first:

const onchainDomainSep = await publicClient.readContract({
address: VAULT_REGISTRAR,
abi: vaultRegistrarAbi,
functionName: "DOMAIN_SEPARATOR",
});

// then compute your local digest with hashTypedData() / TypedDataEncoder.hash() and compare

If your local domain separator does not match the on-chain one, you have the wrong chainId or verifyingContract. If they match but the digest still mismatches, your structHash is wrong — usually because you reordered the fields, used the wrong nonce, or used the wrong token (always read it from the contract; do not hard-code).

Wagmi / React snippet

This is a condensed version of what bc-partners-sandbox-fe/src/hooks/useSignVaultPermission.ts does inside the React app:

import { useSignTypedData } from "wagmi";
import { vaultRegistrarConfig } from "@/config/vault-registrar.config";

const { signTypedDataAsync } = useSignTypedData();

const signature = await signTypedDataAsync({
domain: {
name: vaultRegistrarConfig.eip712Domain.name,
version: vaultRegistrarConfig.eip712Domain.version,
chainId,
verifyingContract: VAULT_REGISTRAR,
},
types: vaultRegistrarConfig.eip712Types,
primaryType: "RegisterVault",
message: { investor, operator, token, nonce, deadline },
});

ERC-1271 (contract wallets)

registerVault verifies signatures via SignatureChecker.isValidSignatureNow, which transparently supports both EOA signatures and ERC-1271 contract wallets. If your investor is a Safe or other smart contract wallet, the same EIP-712 flow works — the wallet's isValidSignature callback will be invoked and the contract decides whether to honor the signature.

The wallet returning the magic value 0x1626ba7e is treated as a valid signature.