diff --git a/docs/AUTOVAULT_SETUP_COMPARISON.md b/docs/AUTOVAULT_SETUP_COMPARISON.md new file mode 100644 index 00000000..9a8bdc47 --- /dev/null +++ b/docs/AUTOVAULT_SETUP_COMPARISON.md @@ -0,0 +1,45 @@ +# Autovault setup comparison + +Verified on 2026-09-10 against Base RPC history and the JavaScript served by [Morpho's curator app](https://curator.morpho.org/vaults/create). + +## Live reference + +[SoSoValue x Unified Labs USDC](https://base.blockscout.com/address/0xb88A0269C0b7E665F15DE46F634e02cE81E361c9) uses Base USDC, with 6 underlying decimals. + +| Step | Block | Transaction | +| --- | --- | --- | +| Create Vault V2 | 51,024,757 | [Deployment](https://base.blockscout.com/tx/0xad3ab8ea7eb2c03ab93f9a86669565b85663009fef27494c304ac7a0b9dc1213) | +| Set name, symbol, curator | 51,024,762 | [Identity](https://base.blockscout.com/tx/0x746595c500322ff60d2be9450138ac5602ec96d6a0f5ff07f696875f38e65197) | +| Approve USDC | 51,024,780 | [Approval](https://base.blockscout.com/tx/0x00c1656b83a861414b7fc12b75283aa5c329e897c7f203394a8e23352fd12a79) | +| Apply permanent settings and mint dead shares | 51,024,785 | [Setup and seed](https://base.blockscout.com/tx/0x44f3bb2993073d512996126aa1bddc33eb532e234459f8ff3b1c6227d99c23f8) | +| Register adapter | 51,024,800 | [Adapter](https://base.blockscout.com/tx/0xb9b273a843249c0ada7174b1deeca6108f7751767d424331f19fc30ae6b75125) | + +The successful setup transaction contains 11 vault calls: submit/execute the registry setting; submit/execute abdication of the registry setter and three exit-critical gate setters; then `mint(1000000000000000000, 0x000000000000000000000000000000000000dEaD)`. + +Historical reads at block 51,024,784 returned zero supply, zero assets, and zero for all three exit gates. Vault logs from deployment through setup contain no earlier deposit. At block 51,024,785, supply and dead-address balance both equal `1e18`; assets and `convertToAssets(1e18)` both equal `1e6`. The seed therefore spent exactly **1 USDC** and was the first deposit. Deployment preceded the seed by 56 seconds. + +The approval transaction granted 9 USDC, leaving 8 after seeding. The current curator builder requests only the calculated seed amount when allowance is insufficient; this particular wallet's larger approval is an observed transaction choice, not the builder's default. + +## Current curator implementation + +The served [share calculator](https://curator.morpho.org/_next/static/immutable/chunks/1mcrmyw5p2eh3.js) matches Monarch's share and asset amounts for every integer decimal value from 0 through 255. It follows [Morpho's published formula](https://docs.morpho.org/curate/tutorials-v2/dead-deposit/). + +The served [creation and permanent-settings builders](https://curator.morpho.org/_next/static/immutable/chunks/1_icf6v3uc7lz.js) separate deployment, identity, and permanent settings. The latter clears any nonzero exit gates before abdication and minting. Approval can be batched with permanent settings on supported wallets; deployment has a separate confirmation wait. + +Executing the extracted permanent-settings builder locally with equivalent ABI/helper bindings and the reference vault's settings reproduced the live transaction's input byte for byte. This strongly supports a curator-app flow; a transaction cannot prove the originating website. The deployment salt also differs from Monarch's current `MONARCH_0` through `MONARCH_99` salt set. + +Source SHA-256 values, in the same order as the two links above: + +```text +27e21a49b61a181189e54f3ac1b95508a110e471dd2cde9e5f0ca9138629b1c4 +2dd2de7932fa4c90bd7b0ea0277d59b817e7d445d308df1d97856521c58587b5 +``` + +## Result for Monarch + +- The dead-share formula, receiver, and USDC amount agree. Monarch requires an exact seed allowance and checks current supply, price, and wallet balance again after approval. +- Monarch combines seed and adapter/role configuration in one vault multicall. It clears existing exit gates before minting and abdication, and mints before adapter, rate, and fee changes. The curator reference puts registry/gate abdications before minting; either sequence reverts atomically if a call fails. +- The comparison exposed a resumed-setup bug: a local Base-fork reproduction completed setup with a nonzero `sendSharesGate`, abdicated its setter, and left `canSendShares(user)` false. The corrected callback clears the gate and leaves transfers enabled. Permanently configured nonzero gates require review; failed gate reads stop setup, and completion requires all three gates to be zero. +- Both flows still separate deployment from seeding. Current-state checks do not guarantee the seed wins a race with another deposit. Existing funded vaults require historical review, and underlying market/Vault V1 dead-share requirements remain separate. + +The chain inspection was read-only. Transaction execution tests used a local Anvil fork, with no public-chain broadcasts. diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index bb4b7046..126f6ad3 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -112,6 +112,9 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - Do not introduce duplicate Sentry capture for `sendTransaction` mutation errors; `useTransactionWithToast` already reports send failures. - Use shared logic hooks like useBundlerAuthorizationStep, useTransactionWithToast, useTransactionProcessStore...etc. Look at a similar hook and try to follow the pattern instead of creating from scratch. - Validate chain IDs, token addresses, and allowance/permit assumptions at the transaction boundary. +- Vault V2 initialization must mint the decimal-adjusted minimum dead shares before completing setup, bound the asset spend, and await successful approval and setup receipts. Cover 6-, 8-, and 18-decimal assets, retries, and already-funded vaults in regression checks. +- Before abdicating an exit-critical gate setter, clear its nonzero gate. Regression checks must include resumed setup with active gates, already-abdicated nonzero gates, and failed gate reads; setup completion requires verified zero gates as well as abdications. +- A current dead-share balance does not prove that the seed was the first deposit. Document any deployment-to-initialization race when first-deposit ordering is not enforced atomically on-chain. - Verify chain-specific bundler and approval targets against the canonical deployment address for that chain. Deployed bytecode alone is insufficient because compatible bundler code may exist at multiple addresses. - Make sure chain switching and wallet connection are handled. Use shared component like `ExecuteTransactionButton`. - Post-confirmation referral attribution must be fire-and-forget; it must not block, fail, or change the user transaction success flow. diff --git a/scripts/test-vault-dead-deposit.cjs b/scripts/test-vault-dead-deposit.cjs new file mode 100644 index 00000000..b86cf78c --- /dev/null +++ b/scripts/test-vault-dead-deposit.cjs @@ -0,0 +1,234 @@ +'use strict'; +// Run with: node --test scripts/test-vault-dead-deposit.cjs +require('tsx/cjs'); +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { decodeFunctionData, maxUint256 } = require('viem'); +const { vaultv2Abi } = require('../src/abis/vaultv2'); +const { getVaultV2DeadDepositAmounts, VAULT_V2_DEAD_DEPOSIT_RECEIVER } = require('../src/utils/vaultV2Setup'); +const { fetchVaultV2DeadDeposit } = require('../src/data-sources/rpc/vault-dead-deposit'); +const { prepareVaultV2DeadDeposit } = require('../src/hooks/vault-dead-deposit'); + +const vaultAddress = '0x1111111111111111111111111111111111111111'; +const account = '0x2222222222222222222222222222222222222222'; +const asset = '0x3333333333333333333333333333333333333333'; + +function fixture(overrides = {}) { + const state = { + decimals: 6, + totalSupply: 0n, + totalAssets: 0n, + deadShares: 0n, + allowance: 0n, + balance: 1_000_000n, + previewAssets: 1_000_000n, + ...overrides, + }; + const approvals = []; + const reads = []; + let block = 100n; + const client = { + getBlockNumber: async (options) => { + assert.equal(options.cacheTime, 0); + return ++block; + }, + multicall: async ({ contracts, allowFailure, blockNumber }) => { + assert.equal(allowFailure, false); + reads.push({ contracts, blockNumber }); + return contracts.map((contract) => { + const { functionName, address, args } = contract; + if (functionName === 'asset') return asset; + if (functionName === 'totalSupply') return state.totalSupply; + if (functionName === 'totalAssets') return state.totalAssets; + if (functionName === 'balanceOf') { + if (address === vaultAddress) { + assert.equal(args[0], VAULT_V2_DEAD_DEPOSIT_RECEIVER); + return state.deadShares; + } + assert.equal(address, asset); + assert.equal(args[0], account); + return state.balance; + } + if (functionName === 'allowance') { + assert.equal(address, asset); + assert.deepEqual(args, [account, vaultAddress]); + return state.allowance; + } + if (functionName === 'previewMint') { + assert.equal(address, vaultAddress); + return state.previewAssets; + } + throw new Error(`Unexpected contract read: ${functionName}`); + }); + }, + readContract: async ({ address, functionName, blockNumber }) => { + assert.equal(address, asset); + assert.equal(functionName, 'decimals'); + assert.equal(blockNumber, block); + return state.decimals; + }, + }; + const approve = async (token, amount) => { + assert.equal(token, asset); + approvals.push(amount); + state.allowance = amount; + }; + const prepare = (overrideApprove = approve) => prepareVaultV2DeadDeposit({ client, vaultAddress, account, approve: overrideApprove }); + return { state, client, approvals, reads, approve, prepare }; +} + +test('Morpho seed amounts use underlying decimals, including the share-floor boundary', () => { + for (const [decimals, shares, assets] of [ + [0, 10n ** 24n, 1_000_000n], + [6, 10n ** 18n, 1_000_000n], + [8, 10n ** 16n, 1_000_000n], + [14, 10n ** 10n, 1_000_000n], + [15, 1_000_000_000n, 1_000_000n], + [16, 1_000_000_000n, 10_000_000n], + [18, 1_000_000_000n, 1_000_000_000n], + [24, 1_000_000_000n, 1_000_000_000n], + [255, 1_000_000_000n, 1_000_000_000n], + ]) + assert.deepEqual(getVaultV2DeadDepositAmounts(decimals), { shares, assets }); + for (const decimals of [-1, 1.5, 256, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws(() => getVaultV2DeadDepositAmounts(decimals), /Invalid underlying token decimals/); + } +}); + +test('eligibility reads use one fresh block and fail closed on RPC errors', async () => { + const f = fixture(); + assert.equal((await fetchVaultV2DeadDeposit(f.client, vaultAddress)).isSeeded, false); + assert.equal(f.reads[0].blockNumber, 101n); + f.client.readContract = async () => { + throw new Error('RPC unavailable'); + }; + await assert.rejects(f.prepare(), /RPC unavailable/); + assert.deepEqual(f.approvals, []); +}); + +test('empty vault approves only the seed and encodes mint to the dead address', async () => { + const f = fixture(); + const calls = await f.prepare(); + assert.deepEqual(f.approvals, [1_000_000n]); + assert.equal(calls.length, 1); + assert.deepEqual(decodeFunctionData({ abi: vaultv2Abi, data: calls[0] }), { + functionName: 'mint', + args: [10n ** 18n, VAULT_V2_DEAD_DEPOSIT_RECEIVER], + }); +}); + +test('8- and 18-decimal assets mint the correct raw shares and spend', async () => { + for (const [decimals, expectedShares, expectedAssets] of [ + [8, 10n ** 16n, 1_000_000n], + [18, 1_000_000_000n, 1_000_000_000n], + ]) { + const f = fixture({ decimals, balance: expectedAssets, previewAssets: expectedAssets }); + const [data] = await f.prepare(); + assert.deepEqual(f.approvals, [expectedAssets]); + assert.equal(decodeFunctionData({ abi: vaultv2Abi, data }).args[0], expectedShares); + } +}); + +test('existing sufficient dead shares skip mint and approval on resumed setup', async () => { + const f = fixture({ totalSupply: 2n * 10n ** 18n, deadShares: 10n ** 18n, totalAssets: 2_000_000n }); + assert.deepEqual(await f.prepare(), []); + assert.deepEqual(f.approvals, []); +}); + +test('funded vaults, insufficient dead shares, and residual assets cannot be seeded automatically', async () => { + for (const state of [{ totalSupply: 1n }, { totalSupply: 10n ** 18n, deadShares: 10n ** 18n - 1n }, { totalAssets: 1n }]) { + const f = fixture(state); + await assert.rejects(f.prepare(), /already has deposits/); + assert.deepEqual(f.approvals, []); + } +}); + +test('insufficient wallet funds and unexpected seed prices stop before approval', async () => { + for (const state of [{ balance: 999_999n }, { previewAssets: 1_000_001n }, { previewAssets: 999_999n }]) { + const f = fixture(state); + await assert.rejects(f.prepare(), /Insufficient token balance|initial share price has changed/); + assert.deepEqual(f.approvals, []); + } +}); + +test('both excessive and insufficient prior allowances reset to zero before exact approval', async () => { + for (const allowance of [maxUint256, 999_999n, 1_000_001n]) { + const f = fixture({ allowance }); + await f.prepare(); + assert.deepEqual(f.approvals, [0n, 1_000_000n]); + } + const exact = fixture({ allowance: 1_000_000n }); + await exact.prepare(); + assert.deepEqual(exact.approvals, []); +}); + +test('rejected or reverted approval cannot produce a mint call', async () => { + const f = fixture(); + await assert.rejects( + f.prepare(async () => { + throw new Error('Approval reverted'); + }), + /Approval reverted/, + ); +}); + +test('preparation waits for approval confirmation before rechecking vault state', async () => { + const f = fixture(); + let confirm; + const confirmation = new Promise((resolve) => { + confirm = resolve; + }); + let approvalStarted; + const started = new Promise((resolve) => { + approvalStarted = resolve; + }); + const pending = f.prepare(async (token, amount) => { + approvalStarted(); + await confirmation; + await f.approve(token, amount); + }); + await started; + assert.equal(f.reads.length, 2); + confirm(); + await pending; + assert.equal(f.reads[2].blockNumber, 102n); +}); + +test('a deposit during approval blocks setup instead of seeding an already-used vault', async () => { + const f = fixture(); + await assert.rejects( + f.prepare(async (token, amount) => { + await f.approve(token, amount); + f.state.totalSupply = 1n; + }), + /already has deposits/, + ); +}); + +test('a completed seed during approval is not minted again', async () => { + const f = fixture(); + const calls = await f.prepare(async (token, amount) => { + await f.approve(token, amount); + f.state.totalSupply = 10n ** 18n; + f.state.deadShares = 10n ** 18n; + }); + assert.deepEqual(calls, []); +}); + +test('wallet-edited approval limits and changed prices after approval are rejected', async () => { + const f = fixture(); + await assert.rejects( + f.prepare(async () => { + f.state.allowance = maxUint256; + }), + /Approve exactly/, + ); + const changed = fixture(); + await assert.rejects( + changed.prepare(async (token, amount) => { + await changed.approve(token, amount); + changed.state.previewAssets = 1_000_001n; + }), + /initial share price has changed/, + ); +}); diff --git a/scripts/test-vault-initialization-gates.cjs b/scripts/test-vault-initialization-gates.cjs new file mode 100644 index 00000000..831bec8f --- /dev/null +++ b/scripts/test-vault-initialization-gates.cjs @@ -0,0 +1,161 @@ +'use strict'; +// Run with: node --test scripts/test-vault-initialization-gates.cjs +require('tsx/cjs'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const { createRequire } = require('node:module'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); +const ts = require('typescript'); +const { decodeFunctionData, erc20Abi, zeroAddress } = require('viem'); +const { vaultv2Abi } = require('../src/abis/vaultv2'); +const setup = require('../src/utils/vaultV2Setup'); + +const account = '0x1111111111111111111111111111111111111111'; +const vaultAddress = '0x2222222222222222222222222222222222222222'; +const asset = '0x3333333333333333333333333333333333333333'; +const adapter = '0x4444444444444444444444444444444444444444'; +const registry = '0x5555555555555555555555555555555555555555'; +const gate = '0x6666666666666666666666666666666666666666'; +const react = { useCallback: (f) => f, useMemo: (f) => f(), useRef: (v) => ({ current: v }), useState: (v) => [v, () => {}] }; + +function loadHook(filename, mocks) { + const file = path.join(__dirname, '../src/hooks', filename); + const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const localRequire = createRequire(file); + const mod = { exports: {} }; + vm.runInNewContext(compiled, { + exports: mod.exports, + module: mod, + require: (name) => (name === 'react' ? react : (mocks[name] ?? localRequire(name))), + }); + return mod.exports; +} + +function fixture({ gates = {}, abdicated = new Set(), failedRead } = {}) { + const sent = []; + let allowance = 0n; + const read = ({ address, functionName, args }) => { + if (functionName === failedRead) throw new Error('Gate read failed'); + if (setup.VAULT_V2_EXIT_CRITICAL_GATES.some(({ getter }) => getter === functionName)) return gates[functionName] ?? zeroAddress; + if (functionName === 'abdicated') return abdicated.has(args[0]); + if (functionName === 'asset') return asset; + if (functionName === 'curator') return zeroAddress; + if (functionName === 'adapterRegistry') return registry; + if (functionName === 'isAdapter' || functionName === 'isAllocator') return true; + if (functionName === 'forceDeallocatePenalty') return setup.VAULT_V2_DEFAULT_FORCE_DEALLOCATE_PENALTY; + if (functionName === 'maxRate') return setup.VAULT_V2_DEFAULT_MAX_RATE; + if (functionName === 'performanceFeeRecipient') return zeroAddress; + if (functionName === 'performanceFee' || functionName === 'totalSupply' || functionName === 'totalAssets') return 0n; + if (functionName === 'allowance') return allowance; + if (functionName === 'balanceOf') return address === asset ? 1_000_000n : 0n; + if (functionName === 'previewMint') return 1_000_000n; + throw new Error(`Unexpected read: ${functionName}`); + }; + const client = { + getBlockNumber: async () => 100n, + readContract: async () => 6, + multicall: async ({ contracts, allowFailure }) => + contracts.map((c) => (allowFailure ? { status: 'success', result: read(c) } : read(c))), + simulateContract: async () => ({}), + waitForTransactionReceipt: async () => ({ status: 'success' }), + }; + const { useVaultV2 } = loadHook('useVaultV2.ts', { + wagmi: { + useConnection: () => ({ address: account }), + useChainId: () => 8453, + usePublicClient: () => client, + useReadContracts: () => ({ refetch: async () => {} }), + }, + '@tanstack/react-query': { useQueryClient: () => ({}) }, + './useTransactionTracking': { useTransactionTracking: () => ({ start() {}, update() {}, complete() {}, fail() {} }) }, + './useVaultQueryRefresh': { refetchVaultQueryData: async () => {} }, + './useTransactionWithToast': { + useTransactionWithToast: () => ({ + sendTransactionAsync: async (tx) => { + sent.push(tx); + if (tx.to === asset) allowance = decodeFunctionData({ abi: erc20Abi, data: tx.data }).args[1]; + return `0x${'1'.repeat(64)}`; + }, + }), + }, + '@/utils/morpho': {}, + '@/utils/monarch-agent': { findAgent: () => undefined }, + }); + return { sent, initialize: () => useVaultV2({ vaultAddress, chainId: 8453 }).completeInitialization(registry, adapter) }; +} + +test('resumed setup clears all active exit gates before minting and abdication', async () => { + const f = fixture({ gates: Object.fromEntries(setup.VAULT_V2_EXIT_CRITICAL_GATES.map(({ getter }) => [getter, gate])) }); + assert.equal(await f.initialize(), true); + const multicall = decodeFunctionData({ abi: vaultv2Abi, data: f.sent.at(-1).data }); + const calls = multicall.args[0].map((data) => decodeFunctionData({ abi: vaultv2Abi, data })); + const mintIndex = calls.findIndex((c) => c.functionName === 'mint'); + assert.ok(mintIndex > 0); + for (const [index, { setter }] of setup.VAULT_V2_EXIT_CRITICAL_GATES.entries()) { + const resetIndex = calls.findIndex((c) => c.functionName === setter); + assert.ok(resetIndex > 0 && resetIndex < mintIndex); + assert.equal(calls[resetIndex].args[0], zeroAddress); + const submitted = decodeFunctionData({ abi: vaultv2Abi, data: calls[resetIndex - 1].args[0] }); + assert.deepEqual(submitted, calls[resetIndex]); + const abdicateIndex = calls.findIndex( + (c) => c.functionName === 'abdicate' && c.args[0] === setup.VAULT_V2_EXIT_CRITICAL_GATE_SETTER_SELECTORS[index], + ); + assert.ok(abdicateIndex > resetIndex); + } +}); + +test('fresh setup does not add unnecessary gate resets', async () => { + const f = fixture(); + await f.initialize(); + const calls = decodeFunctionData({ abi: vaultv2Abi, data: f.sent.at(-1).data }).args[0]; + assert.ok( + calls.every( + (data) => + !setup.VAULT_V2_EXIT_CRITICAL_GATES.some(({ setter }) => decodeFunctionData({ abi: vaultv2Abi, data }).functionName === setter), + ), + ); +}); + +test('permanently configured gates and failed gate reads stop setup before approval', async () => { + for (const [index, { getter }] of setup.VAULT_V2_EXIT_CRITICAL_GATES.entries()) { + const f = fixture({ gates: { [getter]: gate }, abdicated: new Set([setup.VAULT_V2_EXIT_CRITICAL_GATE_SETTER_SELECTORS[index]]) }); + await assert.rejects(f.initialize(), /permanently set/); + assert.equal(f.sent.length, 0); + const failed = fixture({ failedRead: getter }); + await assert.rejects(failed.initialize(), /Gate read failed/); + assert.equal(failed.sent.length, 0); + } +}); + +test('setup status requires successful zero-gate reads as well as abdications', () => { + for (const result of [zeroAddress, gate, undefined]) { + const { useVaultV2InitializationStatus } = loadHook('useVaultV2InitializationStatus.ts', { + wagmi: { + useReadContracts: ({ contracts }) => ({ + data: contracts.map(({ functionName }) => { + const value = { + adapterRegistry: registry, + curator: account, + isAdapter: true, + forceDeallocatePenalty: setup.VAULT_V2_DEFAULT_FORCE_DEALLOCATE_PENALTY, + abdicated: true, + }[functionName]; + return value !== undefined || result !== undefined ? { status: 'success', result: value ?? result } : { status: 'failure' }; + }), + isLoading: false, + refetch: async () => {}, + }), + }, + '@/utils/networks': { getNetworkConfig: () => ({ vaultConfig: { morphoRegistry: registry } }) }, + './queries/useVaultV2DeadDepositQuery': { + useVaultV2DeadDepositQuery: () => ({ data: { isSeeded: true }, isPending: false, isError: false }), + }, + }); + const status = useVaultV2InitializationStatus({ vaultAddress, adapterAddress: adapter, chainId: 8453 }); + assert.equal(status.isComplete, result === zeroAddress); + } +}); diff --git a/src/data-sources/rpc/vault-dead-deposit.ts b/src/data-sources/rpc/vault-dead-deposit.ts new file mode 100644 index 00000000..bbdf456c --- /dev/null +++ b/src/data-sources/rpc/vault-dead-deposit.ts @@ -0,0 +1,32 @@ +import { type Address, type PublicClient, erc20Abi } from 'viem'; +import { vaultv2Abi } from '@/abis/vaultv2'; +import { getVaultV2DeadDepositAmounts, VAULT_V2_DEAD_DEPOSIT_RECEIVER } from '@/utils/vaultV2Setup'; + +export async function fetchVaultV2DeadDeposit(client: PublicClient, vaultAddress: Address) { + // Keep the seed eligibility and its price on one fresh snapshot. Failed reads + // must not be interpreted as an empty vault. + const blockNumber = await client.getBlockNumber({ cacheTime: 0 }); + const contract = { address: vaultAddress, abi: vaultv2Abi } as const; + const [asset, totalSupply, deadShares, totalAssets] = await client.multicall({ + allowFailure: false, + blockNumber, + contracts: [ + { ...contract, functionName: 'asset' }, + { ...contract, functionName: 'totalSupply' }, + { ...contract, functionName: 'balanceOf', args: [VAULT_V2_DEAD_DEPOSIT_RECEIVER] }, + { ...contract, functionName: 'totalAssets' }, + ], + }); + const assetDecimals = await client.readContract({ address: asset, abi: erc20Abi, functionName: 'decimals', blockNumber }); + const { shares, assets } = getVaultV2DeadDepositAmounts(assetDecimals); + + return { asset, assetDecimals, shares, assets, totalSupply, totalAssets, isSeeded: deadShares >= shares }; +} + +export type VaultV2DeadDeposit = Awaited>; + +export function assertVaultV2CanSeed(seed: VaultV2DeadDeposit) { + if (!seed.isSeeded && (seed.totalSupply !== 0n || seed.totalAssets !== 0n)) { + throw new Error('This vault already has deposits without the required dead shares. Review its deposit history before proceeding.'); + } +} diff --git a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx index 9071aa66..28a0a740 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { FiZap } from 'react-icons/fi'; -import { type Address, zeroAddress } from 'viem'; +import { type Address, formatUnits, zeroAddress } from 'viem'; import { useParams } from 'next/navigation'; import { usePublicClient } from 'wagmi'; import { Button } from '@/components/ui/button'; +import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { Input } from '@/components/ui/input'; import { AllocatorCard } from '@/components/shared/allocator-card'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; @@ -18,6 +19,8 @@ import { useVaultV2 } from '@/hooks/useVaultV2'; import { v2AgentsBase } from '@/utils/monarch-agent'; import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; import { useVaultInitializationModalStore } from '@/stores/vault-initialization-modal-store'; +import { useVaultV2InitializationStatus } from '@/hooks/useVaultV2InitializationStatus'; +import type { VaultV2DeadDeposit } from '@/data-sources/rpc/vault-dead-deposit'; const ZERO_ADDRESS = zeroAddress; const MORPHO_MARKET_ADAPTER_V2_CREATED_TOPIC = '0x2d5aa62fff752ff7caa68d3c82c1ae04ccb2053bd3be0ffee086953f6adc894e'; @@ -130,11 +133,13 @@ function MetadataStep({ function FinalizeSetupStep({ adapter, registryAddress, - isInitializing, + seed, + tokenSymbol, }: { adapter: Address; registryAddress: Address; - isInitializing: boolean; + seed?: VaultV2DeadDeposit; + tokenSymbol: string; }) { const adapterIsReady = adapter !== ZERO_ADDRESS; @@ -154,6 +159,16 @@ function FinalizeSetupStep({ Morpho registry
{shortenAddress(registryAddress)}
+
+ Dead deposit +

+ {seed + ? seed.isSeeded + ? 'Required dead shares are already present. No additional seed will be spent.' + : `${formatUnits(seed.assets, seed.assetDecimals)} ${tokenSymbol} will be permanently locked to protect the initial share price. This amount cannot be withdrawn.` + : 'Checking the vault and seed amount...'} +

+
); @@ -222,6 +237,7 @@ export function VaultInitializationModal() { vaultAddress: vaultAddressValue, chainId, }); + const [initializationError, setInitializationError] = useState(null); // Transaction success handler const handleTransactionSuccess = useCallback(() => { @@ -257,7 +273,6 @@ export function VaultInitializationModal() { const [vaultName, setVaultName] = useState(''); const [vaultSymbol, setVaultSymbol] = useState(''); const [deployedAdapter, setDeployedAdapter] = useState
(ZERO_ADDRESS); - const currentStep = STEP_SEQUENCE[stepIndex]; const publicClient = usePublicClient({ chainId }); const registryAddress = useMemo(() => { @@ -269,6 +284,15 @@ export function VaultInitializationModal() { // Adapter is detected if Monarch has indexed it or we just deployed it locally. const adapterAddress = deployedAdapter === ZERO_ADDRESS ? (marketAdapter ?? ZERO_ADDRESS) : deployedAdapter; const adapterDetected = adapterAddress !== ZERO_ADDRESS; + const { deadDeposit, refetch: refetchSetupStatus } = useVaultV2InitializationStatus({ + vaultAddress: vaultAddressValue, + chainId, + adapterAddress, + }); + const currentStep = + deadDeposit.data && !deadDeposit.data.isSeeded && (deadDeposit.data.totalSupply !== 0n || deadDeposit.data.totalAssets !== 0n) + ? 'review' + : STEP_SEQUENCE[stepIndex]; const isCheckingAdapter = (isAdapterLoading || isAdapterFetching) && !adapterDetected; const { deploy, isDeploying, canDeploy, factoryAddress } = useDeployMorphoMarketAdapter({ @@ -308,6 +332,7 @@ export function VaultInitializationModal() { const handleCompleteInitialization = useCallback(async () => { if (adapterAddress === ZERO_ADDRESS || registryAddress === ZERO_ADDRESS || !vaultAddress || !chainId) return; + setInitializationError(null); try { // Note: Adapter cap will be set when user configures market caps // Pass name and symbol if provided (will be trimmed and checked in useVaultV2) @@ -322,16 +347,18 @@ export function VaultInitializationModal() { return; } - await refetchVaultQueries({ includeRetries: true }); + await Promise.all([refetchVaultQueries({ includeRetries: true }), refetchSetupStatus()]); close(); - } catch (_error) { - // Error is handled by useVaultV2 hook (toast shown to user) + } catch (error) { + setInitializationError(error instanceof Error ? error.message : 'Unable to complete vault setup. Please try again.'); + void deadDeposit.refetch(); } }, [ completeInitialization, close, refetchVaultQueries, + refetchSetupStatus, registryAddress, selectedAgent, adapterAddress, @@ -340,6 +367,7 @@ export function VaultInitializationModal() { vaultAddress, vaultAddressValue, chainId, + deadDeposit.refetch, ]); // Reset state when modal closes @@ -350,6 +378,7 @@ export function VaultInitializationModal() { setVaultName(''); setVaultSymbol(''); setDeployedAdapter(ZERO_ADDRESS); + setInitializationError(null); } }, [isOpen]); @@ -360,7 +389,12 @@ export function VaultInitializationModal() { } }, [adapterDetected, stepIndex]); - const canCompleteInitialization = adapterAddress !== ZERO_ADDRESS && registryAddress !== ZERO_ADDRESS; + const canCompleteInitialization = + adapterAddress !== ZERO_ADDRESS && + registryAddress !== ZERO_ADDRESS && + !deadDeposit.isError && + !!deadDeposit.data && + (deadDeposit.data.isSeeded || (deadDeposit.data.totalSupply === 0n && deadDeposit.data.totalAssets === 0n)); const stepTitle = useMemo(() => { switch (currentStep) { @@ -372,12 +406,24 @@ export function VaultInitializationModal() { return 'Choose an Allocator'; case 'finalize': return 'Review & finalize'; + case 'review': + return 'Review existing deposits'; default: return ''; } }, [currentStep]); const renderCta = () => { + if (currentStep === 'review') { + return ( + + ); + } // Step 0: Deploy adapter if (stepIndex === 0) { return ( @@ -429,20 +475,16 @@ export function VaultInitializationModal() { // Step 3: Finalize - execute initialization return ( - + {isInitializing ? 'Completing...' : deadDeposit.data?.isSeeded ? 'Complete setup' : 'Approve & complete setup'} + ); }; @@ -468,6 +510,15 @@ export function VaultInitializationModal() { onClose={close} /> + {currentStep === 'review' && ( +

+ This vault already has deposits without the required dead shares. The initial dead deposit must come before user deposits. + Review its first deposit and share price before deciding how to proceed; this setup flow cannot repair it retroactively. +

+ )} {currentStep === 'deploy' && ( )} + {currentStep === 'finalize' && deadDeposit.isError && ( +
+

Unable to verify the dead deposit. Try again before completing setup.

+ +
+ )} + {initializationError && ( +

+ {initializationError} +

+ )} {currentStep === 'agents' && ( - + {currentStep !== 'review' && }
{renderCta()}
diff --git a/src/hooks/queries/useVaultV2DeadDepositQuery.ts b/src/hooks/queries/useVaultV2DeadDepositQuery.ts new file mode 100644 index 00000000..2199a405 --- /dev/null +++ b/src/hooks/queries/useVaultV2DeadDepositQuery.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { type Address, zeroAddress } from 'viem'; +import { usePublicClient } from 'wagmi'; +import { fetchVaultV2DeadDeposit } from '@/data-sources/rpc/vault-dead-deposit'; + +export function useVaultV2DeadDepositQuery(vaultAddress: Address | undefined, chainId: number) { + const client = usePublicClient({ chainId }); + + return useQuery({ + queryKey: ['vault-v2-dead-deposit', vaultAddress?.toLowerCase(), chainId], + queryFn: () => { + if (!client || !vaultAddress) throw new Error('Vault client unavailable'); + return fetchVaultV2DeadDeposit(client, vaultAddress); + }, + enabled: !!client && !!vaultAddress && vaultAddress !== zeroAddress, + staleTime: 10_000, + }); +} diff --git a/src/hooks/useVaultQueryRefresh.ts b/src/hooks/useVaultQueryRefresh.ts index 3ce6629b..a51a203d 100644 --- a/src/hooks/useVaultQueryRefresh.ts +++ b/src/hooks/useVaultQueryRefresh.ts @@ -27,6 +27,7 @@ const refetchVaultQuerySet = async (queryClient: QueryClient, vaultAddress: Addr await Promise.all([ queryClient.refetchQueries({ queryKey: ['vault-v2-data', normalizedVaultAddress, chainId], exact: false }), + queryClient.refetchQueries({ queryKey: ['vault-v2-dead-deposit', normalizedVaultAddress, chainId], exact: true }), queryClient.refetchQueries({ queryKey: ['vault-allocations', normalizedVaultAddress, chainId], exact: false }), queryClient.refetchQueries({ queryKey: ['user-vaults-v2'], exact: false }), ]); diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index aeec6fe2..280aa651 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -1,7 +1,7 @@ -import { useCallback, useMemo } from 'react'; -import { type Address, encodeFunctionData, zeroAddress } from 'viem'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { type Address, encodeFunctionData, erc20Abi, zeroAddress } from 'viem'; import { useQueryClient } from '@tanstack/react-query'; -import { useConnection, useChainId, useReadContracts } from 'wagmi'; +import { useConnection, useChainId, usePublicClient, useReadContracts } from 'wagmi'; import { vaultv2Abi } from '@/abis/vaultv2'; import type { VaultV2Cap } from '@/data-sources/monarch-api/vaults'; import type { SupportedNetworks } from '@/utils/networks'; @@ -9,15 +9,17 @@ import { VAULT_V2_DEFAULT_FORCE_DEALLOCATE_PENALTY, VAULT_V2_DEFAULT_MAX_RATE, VAULT_V2_EXIT_CRITICAL_GATE_SETTER_SELECTORS, + VAULT_V2_EXIT_CRITICAL_GATES, VAULT_V2_INITIALIZATION_ABDICATED_SELECTORS, VAULT_V2_SET_ADAPTER_REGISTRY_SELECTOR, } from '@/utils/vaultV2Setup'; -import { getClient } from '@/utils/rpc'; import { MONARCH_VAULT_QUERY_REFETCH_DELAYS_MS, refetchVaultQueryData } from './useVaultQueryRefresh'; import { useTransactionWithToast } from './useTransactionWithToast'; import type { Market } from '@/utils/types'; import { encodeMarketParams } from '@/utils/morpho'; import { findAgent } from '@/utils/monarch-agent'; +import { useTransactionTracking } from './useTransactionTracking'; +import { prepareVaultV2DeadDeposit } from './vault-dead-deposit'; export type PerformanceFeeConfig = { fee: bigint; @@ -94,6 +96,10 @@ export function useVaultV2({ const chainIdToUse = (chainId ?? connectedChainId) as SupportedNetworks; const { address: account } = useConnection(); const queryClient = useQueryClient(); + const publicClient = usePublicClient({ chainId: chainIdToUse }); + const initializationTracking = useTransactionTracking('vault-initialization'); + const initializationInProgress = useRef(false); + const [isInitializing, setIsInitializing] = useState(false); const vaultContract = { address: vaultAddress ?? zeroAddress, @@ -173,17 +179,22 @@ export function useVaultV2({ [chainIdToUse, onTransactionSuccess, queryClient, refetchAll, vaultAddress], ); - const { isConfirming: isInitializing, sendTransactionAsync: sendInitializationTx } = useTransactionWithToast({ + const { sendTransactionAsync: sendInitializationTx } = useTransactionWithToast({ toastId: `init-${vaultAddress ?? 'unknown'}`, pendingText: 'Completing vault initialization', successText: 'Vault initialized successfully', errorText: 'Failed to initialize vault', - pendingDescription: 'Setting up adapter, registry, and optional allocator', + pendingDescription: 'Seeding dead shares and completing vault configuration', successDescription: 'Vault is ready to use', chainId: chainIdToUse, - onSuccess: () => { - refreshVaultStateAfterTransaction(true); - }, + }); + + const { sendTransactionAsync: sendSeedApprovalTx } = useTransactionWithToast({ + toastId: `init-approval-${vaultAddress ?? 'unknown'}`, + pendingText: 'Approving dead deposit', + successText: 'Dead deposit approved', + errorText: 'Failed to approve dead deposit', + chainId: chainIdToUse, }); const { isConfirming: isUpdatingMetadata, sendTransactionAsync: sendMetadataTx } = useTransactionWithToast({ @@ -241,208 +252,271 @@ export function useVaultV2({ // All morpho v2 vault operations have to be proposed first, and then execute const completeInitialization = useCallback( async (morphoRegistry: Address, marketAdapter: Address, allocator?: Address, _name?: string, _symbol?: string): Promise => { - if (!account || !vaultAddress || marketAdapter === zeroAddress) return false; - - const client = getClient(chainIdToUse); - const contractBase = { address: vaultAddress, abi: vaultv2Abi } as const; - const allocatorToCheck = allocator ?? zeroAddress; - const [ - currentCuratorResult, - currentRegistryResult, - isAdapterResult, - forceDeallocatePenaltyResult, - isSelfAllocatorResult, - maxRateResult, - isInitialAllocatorResult, - performanceFeeResult, - performanceFeeRecipientResult, - ] = await client.multicall({ - contracts: [ - { ...contractBase, functionName: 'curator', args: [] }, - { ...contractBase, functionName: 'adapterRegistry', args: [] }, - { ...contractBase, functionName: 'isAdapter', args: [marketAdapter] }, - { ...contractBase, functionName: 'forceDeallocatePenalty', args: [marketAdapter] }, - { ...contractBase, functionName: 'isAllocator', args: [account] }, - { ...contractBase, functionName: 'maxRate', args: [] }, - { ...contractBase, functionName: 'isAllocator', args: [allocatorToCheck] }, - { ...contractBase, functionName: 'performanceFee', args: [] }, - { ...contractBase, functionName: 'performanceFeeRecipient', args: [] }, + if (!account || !vaultAddress || !publicClient || marketAdapter === zeroAddress || initializationInProgress.current) return false; + + initializationInProgress.current = true; + setIsInitializing(true); + initializationTracking.start( + [ + { id: 'prepare', title: 'Check dead deposit', description: 'Check the vault and approve its seed amount if needed' }, + { id: 'initialize', title: 'Complete setup', description: 'Mint dead shares and confirm vault configuration' }, ], - allowFailure: true, - }); - const abdicationResults = await client.multicall({ - contracts: VAULT_V2_INITIALIZATION_ABDICATED_SELECTORS.map((selector) => ({ - ...contractBase, - functionName: 'abdicated' as const, - args: [selector], - })), - allowFailure: true, - }); - const currentCurator = currentCuratorResult.status === 'success' ? (currentCuratorResult.result as Address) : curator; - const currentRegistry = currentRegistryResult.status === 'success' ? (currentRegistryResult.result as Address) : zeroAddress; - const isAdapterLinked = isAdapterResult.status === 'success' && isAdapterResult.result === true; - const currentForceDeallocatePenalty = - forceDeallocatePenaltyResult.status === 'success' ? (forceDeallocatePenaltyResult.result as bigint) : 0n; - const isSelfAllocator = isSelfAllocatorResult.status === 'success' && isSelfAllocatorResult.result === true; - const currentMaxRate = maxRateResult.status === 'success' ? (maxRateResult.result as bigint) : 0n; - const isInitialAllocator = isInitialAllocatorResult.status === 'success' && isInitialAllocatorResult.result === true; - const currentPerformanceFee = performanceFeeResult.status === 'success' ? (performanceFeeResult.result as bigint) : undefined; - const currentPerformanceFeeRecipient = - performanceFeeRecipientResult.status === 'success' ? (performanceFeeRecipientResult.result as Address) : undefined; - const abdicatedSelectors = new Set( - VAULT_V2_INITIALIZATION_ABDICATED_SELECTORS.filter( - (_selector, index) => abdicationResults[index]?.status === 'success' && abdicationResults[index]?.result === true, - ), + { title: 'Initialize vault' }, + 'prepare', ); - const txs: `0x${string}`[] = []; - // Step 0 (Optional). Set vault metadata if provided (no timelock needed) - if (_name?.trim()) { - const setNameTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'setName', - args: [_name.trim()], + try { + const client = publicClient; + + const contractBase = { address: vaultAddress, abi: vaultv2Abi } as const; + const allocatorToCheck = allocator ?? zeroAddress; + const [ + currentCuratorResult, + currentRegistryResult, + isAdapterResult, + forceDeallocatePenaltyResult, + isSelfAllocatorResult, + maxRateResult, + isInitialAllocatorResult, + performanceFeeResult, + performanceFeeRecipientResult, + ] = await client.multicall({ + contracts: [ + { ...contractBase, functionName: 'curator', args: [] }, + { ...contractBase, functionName: 'adapterRegistry', args: [] }, + { ...contractBase, functionName: 'isAdapter', args: [marketAdapter] }, + { ...contractBase, functionName: 'forceDeallocatePenalty', args: [marketAdapter] }, + { ...contractBase, functionName: 'isAllocator', args: [account] }, + { ...contractBase, functionName: 'maxRate', args: [] }, + { ...contractBase, functionName: 'isAllocator', args: [allocatorToCheck] }, + { ...contractBase, functionName: 'performanceFee', args: [] }, + { ...contractBase, functionName: 'performanceFeeRecipient', args: [] }, + ], + allowFailure: true, }); - txs.push(setNameTx); - } - - if (_symbol?.trim()) { - const setSymbolTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'setSymbol', - args: [_symbol.trim()], + const abdicationResults = await client.multicall({ + contracts: VAULT_V2_INITIALIZATION_ABDICATED_SELECTORS.map((selector) => ({ + ...contractBase, + functionName: 'abdicated' as const, + args: [selector], + })), + allowFailure: true, }); - txs.push(setSymbolTx); - } - - // Step 1. Assign curator if unset. - if (currentCurator === zeroAddress) { - const setCuratorTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'setCurator', - args: [account], + const currentCurator = currentCuratorResult.status === 'success' ? (currentCuratorResult.result as Address) : curator; + const currentRegistry = currentRegistryResult.status === 'success' ? (currentRegistryResult.result as Address) : zeroAddress; + const isAdapterLinked = isAdapterResult.status === 'success' && isAdapterResult.result === true; + const currentForceDeallocatePenalty = + forceDeallocatePenaltyResult.status === 'success' ? (forceDeallocatePenaltyResult.result as bigint) : 0n; + const isSelfAllocator = isSelfAllocatorResult.status === 'success' && isSelfAllocatorResult.result === true; + const currentMaxRate = maxRateResult.status === 'success' ? (maxRateResult.result as bigint) : 0n; + const isInitialAllocator = isInitialAllocatorResult.status === 'success' && isInitialAllocatorResult.result === true; + const currentPerformanceFee = performanceFeeResult.status === 'success' ? (performanceFeeResult.result as bigint) : undefined; + const currentPerformanceFeeRecipient = + performanceFeeRecipientResult.status === 'success' ? (performanceFeeRecipientResult.result as Address) : undefined; + const abdicatedSelectors = new Set( + VAULT_V2_INITIALIZATION_ABDICATED_SELECTORS.filter( + (_selector, index) => abdicationResults[index]?.status === 'success' && abdicationResults[index]?.result === true, + ), + ); + const currentGates = await client.multicall({ + contracts: VAULT_V2_EXIT_CRITICAL_GATES.map(({ getter }) => ({ ...contractBase, functionName: getter })), + allowFailure: false, + }); + const gateResetCalls = VAULT_V2_EXIT_CRITICAL_GATES.flatMap(({ getter, setter }, index) => { + if (normalizeAddress(currentGates[index]) === zeroAddress) return []; + if (abdicatedSelectors.has(VAULT_V2_EXIT_CRITICAL_GATE_SETTER_SELECTORS[index])) { + throw new Error(`The ${getter} is permanently set. Review this vault before completing setup.`); + } + return buildTimelockedCall(encodeFunctionData({ abi: vaultv2Abi, functionName: setter, args: [zeroAddress] })); + }); + const seedCalls = await prepareVaultV2DeadDeposit({ + client, + vaultAddress, + account, + approve: async (asset, amount) => { + const hash = await sendSeedApprovalTx({ + account, + chainId: chainIdToUse, + to: asset, + data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [vaultAddress, amount] }), + }); + const receipt = await client.waitForTransactionReceipt({ hash }); + if (receipt.status !== 'success') throw new Error('Dead deposit approval reverted'); + }, }); - txs.push(setCuratorTx); - } - // Abdicate exit-critical gate setters during initialization so the curator - // cannot later lock users out of shares or asset withdrawals. - const gateSettersToAbdicate = VAULT_V2_EXIT_CRITICAL_GATE_SETTER_SELECTORS.filter((selector) => !abdicatedSelectors.has(selector)); - txs.push(...buildVaultV2AbdicationCalls(gateSettersToAbdicate)); + const txs: `0x${string}`[] = []; - // Step 2. Commit to Morpho registry. - if (normalizeAddress(currentRegistry) !== morphoRegistry.toLowerCase()) { - const setRegistryTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'setAdapterRegistry', - args: [morphoRegistry], - }); + // Step 0 (Optional). Set vault metadata if provided (no timelock needed) + if (_name?.trim()) { + const setNameTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setName', + args: [_name.trim()], + }); + txs.push(setNameTx); + } - txs.push(...buildTimelockedCall(setRegistryTx)); - } + if (_symbol?.trim()) { + const setSymbolTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setSymbol', + args: [_symbol.trim()], + }); + txs.push(setSymbolTx); + } - // Step 3. Register the deployed adapter. - if (!isAdapterLinked) { - const addAdapterTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'addAdapter', - args: [marketAdapter], - }); + // Step 1. Assign curator if unset. + if (currentCurator === zeroAddress) { + const setCuratorTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setCurator', + args: [account], + }); + txs.push(setCuratorTx); + } - txs.push(...buildTimelockedCall(addAdapterTx)); - } + // Clear existing exit gates before minting or permanently disabling their + // setters, matching Morpho's curator setup. Mint before adapter/rate/fee + // changes; a failed reset or mint reverts the entire setup multicall. + txs.push(...gateResetCalls, ...seedCalls); + const gateSettersToAbdicate = VAULT_V2_EXIT_CRITICAL_GATE_SETTER_SELECTORS.filter((selector) => !abdicatedSelectors.has(selector)); + txs.push(...buildVaultV2AbdicationCalls(gateSettersToAbdicate)); - if (currentForceDeallocatePenalty !== VAULT_V2_DEFAULT_FORCE_DEALLOCATE_PENALTY) { - const setForceDeallocatePenaltyTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'setForceDeallocatePenalty', - args: [marketAdapter, VAULT_V2_DEFAULT_FORCE_DEALLOCATE_PENALTY], - }); + // Step 2. Commit to Morpho registry. + if (normalizeAddress(currentRegistry) !== morphoRegistry.toLowerCase()) { + const setRegistryTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setAdapterRegistry', + args: [morphoRegistry], + }); - txs.push(...buildTimelockedCall(setForceDeallocatePenaltyTx)); - } + txs.push(...buildTimelockedCall(setRegistryTx)); + } - // Note: Adapter cap will be set when user configures market caps in settings - // (EditCaps.tsx automatically ensures adapter cap is 100% + maxUint128) + // Step 3. Register the deployed adapter. + if (!isAdapterLinked) { + const addAdapterTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'addAdapter', + args: [marketAdapter], + }); - // Step 5. Abdicate registry control. - if (!abdicatedSelectors.has(VAULT_V2_SET_ADAPTER_REGISTRY_SELECTOR)) { - txs.push(...buildVaultV2AbdicationCalls([VAULT_V2_SET_ADAPTER_REGISTRY_SELECTOR])); - } + txs.push(...buildTimelockedCall(addAdapterTx)); + } - // Step 6.1 Set user as allocator (for withdrawal / setting Withdrawal Data) - if (!isSelfAllocator) { - const setSelfAllocatorTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'setIsAllocator', - args: [account, true], - }); + if (currentForceDeallocatePenalty !== VAULT_V2_DEFAULT_FORCE_DEALLOCATE_PENALTY) { + const setForceDeallocatePenaltyTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setForceDeallocatePenalty', + args: [marketAdapter, VAULT_V2_DEFAULT_FORCE_DEALLOCATE_PENALTY], + }); - txs.push(...buildTimelockedCall(setSelfAllocatorTx)); - } + txs.push(...buildTimelockedCall(setForceDeallocatePenaltyTx)); + } - // Step 6.2 As allocator, set max apy - if (currentMaxRate !== VAULT_V2_DEFAULT_MAX_RATE) { - const setMaxAPYTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'setMaxRate', - args: [VAULT_V2_DEFAULT_MAX_RATE], - }); + // Note: Adapter cap will be set when user configures market caps in settings + // (EditCaps.tsx automatically ensures adapter cap is 100% + maxUint128) - txs.push(setMaxAPYTx); - } + // Step 5. Abdicate registry control. + if (!abdicatedSelectors.has(VAULT_V2_SET_ADAPTER_REGISTRY_SELECTOR)) { + txs.push(...buildVaultV2AbdicationCalls([VAULT_V2_SET_ADAPTER_REGISTRY_SELECTOR])); + } - // Step 6.3 (Optional). Set initial allocator if provided. - if (allocator && allocator !== zeroAddress && !isInitialAllocator) { - const setAllocatorTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'setIsAllocator', - args: [allocator, true], - }); + // Step 6.1 Set user as allocator (for withdrawal / setting Withdrawal Data) + if (!isSelfAllocator) { + const setSelfAllocatorTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setIsAllocator', + args: [account, true], + }); - txs.push(...buildTimelockedCall(setAllocatorTx)); - } + txs.push(...buildTimelockedCall(setSelfAllocatorTx)); + } - // Step 6.4 (Optional). Apply performance fee if allocator is a known agent with a fee. - const agent = allocator && allocator !== zeroAddress ? findAgent(allocator) : undefined; - if ( - agent?.performanceFee !== undefined && - agent.performanceFeeRecipient && - (currentPerformanceFee !== agent.performanceFee || - normalizeAddress(currentPerformanceFeeRecipient) !== agent.performanceFeeRecipient.toLowerCase()) - ) { - txs.push(...buildPerformanceFeeCalls({ fee: agent.performanceFee, recipient: agent.performanceFeeRecipient })); - } + // Step 6.2 As allocator, set max apy + if (currentMaxRate !== VAULT_V2_DEFAULT_MAX_RATE) { + const setMaxAPYTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setMaxRate', + args: [VAULT_V2_DEFAULT_MAX_RATE], + }); - if (txs.length === 0) { - return true; - } + txs.push(setMaxAPYTx); + } - // Step 7. Execute multicall with all steps. - const multicallTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'multicall', - args: [txs], - }); + // Step 6.3 (Optional). Set initial allocator if provided. + if (allocator && allocator !== zeroAddress && !isInitialAllocator) { + const setAllocatorTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'setIsAllocator', + args: [allocator, true], + }); - try { - await sendInitializationTx({ + txs.push(...buildTimelockedCall(setAllocatorTx)); + } + + // Step 6.4 (Optional). Apply performance fee if allocator is a known agent with a fee. + const agent = allocator && allocator !== zeroAddress ? findAgent(allocator) : undefined; + if ( + agent?.performanceFee !== undefined && + agent.performanceFeeRecipient && + (currentPerformanceFee !== agent.performanceFee || + normalizeAddress(currentPerformanceFeeRecipient) !== agent.performanceFeeRecipient.toLowerCase()) + ) { + txs.push(...buildPerformanceFeeCalls({ fee: agent.performanceFee, recipient: agent.performanceFeeRecipient })); + } + + if (txs.length === 0) { + initializationTracking.complete(); + return true; + } + + // Step 7. Execute multicall with all steps. + const multicallTx = encodeFunctionData({ + abi: vaultv2Abi, + functionName: 'multicall', + args: [txs], + }); + + initializationTracking.update('initialize'); + // Surface reverts before opening the wallet. The exact seed allowance + // remains the on-chain spend bound after this simulation. + await client.simulateContract({ account, address: vaultAddress, abi: vaultv2Abi, functionName: 'multicall', args: [txs] }); + const hash = await sendInitializationTx({ account, to: vaultAddress, data: multicallTx, chainId: chainIdToUse, }); + const receipt = await client.waitForTransactionReceipt({ hash }); + if (receipt.status !== 'success') throw new Error('Vault initialization reverted'); + initializationTracking.complete(); + refreshVaultStateAfterTransaction(true); return true; } catch (initError) { + initializationTracking.fail(); if (initError instanceof Error && initError.message.toLowerCase().includes('reject')) { // user rejected the transaction; treat as graceful cancellation return false; } - console.error('Failed to complete vault initialization', initError); throw initError; + } finally { + initializationInProgress.current = false; + setIsInitializing(false); } }, - [account, chainIdToUse, curator, sendInitializationTx, vaultAddress], + [ + account, + chainIdToUse, + curator, + initializationTracking, + publicClient, + refreshVaultStateAfterTransaction, + sendInitializationTx, + sendSeedApprovalTx, + vaultAddress, + ], ); const updateNameAndSymbol = useCallback( diff --git a/src/hooks/useVaultV2InitializationStatus.ts b/src/hooks/useVaultV2InitializationStatus.ts index a1c0a264..a290687e 100644 --- a/src/hooks/useVaultV2InitializationStatus.ts +++ b/src/hooks/useVaultV2InitializationStatus.ts @@ -3,9 +3,20 @@ import { type Address, zeroAddress } from 'viem'; import { useReadContracts } from 'wagmi'; import { vaultv2Abi } from '@/abis/vaultv2'; import { getNetworkConfig, type SupportedNetworks } from '@/utils/networks'; -import { VAULT_V2_DEFAULT_FORCE_DEALLOCATE_PENALTY, VAULT_V2_INITIALIZATION_ABDICATED_SELECTORS } from '@/utils/vaultV2Setup'; - -export type VaultV2MissingSetupRequirement = 'adapter' | 'adapterRegistry' | 'curator' | 'forceDeallocatePenalty' | 'setupAbdications'; +import { + VAULT_V2_DEFAULT_FORCE_DEALLOCATE_PENALTY, + VAULT_V2_EXIT_CRITICAL_GATES, + VAULT_V2_INITIALIZATION_ABDICATED_SELECTORS, +} from '@/utils/vaultV2Setup'; +import { useVaultV2DeadDepositQuery } from './queries/useVaultV2DeadDepositQuery'; + +export type VaultV2MissingSetupRequirement = + | 'adapter' + | 'adapterRegistry' + | 'curator' + | 'forceDeallocatePenalty' + | 'setupAbdications' + | 'deadDeposit'; const normalizeAddress = (value: unknown): string => (typeof value === 'string' ? value.toLowerCase() : ''); @@ -29,6 +40,8 @@ export function useVaultV2InitializationStatus({ const vaultAddressToCheck = vaultAddress ?? zeroAddress; const adapterAddressToCheck = adapterAddress ?? zeroAddress; const enabled = vaultAddressToCheck !== zeroAddress; + const deadDeposit = useVaultV2DeadDepositQuery(vaultAddress, chainId); + const vaultContract = { address: vaultAddressToCheck, abi: vaultv2Abi, chainId } as const; const { data: setupCoreResults, @@ -40,34 +53,13 @@ export function useVaultV2InitializationStatus({ allowFailure: true, contracts: enabled ? [ - { - address: vaultAddressToCheck, - abi: vaultv2Abi, - functionName: 'adapterRegistry', - args: [], - chainId, - }, - { - address: vaultAddressToCheck, - abi: vaultv2Abi, - functionName: 'curator', - args: [], - chainId, - }, - { - address: vaultAddressToCheck, - abi: vaultv2Abi, - functionName: 'isAdapter', - args: [adapterAddressToCheck], - chainId, - }, - { - address: vaultAddressToCheck, - abi: vaultv2Abi, - functionName: 'forceDeallocatePenalty', - args: [adapterAddressToCheck], - chainId, - }, + { ...vaultContract, functionName: 'adapterRegistry' }, + { ...vaultContract, functionName: 'curator' }, + { ...vaultContract, functionName: 'isAdapter', args: [adapterAddressToCheck] }, + { ...vaultContract, functionName: 'forceDeallocatePenalty', args: [adapterAddressToCheck] }, + { ...vaultContract, functionName: 'receiveSharesGate' }, + { ...vaultContract, functionName: 'sendSharesGate' }, + { ...vaultContract, functionName: 'receiveAssetsGate' }, ] : [], query: { @@ -102,16 +94,22 @@ export function useVaultV2InitializationStatus({ }); const missingRequirements = useMemo(() => { - const adapterRegistry = setupCoreResults?.[0]?.status === 'success' ? (setupCoreResults[0].result as Address) : undefined; - const curator = setupCoreResults?.[1]?.status === 'success' ? (setupCoreResults[1].result as Address) : undefined; - const isLinkedAdapter = setupCoreResults?.[2]?.status === 'success' ? setupCoreResults[2].result === true : false; - const forceDeallocatePenalty = setupCoreResults?.[3]?.status === 'success' ? (setupCoreResults[3].result as bigint) : undefined; + const [registryResult, curatorResult, adapterResult, penaltyResult, ...gateResults] = setupCoreResults ?? []; + const adapterRegistry = registryResult?.status === 'success' ? (registryResult.result as Address) : undefined; + const curator = curatorResult?.status === 'success' ? (curatorResult.result as Address) : undefined; + const isLinkedAdapter = adapterResult?.status === 'success' && adapterResult.result === true; + const forceDeallocatePenalty = penaltyResult?.status === 'success' ? (penaltyResult.result as bigint) : undefined; const setupAbdicationsComplete = VAULT_V2_INITIALIZATION_ABDICATED_SELECTORS.every( (_selector, index) => abdicationResults?.[index]?.status === 'success' && abdicationResults[index]?.result === true, ); + const exitGatesOpen = VAULT_V2_EXIT_CRITICAL_GATES.every( + (_gate, index) => gateResults[index]?.status === 'success' && normalizeAddress(gateResults[index]?.result) === zeroAddress, + ); const missing: VaultV2MissingSetupRequirement[] = []; + if (!deadDeposit.data?.isSeeded) missing.push('deadDeposit'); + if (!adapterAddress || adapterAddress === zeroAddress || !isLinkedAdapter) { missing.push('adapter'); } @@ -128,23 +126,25 @@ export function useVaultV2InitializationStatus({ missing.push('forceDeallocatePenalty'); } - if (!setupAbdicationsComplete) { + if (!setupAbdicationsComplete || !exitGatesOpen) { missing.push('setupAbdications'); } return missing; - }, [adapterAddress, abdicationResults, expectedRegistry, setupCoreResults]); + }, [adapterAddress, abdicationResults, deadDeposit.data?.isSeeded, expectedRegistry, setupCoreResults]); const refetchSetupStatus = useCallback(async () => { - const [setupResult] = await Promise.all([refetch(), refetchAbdications()]); + const [setupResult] = await Promise.all([refetch(), refetchAbdications(), deadDeposit.refetch()]); return setupResult; - }, [refetch, refetchAbdications]); + }, [refetch, refetchAbdications, deadDeposit.refetch]); return { - error: error ?? abdicationError, - isComplete: enabled && !isLoading && !isLoadingAbdications && missingRequirements.length === 0, - isFetching: isFetching || isFetchingAbdications, - isLoading: isLoading || isLoadingAbdications, + deadDeposit, + error: error ?? abdicationError ?? deadDeposit.error, + isComplete: + enabled && !isLoading && !isLoadingAbdications && !deadDeposit.isPending && !deadDeposit.isError && missingRequirements.length === 0, + isFetching: isFetching || isFetchingAbdications || deadDeposit.isFetching, + isLoading: isLoading || isLoadingAbdications || deadDeposit.isPending, missingRequirements, refetch: refetchSetupStatus, }; diff --git a/src/hooks/vault-dead-deposit.ts b/src/hooks/vault-dead-deposit.ts new file mode 100644 index 00000000..f81f1d9b --- /dev/null +++ b/src/hooks/vault-dead-deposit.ts @@ -0,0 +1,58 @@ +import { type Address, type PublicClient, encodeFunctionData, erc20Abi } from 'viem'; +import { vaultv2Abi } from '@/abis/vaultv2'; +import { assertVaultV2CanSeed, fetchVaultV2DeadDeposit } from '@/data-sources/rpc/vault-dead-deposit'; +import { VAULT_V2_DEAD_DEPOSIT_RECEIVER } from '@/utils/vaultV2Setup'; + +export async function prepareVaultV2DeadDeposit({ + client, + vaultAddress, + account, + approve, +}: { + client: PublicClient; + vaultAddress: Address; + account: Address; + approve: (asset: Address, amount: bigint) => Promise; +}): Promise<`0x${string}`[]> { + const seed = await fetchVaultV2DeadDeposit(client, vaultAddress); + assertVaultV2CanSeed(seed); + if (seed.isSeeded) return []; + + const token = { address: seed.asset, abi: erc20Abi } as const; + const [allowance, balance, previewAssets] = await client.multicall({ + allowFailure: false, + contracts: [ + { ...token, functionName: 'allowance', args: [account, vaultAddress] }, + { ...token, functionName: 'balanceOf', args: [account] }, + { address: vaultAddress, abi: vaultv2Abi, functionName: 'previewMint', args: [seed.shares] }, + ], + }); + if (previewAssets !== seed.assets) throw new Error('The initial share price has changed. Review this vault before seeding it.'); + if (balance < seed.assets) throw new Error('Insufficient token balance for the dead deposit. Fund your wallet and try again.'); + + // mint has no maxAssets argument. An exact allowance bounds the permanent + // spend even if the price changes while the wallet prompt is open. Reset an + // existing allowance first, including for tokens such as USDT. + if (allowance !== seed.assets) { + if (allowance > 0n) await approve(seed.asset, 0n); + await approve(seed.asset, seed.assets); + } + + // Approval may take several blocks. Never seed an observed non-empty vault, + // and do not mint twice when setup is resumed after another seed transaction. + const freshSeed = await fetchVaultV2DeadDeposit(client, vaultAddress); + assertVaultV2CanSeed(freshSeed); + if (freshSeed.isSeeded) return []; + + const [confirmedAllowance, freshPreviewAssets] = await client.multicall({ + allowFailure: false, + contracts: [ + { ...token, functionName: 'allowance', args: [account, vaultAddress] }, + { address: vaultAddress, abi: vaultv2Abi, functionName: 'previewMint', args: [seed.shares] }, + ], + }); + if (confirmedAllowance !== seed.assets) throw new Error('Approve exactly the dead deposit amount before completing setup.'); + if (freshPreviewAssets !== seed.assets) throw new Error('The initial share price has changed. Review this vault before seeding it.'); + + return [encodeFunctionData({ abi: vaultv2Abi, functionName: 'mint', args: [seed.shares, VAULT_V2_DEAD_DEPOSIT_RECEIVER] })]; +} diff --git a/src/utils/vaultV2Setup.ts b/src/utils/vaultV2Setup.ts index 8f908c46..8014a0e1 100644 --- a/src/utils/vaultV2Setup.ts +++ b/src/utils/vaultV2Setup.ts @@ -1,14 +1,16 @@ import { toFunctionSelector } from 'viem'; -export const VAULT_V2_EXIT_CRITICAL_GATE_SETTER_SIGNATURES = [ - 'setReceiveSharesGate(address)', - 'setSendSharesGate(address)', - 'setReceiveAssetsGate(address)', +export const VAULT_V2_EXIT_CRITICAL_GATES = [ + { getter: 'receiveSharesGate', setter: 'setReceiveSharesGate' }, + { getter: 'sendSharesGate', setter: 'setSendSharesGate' }, + { getter: 'receiveAssetsGate', setter: 'setReceiveAssetsGate' }, ] as const; export const VAULT_V2_SET_ADAPTER_REGISTRY_SIGNATURE = 'setAdapterRegistry(address)' as const; -export const VAULT_V2_EXIT_CRITICAL_GATE_SETTER_SELECTORS = VAULT_V2_EXIT_CRITICAL_GATE_SETTER_SIGNATURES.map(toFunctionSelector); +export const VAULT_V2_EXIT_CRITICAL_GATE_SETTER_SELECTORS = VAULT_V2_EXIT_CRITICAL_GATES.map(({ setter }) => + toFunctionSelector(`${setter}(address)`), +); export const VAULT_V2_SET_ADAPTER_REGISTRY_SELECTOR = toFunctionSelector(VAULT_V2_SET_ADAPTER_REGISTRY_SIGNATURE); @@ -19,3 +21,18 @@ export const VAULT_V2_INITIALIZATION_ABDICATED_SELECTORS = [ export const VAULT_V2_DEFAULT_FORCE_DEALLOCATE_PENALTY = 5_000_000_000_000_000n; // 0.5%, WAD-scaled. export const VAULT_V2_DEFAULT_MAX_RATE = 63_419_583_967n; // 200% APR. + +export const VAULT_V2_DEAD_DEPOSIT_RECEIVER = '0x000000000000000000000000000000000000dEaD' as const; + +/** Morpho's minimum seed: https://docs.morpho.org/curate/tutorials-v2/dead-deposit/ */ +export function getVaultV2DeadDepositAmounts(assetDecimals: number) { + if (!Number.isInteger(assetDecimals) || assetDecimals < 0 || assetDecimals > 255) { + throw new Error('Invalid underlying token decimals'); + } + + const virtualShares = 10n ** BigInt(Math.max(0, 18 - assetDecimals)); + const inflationProtectionShares = 1_000_000n * virtualShares; + const shares = inflationProtectionShares > 1_000_000_000n ? inflationProtectionShares : 1_000_000_000n; + + return { shares, assets: shares / virtualShares }; +}