Deposit Flow
End-to-end TypeScript that drives the MinimalVaultFactory from off-chain. The script:
- Reads the operator nonce and the bound DS token from the registrar.
- Asks the investor to sign the EIP-712 standing permission.
- Approves the factory to spend DS tokens (one-time, investor-side).
- Calls
factory.deposit(...)which deploys + registers the vault and pulls funds in. - Watches for
VaultRegistered,VaultDeployed, andDeposited. - 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).
MinimalVaultFactorydeployed atFACTORY_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 path | Gateway path | |
|---|---|---|
Who is msg.sender of registerVault | MinimalVaultFactory | The gateway's operator wallet |
| Operator field in EIP-712 signature | FACTORY | gatewayOperatorAddress |
| Vault address used | The one your factory just deployed | The gateway's per-network preconfigured VAULT_ADDRESS_<network> |
| Token movement | Done inside factory.deposit() | Not done — the gateway only relays registerVault. You still need to move tokens yourself. |
| Confirmation | waitForTransactionReceipt | Returns 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 when | Use gateway-mediated when |
|---|---|
| You're building a real protocol | You're prototyping a dApp |
| You hold a stable operator key | You don't want to manage keys yet |
| You need a per-investor vault | You're OK with a fixed sandbox vault |
| You need to move tokens in the same tx | You only need to register, not transfer |
| You want full control over the gas price and confirmation policy | You're OK with submit-only semantics |
Common pitfalls
- Signing with the wrong operator address. This is the #1 cause of
InvalidInvestorSignature(). Theoperatorfield in the EIP-712 message must be the contract or wallet that will bemsg.senderof the on-chainregisterVaultcall. - 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.
- 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). - Treating the gateway 200 response as confirmation. It's submit-only. Always poll the tx hash if you depend on inclusion.
- Skipping
addOperator. If you deploy the factory and immediately try to deposit, you'll see a role-related revert fromregisterVault. Have the registrar admin grantOPERATOR_ROLEfirst.