Skip to main content

Deposit Flow

End-to-end TypeScript that drives the MinimalVaultFactory from off-chain. The script:

  1. Reads the operator nonce and the bound DS token from the registrar.
  2. Asks the investor to sign the EIP-712 standing permission.
  3. Approves the factory to spend DS tokens (one-time, investor-side).
  4. Calls factory.deposit(...) which deploys + registers the vault and pulls funds in.
  5. Watches for VaultRegistered, VaultDeployed, and Deposited.
  6. Demonstrates the off-ramp: revoke + withdraw.

The whole flow uses viem. Adapt freely to ethers — the only viem-specific bits are the client setup and the signTypedData call.

Prerequisites

  • Investor wallet has DST (drip from the faucet first — see Step 1).
  • MinimalVaultFactory deployed at FACTORY_ADDRESS.
  • vaultRegistrar.addOperator(FACTORY_ADDRESS) was called by the registrar admin.
  • You know the registrar address and have its ABI (import from @/config/vault-registrar-abi).

Direct on-chain path

import {
createPublicClient,
createWalletClient,
custom,
http,
parseUnits,
parseEventLogs,
getContract,
} from "viem";
import { sepolia } from "viem/chains";
import { vaultRegistrarAbi } from "@/config/vault-registrar-abi";
import { factoryAbi } from "./minimal-vault-factory-abi";
import { erc20Abi } from "viem";

const publicClient = createPublicClient({
chain: sepolia,
transport: http(process.env.RPC_SEPOLIA),
});

// Investor signs through their wallet (browser or local key)
const investorWalletClient = createWalletClient({
account: investorAccount,
chain: sepolia,
transport: custom(window.ethereum), // or http() with a local key
});

const REGISTRAR = "0xVaultRegistrar" as const;
const FACTORY = "0xMinimalFactory" as const;

// 1. Read inputs from chain
const [nonce, token] = await Promise.all([
publicClient.readContract({
address: REGISTRAR,
abi: vaultRegistrarAbi,
functionName: "operatorNonce",
args: [investorAccount.address, FACTORY],
}),
publicClient.readContract({
address: REGISTRAR,
abi: vaultRegistrarAbi,
functionName: "token",
}),
]);

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

// 2. Investor signs the standing permission
const signature = await investorWalletClient.signTypedData({
account: investorAccount,
domain: {
name: "VaultRegistrar",
version: "1",
chainId: sepolia.id,
verifyingContract: 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: investorAccount.address,
operator: FACTORY, // CRITICAL: this is the factory address, not the investor's
token,
nonce,
deadline,
},
});

// 3. Approve the factory to spend DST (one-time per investor)
const approveHash = await investorWalletClient.writeContract({
address: token,
abi: erc20Abi,
functionName: "approve",
args: [FACTORY, parseUnits("100", 6)],
});
await publicClient.waitForTransactionReceipt({ hash: approveHash });

// 4. Deposit — factory deploys vault, registers it, pulls DST in
const depositHash = await investorWalletClient.writeContract({
address: FACTORY,
abi: factoryAbi,
functionName: "deposit",
args: [
investorAccount.address,
parseUnits("100", 6),
deadline,
signature,
],
});

const receipt = await publicClient.waitForTransactionReceipt({ hash: depositHash });

// 5. Parse the events
const registeredLog = parseEventLogs({
abi: vaultRegistrarAbi,
eventName: "VaultRegistered",
logs: receipt.logs,
})[0];

const depositedLog = parseEventLogs({
abi: factoryAbi,
eventName: "Deposited",
logs: receipt.logs,
})[0];

console.log("Registered:", {
investor: registeredLog.args.investor,
vault: registeredLog.args.vault,
sender: registeredLog.args.sender, // == FACTORY
investorId: registeredLog.args.investorId,
});
console.log("Deposited:", depositedLog.args);

Watching events live

const unwatch = publicClient.watchContractEvent({
address: REGISTRAR,
abi: vaultRegistrarAbi,
eventName: "VaultRegistered",
args: { investor: investorAccount.address },
onLogs: (logs) => {
for (const log of logs) {
console.log("New vault registered:", log.args.vault);
}
},
});

// later
unwatch();

Gateway-mediated path

If you want the gateway to submit registerVault for you, use the same signature but a different submission:

const response = await fetch(`${LABS_GW_URL}/api/v1/vault-registrar/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
networkId: "ethereum-sepolia",
investorAddress: investorAccount.address,
signature,
deadline: Number(deadline),
amount: "100000000", // accepted by the DTO; not used by the service today
}),
});

const { txHash } = await response.json();
console.log("Submitted via gateway:", txHash);

Critical differences from the direct path:

Direct pathGateway path
Who is msg.sender of registerVaultMinimalVaultFactoryThe gateway's operator wallet
Operator field in EIP-712 signatureFACTORYgatewayOperatorAddress
Vault address usedThe one your factory just deployedThe gateway's per-network preconfigured VAULT_ADDRESS_<network>
Token movementDone inside factory.deposit()Not done — the gateway only relays registerVault. You still need to move tokens yourself.
ConfirmationwaitForTransactionReceiptReturns on submit only — poll the explorer

In other words: the gateway path is useful for the narrow sandbox case where you want to register against a fixed vault without running your own operator key. It is not a drop-in replacement for the factory pattern.

Off-ramp: revoke and withdraw

The investor can revoke the standing permission at any time. This invalidates future use of any signature from this investor to this operator, but does not unbind already-registered vaults.

// 1. Withdraw funds first (operator-side)
await operatorWalletClient.writeContract({
address: FACTORY,
abi: factoryAbi,
functionName: "withdraw",
args: [investorAccount.address, parseUnits("100", 6)],
});

// 2. Investor revokes future operator permissions
const revokeHash = await investorWalletClient.writeContract({
address: REGISTRAR,
abi: vaultRegistrarAbi,
functionName: "invalidateOperatorPermission",
args: [FACTORY],
});

const revokeReceipt = await publicClient.waitForTransactionReceipt({ hash: revokeHash });

const invalidatedLog = parseEventLogs({
abi: vaultRegistrarAbi,
eventName: "OperatorPermissionInvalidated",
logs: revokeReceipt.logs,
})[0];

console.log("New nonce:", invalidatedLog.args.newNonce);

After revoke, every previously issued signature with the old nonce will fail with InvalidInvestorSignature(). Re-signing with the new nonce restores the standing permission.

Choosing the path

Use direct on-chain whenUse gateway-mediated when
You're building a real protocolYou're prototyping a dApp
You hold a stable operator keyYou don't want to manage keys yet
You need a per-investor vaultYou're OK with a fixed sandbox vault
You need to move tokens in the same txYou only need to register, not transfer
You want full control over the gas price and confirmation policyYou're OK with submit-only semantics

Common pitfalls

  1. Signing with the wrong operator address. This is the #1 cause of InvalidInvestorSignature(). The operator field in the EIP-712 message must be the contract or wallet that will be msg.sender of the on-chain registerVault call.
  2. Caching the nonce across sessions. If the investor revokes between sign-time and submit-time, your cached nonce is stale. Read it as close to the signing call as possible.
  3. Forgetting to approve before deposit. ERC-20 approve(factory, amount) is a separate transaction and must come first (or be combined via permit if your DST supports EIP-2612).
  4. Treating the gateway 200 response as confirmation. It's submit-only. Always poll the tx hash if you depend on inclusion.
  5. Skipping addOperator. If you deploy the factory and immediately try to deposit, you'll see a role-related revert from registerVault. Have the registrar admin grant OPERATOR_ROLE first.