Add support for a new ERC-4626 vault protocol. Use when the user wants to integrate a new vault protocol like IPOR, Plutus, Morpho, etc...
This skill guides you through adding support for a new ERC-4626 vault protocol to the eth_defi library.
Tokenised fund protocols belong under
eth_defi/tokenised_fund/{protocol_slug}/, rather than
eth_defi/erc_4626/vault_protocol/. Use
eth_defi/tokenised_fund/asseto/ and
eth_defi/tokenised_fund/securitize/ as the reference integrations. These
protocols commonly expose permissioned ERC-20 token shares and bespoke
subscription and redemption flows instead of ERC-4626 vault contracts.
Protocol metadata YAML is public website copy for DeFi professionals. The
short_description, long_description and fee_description fields must
explain the product, not this repository's integration.
links mapping alone is not enough.Before starting, gather the following information from the user:
HARDCODED_PROTOCOLS classification later, as there is no point to create complex vault smart contract detection patterns if the protocol does not need it.NoneA new vault protocol integration is not complete unless it includes:
create_vault_instance() wiringeth_defi/data/vaults/metadata/light.png: a light-coloured logo that remains legible on
the frontend's dark backgroundsRun the categorise-vault-strategy skill for every newly added vault and
every existing vault newly covered by the protocol integration. Do not tag
only the example contract used for protocol detection. Use
.claude/skills/categorise-vault-strategy/SKILL.md to review each vault's
description and context, maintain the protocol's address-level tags.py
mapping, wire get_strategy_tags() into the adapter, and add focused coverage.
Strategy tagging is part of protocol onboarding: the integration is incomplete
until all vault addresses introduced or newly covered by detection have either
an evidence-based tag mapping or are deliberately left unmapped so the
resolver returns the explicit missing-information result (None for
VaultBase adapters).
EVM address keys in protocol tags.py tables must be plain lowercase
strings, such as "0x1234...". Do not use verbose HexAddress(...)
constructors in table literals; the shared lookup helper normalises adapter
inputs before looking up these keys.
implementation() function or similareth_defi/abi/{protocol_slug}/
eth_defi/abi/{protocol_slug}/{ContractName}.json
eth_defi/abi/lagoon/ as a reference for structureFor a narrowly scoped adapter that only needs stable, no-argument view methods, using their canonical four-byte selectors is acceptable instead of storing a generated ABI. Link the authoritative ABI in the module docstring and add a fork regression test for every decoded value and scale.
Create eth_defi/erc_4626/vault_protocol/{protocol_slug}/vault.py following the patterns in:
eth_defi/erc_4626/vault_protocol/plutus/vault.py - Simple vault with hardcoded feeseth_defi/erc_4626/vault_protocol/ipor/vault.py - Complex vault with custom fee reading and multicall supportThe vault class should:
"""Module docstring describing the protocol."""
import datetime
import logging
from eth_typing import BlockIdentifier
from eth_defi.erc_4626.vault import ERC4626Vault
logger = logging.getLogger(__name__)
class {ProtocolName}Vault(ERC4626Vault):
"""Protocol vault support.
One line description of the protocol.
- Add links to protocol documentation
- Add links to example contracts on block explorers
- Add links to github
- If fee information is documented or available as Github source code, link into it
"""
def get_management_fee(self, block_identifier: BlockIdentifier) -> float:
return None
def get_performance_fee(self, block_identifier: BlockIdentifier) -> float | None:
return None
def get_estimated_lock_up(self) -> datetime.timedelta | None:
return None
def get_link(self, referral: str | None = None) -> str:
return f"https://protocol-url.com/vault/{self.vault_address}"
For get_link() check the protocol website to find a direct link URL pattern to its vault. Usual formats:
get_chain_name(chain_id).lower() or simiarEdit eth_defi/erc_4626/core.py and add a new enum member to ERC4626Feature:
#: {Protocol Name}
#:
#: {Protocol URL}
{protocol_slug}_like = "{protocol_slug}_like"
Also update get_vault_protocol_name() to return the protocol name:
elif ERC4626Feature.{protocol_slug}_like in features:
return "{Protocol Name}"
Edit eth_defi/erc_4626/classification.py:
Probe budget: Classification runs every probe against every candidate vault. Prefer one no-argument, protocol-specific view accessor per protocol. Use a second probe only when independently necessary contract variants cannot be safely identified by the first one, and document why both are required. Do not add fee, version, or other adapter data accessors merely to corroborate a classification; read those only after the adapter has been selected. Never add more than two protocol probes without explicit maintainer approval.
create_probe_calls(), add a probe call that uniquely identifies this protocol:getProtocolSpecificData(), custom role constants, etc. and compare them to what is already implemented in create_probe_calls()HARDCODED_PROTOCOLS in classification.py insteadIf you cannot find a such accessor function in the ABI or vault smart contract source, interrupt the skill and ask for user intervention.
# {Protocol Name}
# {Block explorer link}
{protocol_slug}_call = EncodedCall.from_keccak_signature(
address=address,
signature=Web3.keccak(text="uniqueFunction()")[0:4],
function="uniqueFunction",
data=b"",
extra_data=None,
)
yield {protocol_slug}_call
identify_vault_features(), add detection logic:if calls["uniqueFunction"].success:
features.add(ERC4626Feature.{protocol_slug}_like)
In eth_defi/erc_4626/classification.py, add a case for the new protocol in create_vault_instance():
elif ERC4626Feature.{protocol_slug}_like in features:
from eth_defi.erc_4626.vault_protocol.{protocol_slug}.vault import {ProtocolName}Vault
return {ProtocolName}Vault(web3, spec, token_cache=token_cache, features=features)
Every vault adapter must explicitly declare whether it supports deposits and redemptions. Do not treat ERC-4626 interface detection alone as permission to advertise deposit-manager support: public support requires a complete tested lifecycle.
Determine the flow from the vault contract and protocol documentation:
deposit() / mint() and withdraw() / redeem().None until both directions are implemented and tested.For a standard synchronous ERC-4626 adapter, certify the inherited
ERC4626DepositManager by adding the exact fully-qualified class name to
CERTIFIED_SYNCHRONOUS_DEPOSIT_MANAGER_CLASSES in
eth_defi/erc_4626/vault.py:
"eth_defi.erc_4626.vault_protocol.{protocol_slug}.vault.{ProtocolName}Vault",
The inherited get_deposit_manager() then returns ERC4626DepositManager, and
get_deposit_manager_capability() exports the public fields:
{
"can_deposit": True,
"can_redeem": True,
"deposit_flow": "synchronous",
"redemption_flow": "synchronous",
}
Add a guarded Anvil fork test that uses an unlocked token holder to transfer the
denomination token to an Anvil account, approves the vault, deposits through
vault.get_deposit_manager(), and redeems the exact minted share balance. Assert
that the manager is ERC4626DepositManager, both flow methods are synchronous,
the public capability fields match the schema above, and the final share balance
is zero.
Add or update a no-RPC unit test for the exact-class allowlist. This prevents a future refactor from silently removing the advertised capability when RPC-backed tests are skipped.
Reference implementations:
eth_defi/erc_4626/deposit_redeem.py and eth_defi/erc_4626/vault.pytests/erc_4626/test_4626_deposit_redeem.pytests/erc_4626/vault_protocol/test_kiln.pytests/erc_4626/test_deposit_probe.pyeth_defi/erc_4626/vault_protocol/gains/ and
eth_defi/erc_4626/vault_protocol/upshift/vault.pyUpdate eth_defi/vault/risk.py with the protocol stub.
Set the initial risk level for the protocol in VAULT_PROTOCOL_RISK_MATRIX.
USe None if not given and this will be later updated by human judgement.
Update eth_defi/vault/fee.py with the protocol stub.
Set VAULT_PROTOCOL_FEE_MATRIX to None for newly added protocol.
Match get_vault_protocol_name() for the protocol name spelling.
Create eth_defi/data/vaults/metadata/{protocol-slug}.yaml.
eth_defi/data/vaults/README.md as the schema referencetrading_strategy and integration_documentation links even if the Trading Strategy listing is not live yetlinks mapping.Validate that the metadata can be parsed:
poetry run python - <<'PY'
from pathlib import Path
from eth_defi.vault.protocol_metadata import build_metadata_json
print(build_metadata_json(Path("eth_defi/data/vaults/metadata/{protocol-slug}.yaml"), "https://example.invalid")["name"])
PY
Protocol logos are required for vault protocol metadata and frontend listings. Do not skip this step unless no official or defensible logo source can be found after following the logo extraction workflow; if skipped, document why in the final response and in the logo README.
extract-vault-protocol-logo skill..claude/skills/extract-vault-protocol-logo/SKILL.mdeth_defi/data/vaults/metadata/{protocol-slug}.yamleth_defi/data/vaults/original_logos/{protocol-slug}/README.md in the original logo folder documenting sources and choicespost-process-logo skill..claude/skills/post-process-logo/SKILL.mdeth_defi/data/vaults/formatted_logos/{protocol-slug}/light.png, a light-coloured logo for dark frontend
backgrounds. This is the required listing-logo variant.dark.png when the source supports a distinct dark-coloured
variant for light backgrounds; otherwise document why it is unavailable.poetry run python - <<'PY'
from pathlib import Path
from eth_defi.vault.protocol_metadata import build_metadata_json
metadata = build_metadata_json(Path("eth_defi/data/vaults/metadata/{protocol-slug}.yaml"), "https://example.invalid")
print(metadata["logos"])
PY
New Anvil mainnet-fork characterisation tests must use the shared Anvil fork
anvil_fork_pool fixture,
chain *_MIDNIGHT_BLOCK constant, xdist_group marker). Do not launch a
per-file fork_network_anvil at latest or an ad-hoc block โ that is
non-reproducible, unshareable and defeats the CI RPC cache. The canonical,
authoritative description of the pattern (rationale + how-to + copy-paste
skeleton) lives in the module docstring of
eth_defi/testing/anvil_fork_pool.py โ read it before writing the test.Create tests/erc_4626/vault_protocol/test_{protocol_slug}.py following the
reference tests tests/erc_4626/vault_protocol/test_goat.py and
tests/erc_4626/vault_protocol/test_aarna.py (both read-only pooled forks):
"""Test {Protocol Name} vault metadata"""
import os
from pathlib import Path
import pytest
from web3 import Web3
import flaky
from eth_defi.erc_4626.classification import create_vault_instance_autodetect
from eth_defi.erc_4626.core import ERC4626Feature
from eth_defi.erc_4626.vault_protocol.{protocol_slug}.vault import {ProtocolName}Vault
from eth_defi.testing.anvil_fork_pool import AnvilForkPool
from eth_defi.testing.fork_blocks import {CHAIN}_MIDNIGHT_BLOCK
from eth_defi.vault.base import VaultTechnicalRisk
JSON_RPC_{CHAIN} = os.environ.get("JSON_RPC_{CHAIN}")
pytestmark = [
pytest.mark.skipif(JSON_RPC_{CHAIN} is None, reason="JSON_RPC_{CHAIN} needed to run these tests"),
# Co-locate every same-block {chain} sharer on one xdist worker so they
# reuse a single Anvil process under --dist loadgroup.
pytest.mark.xdist_group("fork:{chain}:midnight"),
]
@pytest.fixture(scope="module")
def web3(anvil_fork_pool: AnvilForkPool) -> Web3:
"""Web3 backed by a shared {chain} fork from the session-scoped pool.
Read-only test: shares one Anvil fork, so no snapshot/revert reset is
needed between tests.
"""
return anvil_fork_pool.get_web3(JSON_RPC_{CHAIN}, {CHAIN}_MIDNIGHT_BLOCK)
@flaky.flaky
def test_{protocol_slug}(
web3: Web3,
tmp_path: Path,
):
"""Read {Protocol Name} vault metadata"""
vault = create_vault_instance_autodetect(
web3,
vault_address="{vault_address}",
)
assert isinstance(vault, {ProtocolName}Vault)
assert vault.get_protocol_name() == "{Protocol Name}"
# Add assertation about vault feature flags here, like:
# assert vault.features == {ERC4626Feature.goat_like}
# Add assertions for fee data we know
# assert vault.get_management_fee("latest") == ...
# assert vault.get_performance_fee("latest") == ...
# Add assertion for the protcol risk level
# assert vault.get_risk() == VaultTechnicalRisk.unknown
*_MIDNIGHT_BLOCK constant from eth_defi/testing/fork_blocks.py. If the
chain has no constant yet, add one (see the fork_blocks.py module docstring)
or, for a chain without archive history (e.g. Monad), fall back to a
state-relative assertion per CLAUDE.md.After adding it, run the test module and fix any issues.
Create eth_defi/erc_4626/vault_protocol/{protocol_slug}/__init__.py:
"""{Protocol Name} protocol integration."""
docs/source/vaultsdocs/source/vaults/index.rstdocs/source/api/{protocol_slug}/index.rstdocs/source/api/index.rstdocs/source/vaults/index.rstDo not run a Sphinx documentation build as part of this skill. Verifying the new documentation source files, their index references, and metadata parsing is sufficient; documentation builds are handled separately by the project CI.
Examples include
docs/source/vaults/plutus/index.rst, docs/source/vaults/truefi/index.rst, docs/source/api/vaults/index.rst,Check that all ERC-4626 tests pass after adding a new vault protocol by running all testse in tests/erc_4626/vault_protocol folder.
Run all vault testes:
source .local-test.env && poetry run pytest -n auto -k vault_protocol
Fix any issues if found.
Format the newly added files with poetry run ruff format.
Create a feed YAML file at eth_defi/data/feeds/protocols/{protocol-slug}.yaml so the protocol's social media posts are collected by the feed scanner. For full schema documentation and collection behaviour details, see eth_defi/feed/README-feed.md.
Use the protocol slug with dashes (not underscores). E.g. lagoon-finance, ipor-fusion, goat-protocol.
The file should follow this format:
feeder-id: {protocol-slug}
name: {Protocol Name}
role: protocol
website: {homepage URL}
twitter: {twitter handle without @}
linkedin: {linkedin company slug}
rss: {RSS or Atom feed URL}
To fill the fields:
.yaml)get_vault_protocol_name() outputprotocol for vault protocols@ โ find on the protocol homepage. If no Twitter found, omit the field.linkedin.com/company/). Find via web search for "{protocol name}" site:linkedin.com/company. If not found, omit the field.https://medium.com/feed/@{handle} or https://medium.com/feed/{publication}https://{name}.substack.com/feed<link rel="alternate" type="application/rss+xml"> in page source# rss: not found โ {reason}Example (simple):
feeder-id: plutus
name: Plutus
role: protocol
website: https://plutus.fi/
twitter: plutus_fi_x
rss: https://medium.com/feed/@plutus.fi
Example (no RSS):
feeder-id: lagoon-finance
name: Lagoon Finance
role: protocol
website: https://lagoon.finance/
twitter: lagoon_finance
linkedin: lagoon-finance
# rss: not found โ blog is at lagoon.finance/blog but has no RSS feed
After implementation, verify:
eth_defi/abi/{protocol_slug}/, or the protocol is intentionally using HARDCODED_PROTOCOLSERC4626VaultERC4626Feature enum has the new protocolget_vault_protocol_name() returns the correct namecreate_probe_calls() has a unique probe for the protocol, or the protocol is intentionally using HARDCODED_PROTOCOLSidentify_vault_features() or HARDCODED_PROTOCOLS correctly identifies the protocolcreate_vault_instance() creates the correct vault classsource .local-test.env && poetry run pytest tests/erc_4626/vault_protocol/test_{protocol_slug}.py -veth_defi/data/vaults/original_logos/{protocol-slug}/eth_defi/data/vaults/formatted_logos/{protocol-slug}/formatted_logos/{protocol-slug}/light.png exists and is a light-coloured logo suitable for dark backgroundseth_defi/data/feeds/protocols/{protocol-slug}.yamlcategorise-vault-strategy skill and has an evidence-based strategy tag
mapping, or is deliberately left unmapped for the explicit
missing-information resultIf there are problems with the checklist, ask for human assistance.
CHANGELOG.md and add a note of added new protocolAfter everything is done, open a pull request, but only if the user asks you to.
gh pr create \
--title "Add new vault protocol: {protocol name}" \
--body $'Protocol: {protocok name}\nHomepage: {homepage link}\nGithub: {github link}\nDocs: {docs link}\nExample contract: {blockchain explorer link}" \
--base master
To find a function that uniquely identifies the protocol:
Read the ABI and look for:
SAY_TRADER_ROLE() for Plutus)getPerformanceFeeData() for IPOR)MORPHO() for Morpho)Verify the function is truly unique by checking it doesn't exist in other protocols
Some protocols may need name-based detection if no unique function exists:
name = calls["name"].result
if name:
name = name.decode("utf-8", errors="ignore")
if "ProtocolName" in name:
features.add(ERC4626Feature.{protocol_slug}_like)
The ABI JSON file should contain the contract's ABI array. Example:
{
"abi": [
{
"inputs": [],
"name": "totalAssets",
"outputs": [{ "type": "uint256" }],
"stateMutability": "view",
"type": "function"
}
]
}
Or just the array directly:
[
{
"inputs": [],
"name": "totalAssets",
"outputs": [{ "type": "uint256" }],
"stateMutability": "view",
"type": "function"
}
]