Core contractsVault capabilities
Vault capabilities
How to detect what a 4626 vault supports with ERC-165, and how the optional ERC-7540 async request module wraps a sync vault.
Integrators can ask a vault what it supports instead of guessing. This page covers the ERC-165 capability IDs, what share() means on these vaults, and the optional async request module.
At a glance
| Role | Capability discovery for Creator and Agent vaults, plus an optional async (ERC-7540) front end |
| Deployed | Capability IDs are a shared library; OVaultAsyncRequestModule is deployed in front of a vault when needed |
| Standard(s) | ERC-165, ERC-7575, ERC-7540, ERC-7887 (Draft), ERC-8161 |
| Key relationships | CreatorOVault, CreatorOVaultWrapper, CreatorShareOFT, Registry4626 |
How it works
Call supportsInterface(bytes4) with the official ERC-165 IDs, which are also collected in OVaultCapabilityIds. Shared capability interfaces live under contracts/shared/interfaces/vault/. Creator and Agent vaults keep their own lane identities.
IOVault4626 is a lean wiring ABI. Don't look there for share externalization or async requests.
Live CreatorOVault and AgentOVault do not implement supportsInterface or share() today. Adding them to the shared facade would push AgentOVault past the EIP-170 size limit. The intended ABI is covered by tests in test/shared/Test7575OVault.sol.
Capability matrix
| Capability | Interface ID | Intended sync vault | Live sync vault | Async request module |
|---|---|---|---|---|
| ERC-165 | 0x01ffc9a7 | yes | no | yes |
| ERC-7575 vault | 0x2f0a18c5 | yes | no | yes |
| ERC-7575 share | 0xf815c03d | yes | no | no (share is the underlying vault) |
| ERC-7540 operator | 0xe3bc4e65 | no | no | yes |
| ERC-7540 deposit | 0xce3bbe50 | no | no | yes |
| ERC-7540 redeem | 0x620ee8e4 | no | no | yes |
| ERC-7887 cancel deposit (Draft) | 0x8bf840e3 | no | no | yes |
| ERC-7887 cancel redeem (Draft) | 0xe76cffc7 | no | no | yes |
| ERC-8161 transfer deposit | 0x53b3bb0a | no | no | yes |
| ERC-8161 transfer redeem | 0x7846f5bd | no | no | yes |
The advertised ERC-7575 vault ID is IERC-4626 (excluding ERC-20) XOR share(), which is 0x2f0a18c5. It is not type(IOVault7575).interfaceId, which is only the share() selector (0xa8d5fd65).
What share() means here
Intended sync vaults expose:
function share() external view returns (address); // address(this)
function vault(address asset_) external view returns (address);
share() returns the vault itself: the vault claim (, or ◇ on agent lanes). That is the ERC-4626 accounting share, using the EIP-allowed "share() MAY return address(this)" path.
ShareOFT (, or ◆ on agent lanes) is not share(). It is a LayerZero token minted by the wrapper at a fixed 1000:1 normalization, with trade fees and lottery behavior. Find it through IOVaultWrapper4626.shareOFT() or Registry4626.
A true external-share ERC-7575 vault (share != address(this)) is not fully ERC-4626 compatible: the vault could not be the ERC-20, and entry functions would have to mint the external share 1:1. That shape is reserved for a future implementation.
Async request module (ERC-7540)
OVaultAsyncRequestModule sits in front of a compatible sync vault, including a live CreatorOVault that does not advertise ERC-7575. If the vault has no supportsInterface / share(), the module treats the vault itself as the ERC-4626 share token. It does not change CreatorOVault or AgentOVault.
The request flow:
- The controller calls
requestDepositorrequestRedeem. The request is Pending. fulfillDeposit/fulfillRedeemmoves it to Claimable.- The controller claims with
deposit/redeem, which settles against the underlying vault.
Key functions
| Function | What it does | Who can call |
|---|---|---|
requestDeposit(assets, controller, owner) / requestRedeem(shares, controller, owner) | Open a pending request; controller must equal owner | Owner or the owner's operator |
fulfillDeposit(controller) / fulfillRedeem(controller) | Mark pending as claimable (bookkeeping only) | The controller or its operator — not the module owner, not a stranger |
deposit / redeem (and mint / withdraw) | Claim against the underlying vault | Controller or its operator |
cancelDepositRequest(requestId, controller) / cancelRedeemRequest(requestId, controller) | ERC-7887 cancel; skips straight to cancel-claimable | Controller or its operator |
Behavior details
requestIdis always 0.previewDeposit,previewMint,previewRedeem, andpreviewWithdrawrevert.share()is the underlying vault ( / ◇), not the module and not ShareOFT.- Owner pause is instant (emergency) and does not lock claims.
- A new request or pending transfer is blocked while the destination controller still has a cancel-claimable balance.
- Claims emit
Deposit/Withdrawon the module (controller, receiver), so indexers don't need to watch the sync vault. - Cancel recovers pending or claimable assets or shares, so fulfill cannot trap funds. ERC-8161 transfer still moves pending balances only.
assetandshareare cached at construction.totalAssetsis escrowed request value (not donation-inclusive AUM) and never reverts.- Claims measure the token or share delta and revert if the vault spends more than reserved or mints zero shares. Cancel-claims revert if the module cannot cover the booked amount.
request*credits the received delta and reverts on a surplus (rebase-up or reflection tokens).
Max functions
| Function | Returns |
|---|---|
maxDeposit(controller) | min(claimable, vault.maxDeposit(controller)). Pass the controller (the claim-to-self receiver), not the module. |
maxMint(controller) | Inverts previewMint against the deposit cap and vault.maxMint(controller), so a mint-disabled vault advertises 0 |
maxWithdraw(controller) | Inverts previewWithdraw against maxRedeem and vault.maxWithdraw(address(this)) (the module holds the escrowed shares) |
mint(maxMint()) and withdraw(maxWithdraw()) succeed.
Lessons from other ERC-7540 stacks
These are production bugs elsewhere whose fixes we locked in with tests. They are not new product scope.
| Source | Failure | Locked by |
|---|---|---|
| Centrifuge / OZ ERC7540 | Public or owner fulfill griefs ERC-8161 pending transfers | testLesson_centrifuge_strangerFulfillCannotGriefPendingTransfer, testLesson_oz_operatorCanFulfillOwnController, testOwnerCannotFulfill |
| OpenZeppelin ERC7540 natspec | Dead controller locks the request | testRejectsModuleOrVaultAsController, testCancelAfterFulfillDeposit |
| Superform v2 (SUP-14312) | controller != owner on redeem desyncs share buckets | testLesson_superform_redeemControllerMustEqualOwner |
| Lagoon V1–V5 | Hybrid sync and async on one vault | testAsyncSelectorsAreAbsentOnSyncVaults, testSyncVaultPreviewsStillWork |
| ERC-7887 | New request while cancel is outstanding | testRequestBlockedWhileCancelClaimable, testLesson_eip7887_redeemBlockedWhileCancelClaimable |
| EIP-7540 / Centrifuge claim events | Indexers miss claims if only the inner vault emits | testLesson_eip7540_claimEmitsDepositWithController, testLesson_eip7540_claimEmitsWithdrawWithController |
| ERC-8161 | Transfer must not move claimable | testLesson_eip8161_transferDoesNotMoveClaimable |
Run them with:
forge test --match-test testLesson_ --no-match-path 'test/vault/strategies/CreatorOVaultStrategies.Rebalance.*'
If any fail, a known production bug has come back.
Gotchas
- To find the accounting share, require
supportsInterface(0x2f0a18c5)and readshare(). Never infer it fromshareOFT(),IShareOFT4626.vault(), ortype(IOVault7575).interfaceId. - Do not map
share()to ShareOFT on current vaults. - The large-withdrawal queue on the sync vault is an MEV and flash-loan gate, not ERC-7540.
share(),vault(address), andsupportsInterfaceare additive ABI. Live vaults keep their current bytecode until a size-safe reseal; this change does not cut a new greenfield shell or resealDEPLOY_BYTECODE. They won't be added to the shared facade untilAgentOVaultruntime is back under 24,576 bytes.