A hybrid smart contract combining the benefits of an ERC-4626 tokenized vault with the flexibility of a smart wallet for fund management.
MetaWallet enables institutional fund managers to operate a vault that accepts user deposits while maintaining full flexibility to deploy capital across DeFi strategies. It features:
- ERC-4626 Vault: Standard deposit/redeem flow with share-based accounting
- Smart Wallet: Arbitrary execution capabilities for strategy management
- Virtual Accounting:
totalAssetsremains stable during invest/divest operations - Hook System: Modular, chainable hooks for strategy interactions
- Merkle Proof Settlements: Off-chain attestation of external holdings
| Document | Description |
|---|---|
| Architecture | System design, inheritance, storage patterns |
| Hooks | Hook system, execution flows, chaining |
| Interfaces | Complete interface reference |
| Security | Access control, trust model, error codes |
| Coding Standards | Development conventions |
| Diagram | System architecture (Mermaid) |
┌─────────────────────────────────────────────────────────────────┐
│ MetaWallet │
├─────────────────────────────────────────────────────────────────┤
│ MinimalSmartAccount │ HookExecution │ MultiFacetProxy │
│ (execution + roles) │ (hook system) │ (modules) │
└─────────────────────────────────────────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
VaultModule Hooks
(ERC4626 + accounting) (ERC4626, 1inch Swap)
| Component | Description |
|---|---|
MetaWallet.sol |
Main contract inheriting wallet + vault + hook capabilities |
VaultModule.sol |
ERC-4626 vault logic with virtual totalAssets tracking |
HookExecution.sol |
Multi-hook execution system for strategy operations |
ERC4626ApproveAndDepositHook.sol |
Hook for investing into ERC-4626 vaults |
ERC4626RedeemHook.sol |
Hook for divesting from ERC-4626 vaults |
OneInchSwapHook.sol |
Hook for token swaps via 1inch Aggregation Router |
MetaWallet uses a minimalistic virtual accounting model:
totalAssets = virtualTotalAssets (stored value)
totalIdle = asset.balanceOf(vault)
- Deposits (
deposit/mint): IncreasevirtualTotalAssets - Redemptions (
redeem/withdraw): DecreasevirtualTotalAssets - Invest/Divest:
totalAssetsremains unchanged - Settlements: Manager updates
virtualTotalAssetsviasettleTotalAssets()
This design ensures share price stability during strategy operations, which is critical for cross-chain deployments where assets may be "in flight".
| Role | Permissions |
|---|---|
ADMIN_ROLE |
Install/uninstall hooks, add modules, initialize vault |
WHITELISTED_ROLE |
Call deposit on the vault |
EXECUTOR_ROLE |
Execute wallet operations via hooks |
MANAGER_ROLE |
Settle total assets and merkle roots |
EMERGENCY_ADMIN_ROLE |
Pause/unpause the vault |
Note:
WHITELISTED_ROLEandEXECUTOR_ROLEshare the same role slot (_ROLE_1), so granting one automatically grants the other.
// Deposit USDC and receive shares
vault.deposit(1000e6, user);// Redeem shares for USDC (limited by totalIdle)
vault.redeem(shares, user, user);Redemptions are limited by totalIdle - users can only withdraw up to the actual USDC balance in the vault.
ERC4626ApproveAndDepositHook.ApproveAndDepositData memory data =
ERC4626ApproveAndDepositHook.ApproveAndDepositData({
vault: EXTERNAL_VAULT,
assets: 5000e6,
receiver: address(metaWallet),
minShares: 0
});
IHookExecution.HookExecution[] memory hooks = new IHookExecution.HookExecution[](1);
hooks[0] = IHookExecution.HookExecution({
hookId: keccak256("hook.erc4626.deposit"),
data: abi.encode(data)
});
metaWallet.executeWithHookExecution(hooks);OneInchSwapHook.SwapData memory swapData = OneInchSwapHook.SwapData({
router: ONEINCH_ROUTER,
srcToken: USDC,
dstToken: WETH,
amountIn: 1000e6,
minAmountOut: 0.5 ether, // Slippage protection
receiver: address(metaWallet),
value: 0, // ETH value for native swaps
swapCalldata: oneInchCalldata // Pre-built 1inch API calldata
});
IHookExecution.HookExecution[] memory hooks = new IHookExecution.HookExecution[](1);
hooks[0] = IHookExecution.HookExecution({
hookId: keccak256("hook.oneinch.swap"),
data: abi.encode(swapData)
});
metaWallet.executeWithHookExecution(hooks);The swap hook supports:
- Static amounts: Fixed input amount specified in
amountIn - Dynamic amounts: Use
USE_PREVIOUS_HOOK_OUTPUTto chain with previous hooks - Native ETH swaps: Set
srcTokento0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeEandvalueto the ETH amount - Slippage protection: Set
minAmountOutto enforce minimum output
// Update totalAssets to reflect gains/losses
address[] memory strategies = new address[](2);
uint256[] memory values = new uint256[](2);
strategies[0] = VAULT_A;
strategies[1] = VAULT_B;
values[0] = 5000e6;
values[1] = 3000e6;
uint256 newTotalAssets = totalIdle + values[0] + values[1];
bytes32 merkleRoot = metaWallet.computeMerkleRoot(strategies, values);
metaWallet.settleTotalAssets(newTotalAssets, merkleRoot);// Emergency pause (blocks all user operations)
metaWallet.pause();
// Resume operations
metaWallet.unpause();External holdings can be validated against the stored merkle root:
address[] memory strategies = new address[](2);
uint256[] memory values = new uint256[](2);
strategies[0] = VAULT_A;
strategies[1] = VAULT_B;
values[0] = 5000e6;
values[1] = 3000e6;
bool valid = metaWallet.validateTotalAssets(strategies, values, merkleRoot);forge buildforge testforge fmtforge doc --serveMetaWallet uses CREATE2 for deterministic addresses across multiple chains. The deployment process involves:
- Deploy implementation contracts (once per chain)
- Deploy proxy via factory (same address on all chains)
Create a .env file:
# Required
PRIVATE_KEY=0x...
FACTORY_ADDRESS=0x... # MinimalSmartAccountFactory (same on all chains)
REGISTRY_ADDRESS=0x... # Your registry contract
ASSET_ADDRESS=0x... # Underlying asset (e.g., USDC)
# Optional
DEPLOY_SALT=0 # Custom salt for deterministic address
OWNER_ADDRESS=0x... # Defaults to deployer
VAULT_NAME="Meta USDC"
VAULT_SYMBOL="mUSDC"
# RPC URLs
MAINNET_RPC_URL=https://...
ARBITRUM_RPC_URL=https://...
OPTIMISM_RPC_URL=https://...
BASE_RPC_URL=https://...
POLYGON_RPC_URL=https://...
SEPOLIA_RPC_URL=https://...
# Block Explorer API Keys (for verification)
ETHERSCAN_API_KEY=...
ARBISCAN_API_KEY=...Deploy everything in a single transaction:
# Deploy to testnet first
make deploy-all-sepolia
# Deploy to mainnet
make deploy-all-mainnetThis deploys:
- MetaWallet implementation
- VaultModule implementation
- Proxy via CREATE2 factory
- ERC4626 deposit/redeem hooks
The proxy address is deterministic - use the same DEPLOY_SALT and deployer on all chains to get the same address.
If you prefer more control, deploy in steps:
Deploy the MetaWallet implementation and VaultModule to each chain:
# Deploy to Sepolia (testnet)
make deploy-impl-sepolia
# Deploy to mainnet
make deploy-impl-mainnet
# Deploy to all mainnets
make deploy-impl-allSave the deployed addresses:
IMPLEMENTATION_ADDRESS- MetaWallet implementationVAULT_MODULE_ADDRESS- VaultModule implementation
The proxy deployment requires additional environment variables:
# Add to .env
FACTORY_ADDRESS=0x... # MinimalSmartAccountFactory (same on all chains)
IMPLEMENTATION_ADDRESS=0x... # From step 1
VAULT_MODULE_ADDRESS=0x... # From step 1
REGISTRY_ADDRESS=0x... # Your registry contract
ASSET_ADDRESS=0x... # Underlying asset (e.g., USDC)
# Optional
DEPLOY_SALT=0 # Custom salt for deterministic address
OWNER_ADDRESS=0x... # Defaults to deployer
VAULT_NAME="Meta USDC"
VAULT_SYMBOL="mUSDC"Predict the proxy address before deploying:
make predict-addressDeploy the proxy (same address on all chains with same salt):
# Deploy proxy only
make deploy-proxy-sepolia
# Deploy proxy with ERC4626 hooks
make deploy-full-sepolia
# Deploy to all mainnets
make deploy-proxy-allIf you deployed without hooks, you can add them later:
# Add to .env
METAWALLET_ADDRESS=0x... # Deployed proxy address# Deploy hooks
make deploy-hooks-mainnet
# Install hooks (requires DEPOSIT_HOOK_ADDRESS, REDEEM_HOOK_ADDRESS)
make install-hooks-mainnetTo deploy to the same address on multiple chains:
- Use the same
DEPLOY_SALTon all chains - Use the same deployer address (
PRIVATE_KEY) - Ensure the factory is deployed at the same address on all chains
# Predict address first
FACTORY_ADDRESS=0x... DEPLOYER_ADDRESS=0x... DEPLOY_SALT=0 make predict-address
# Deploy to each chain (will have same address)
make deploy-proxy-mainnet
make deploy-proxy-arbitrum
make deploy-proxy-optimism
make deploy-proxy-base
make deploy-proxy-polygon| Script | Command | Description |
|---|---|---|
DeployAll |
deploy-all-* |
One command: impl + proxy + hooks |
Deploy |
deploy-impl-* |
Deploy implementation + VaultModule |
DeployProxy |
deploy-proxy-* |
Deploy proxy with VaultModule |
DeployProxyWithHooks |
deploy-full-* |
Deploy proxy + VaultModule + hooks |
PredictProxyAddress |
predict-address |
Predict CREATE2 address |
DeployHooks |
deploy-hooks-* |
Deploy hooks for existing wallet |
InstallHooks |
install-hooks-* |
Install hooks on existing wallet |
Test deployment without broadcasting by adding -dry-run suffix to any deployment command:
# Full deployment dry-run
make deploy-localhost-dry-run
make deploy-sepolia-dry-run
make deploy-mainnet-dry-run
# Step-by-step dry-run
make deploy-impl-localhost-dry-run
make deploy-proxy-sepolia-dry-run
make deploy-hooks-sepolia-dry-runDeployment addresses are saved to deployments/output/{network}/{accountId}.json:
deployments/
├── config/
│ ├── localhost.json
│ ├── sepolia.json
│ └── mainnet.json
└── output/
├── localhost/
│ └── metawallet.v1.json
└── sepolia/
└── metawallet.v1.json
The accountId is configured in the network config file (e.g., vault.accountId: "metawallet.v1"). This allows multiple MetaWallet instances to be deployed on the same chain with different accountIds.
- solady - Gas-optimized libraries
- minimal-smart-account - Smart wallet base
- kam - Multi-facet proxy and modules
MIT