Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/AUTOVAULT_SETUP_COMPARISON.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions docs/VALIDATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
234 changes: 234 additions & 0 deletions scripts/test-vault-dead-deposit.cjs
Original file line number Diff line number Diff line change
@@ -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/,
);
});
Loading