vault_report.vault_probes

Documentation for eth_defi.vault_report.vault_probes Python module.

Deterministic onchain and API facts for the vault investability check.

The investability check, see eth_defi.vault_report.vault_checks, uses an LLM agent for judgement, but reads everything that code can read reliably here first:

  • where a vault’s money is: for Morpho V1 the markets on its withdraw queue, for Euler Earn its strategies, with each market’s collateral token, oracle, loan-to-value and free liquidity;

  • how much a depositor could redeem right now: idle assets plus the liquidity the vault can pull from its markets or strategies, not only idle assets, see eth_defi/erc_4626/vault_protocol/README-vault-redeemable.md;

  • DEX liquidity of each collateral token, from DexScreener;

  • liquidity, utilisation and served withdrawals over the last 30 days, from the report’s vault price Parquet.

The facts also raise deterministic suspicion signals. A signal never excludes a vault by itself: it marks the vault for the agent to research. The thresholds are investigation triggers from the plan, not calibrated verdicts.

Supported probes: Morpho V1 (MetaMorpho), Morpho V2 (idle assets only, its adapters are not read yet), Euler Earn, Euler EVK and 40acres. Other protocols get unsupported.

Who uses the facts:

  • eth_defi.vault_report.vault_checks writes them to vault-check-facts-N.json for the agent, and the deterministic prescreen of the average yield charts uses only their signals;

  • the agent and humans probe a single vault with scripts/erc-4626/probe-vault-positions.py.

Design notes:

  • All onchain reads of one vault use the same block, the chain head at probe time, so positions and totals are consistent with each other. The facts describe the vault now, not at the report’s data date, which is what an exit liquidity judgement needs. Reading the head also works on Monad, whose nodes keep only recent state.

  • A failed read never aborts the report: it is recorded in VaultFacts.errors for the agent to see, and the rest of the facts are still filled in where possible.

  • Reads are plain eth_call requests through create_multi_provider_web3() with the JSON_RPC_* environment variables, one vault per thread. With a few dozen candidates per round this is fast enough without Multicall.

Module Attributes

SUSPICIOUS_POSITION_SHARE

A position at least this large, as a share of the vault's assets, is checked for suspicious collateral; smaller positions cannot move a vault's yield or solvency much

MIN_COLLATERAL_DEX_LIQUIDITY_USD

Collateral with less DEX liquidity than this, in US dollars, has no practical market: liquidators could not sell it, so the loans against it cannot be valued at the oracle price.

MIN_REDEEMABLE_SHARE

Redeemable liquidity below this share of the vault's assets raises an exit liquidity signal

LOW_LIQUIDITY_DAYS

Days the liquidity must have stayed low to raise the historical exit liquidity signal; long enough that a temporary full utilisation does not trigger it

DEXSCREENER_CHAINS

DexScreener chain slugs by chain id, see the chainId values of the DexScreener API; chains missing here get no DEX liquidity figure

EVK_ABI

Euler EVK functions not in the committed ABIs: cash(), the assets not lent out, and LTVList(), the collateral vaults the EVK vault accepts.

CALL_ERRORS

Exceptions an individual contract read may raise when a contract does not implement a function: a revert, empty return data, an ABI decoding failure or a token without ERC-20 metadata.

Functions

calculate_liquidity_history(prices_path, ...)

Summarise liquidity, utilisation and served withdrawals over recent days.

facts_to_json(facts)

Convert facts to plain JSON data.

fetch_candidate_facts(candidates, ...[, ...])

Read facts for all in-scope candidates in parallel.

fetch_dex_liquidity_usd(chain_id, token[, ...])

Read the largest DEX pool liquidity of a token from DexScreener.

fetch_euler_earn_facts(web3, vault_address, ...)

Read the strategies of an Euler Earn vault.

fetch_morpho_v1_facts(web3, vault_address, ...)

Read the markets of a MetaMorpho V1 vault.

fetch_simple_pool_facts(web3, vault_address, ...)

Read a single-pool lending vault: Euler EVK (cash()) or 40acres (idle balance).

fetch_vault_facts(vault_id, protocol_slug, ...)

Read the onchain facts of one vault.

raise_signals(facts)

Deterministic suspicion signals for the agent to research.

select_probe(protocol_slug, features)

Pick the probe for a vault.

Classes

Exposure

One place a vault's assets are: a Morpho market, an Euler strategy or one of its collaterals.

VaultFacts

Deterministic facts about one candidate vault.

SUSPICIOUS_POSITION_SHARE: float = 0.1

A position at least this large, as a share of the vault’s assets, is checked for suspicious collateral; smaller positions cannot move a vault’s yield or solvency much

MIN_COLLATERAL_DEX_LIQUIDITY_USD = 50000

Collateral with less DEX liquidity than this, in US dollars, has no practical market: liquidators could not sell it, so the loans against it cannot be valued at the oracle price. The skill and its thresholds must stay in sync with these constants.

MIN_REDEEMABLE_SHARE: float = 0.01

Redeemable liquidity below this share of the vault’s assets raises an exit liquidity signal

LOW_LIQUIDITY_DAYS = 14

Days the liquidity must have stayed low to raise the historical exit liquidity signal; long enough that a temporary full utilisation does not trigger it

DEXSCREENER_CHAINS = {1: 'ethereum', 10: 'optimism', 56: 'bsc', 100: 'gnosischain', 130: 'unichain', 137: 'polygon', 143: 'monad', 146: 'sonic', 999: 'hyperevm', 8453: 'base', 9745: 'plasma', 42161: 'arbitrum', 43114: 'avalanche', 59144: 'linea', 80094: 'berachain', 747474: 'katana'}

DexScreener chain slugs by chain id, see the chainId values of the DexScreener API; chains missing here get no DEX liquidity figure

EVK_ABI = [{'name': 'cash', 'type': 'function', 'stateMutability': 'view', 'inputs': [], 'outputs': [{'type': 'uint256'}]}, {'name': 'LTVList', 'type': 'function', 'stateMutability': 'view', 'inputs': [], 'outputs': [{'type': 'address[]'}]}]

Euler EVK functions not in the committed ABIs: cash(), the assets not lent out, and LTVList(), the collateral vaults the EVK vault accepts. The ABI guide in eth_defi/abi/README.md allows an inline fragment of at most two functions.

CALL_ERRORS = (<class 'web3.exceptions.ContractLogicError'>, <class 'web3.exceptions.BadFunctionCallOutput'>, <class 'ValueError'>, <class 'eth_defi.token.TokenDetailError'>)

Exceptions an individual contract read may raise when a contract does not implement a function: a revert, empty return data, an ABI decoding failure or a token without ERC-20 metadata. Caught narrowly so a missing function on one contract is a recorded fact, not a crash.

class Exposure

Bases: object

One place a vault’s assets are: a Morpho market, an Euler strategy or one of its collaterals.

Written to the facts file under exposures; the agent starts its collateral research from these, and raise_signals() checks them.

market: str

Morpho market id (0x-prefixed bytes32), or the Euler strategy or EVK vault address

kind: str

morpho_market, euler_strategy, euler_collateral, or idle for a Morpho market without collateral, which curators use to hold idle assets

assets: float

The vault’s assets in this position, in denomination token units; 0 for euler_collateral, whose exposure is not known

share_of_assets: float

Share of the vault’s total assets; for euler_collateral the share of the whole EVK vault, an upper bound

redeemable: Optional[float]

Assets the vault could withdraw from this position now, in denomination token units

utilisation: Optional[float]

Market utilisation, borrowed over supplied

collateral: Optional[eth_typing.evm.HexAddress]

Collateral token address, if any

collateral_symbol: Optional[str]

Collateral token symbol

collateral_name: Optional[str]

Collateral token name

oracle: Optional[eth_typing.evm.HexAddress]

Price oracle address of the market

lltv: Optional[float]

Liquidation loan-to-value, as a fraction (Morpho stores it scaled by 1e18)

collateral_underlying: Optional[eth_typing.evm.HexAddress]

When the collateral is itself an ERC-4626 vault share, the vault’s underlying asset; DEX liquidity is then measured for the underlying asset

collateral_dex_liquidity_usd: Optional[float]

Largest DEX liquidity of the collateral, or of its underlying asset, in US dollars; None when unknown

__init__(market, kind, assets, share_of_assets, redeemable=None, utilisation=None, collateral=None, collateral_symbol=None, collateral_name=None, oracle=None, lltv=None, collateral_underlying=None, collateral_dex_liquidity_usd=None)
Parameters
Return type

None

class VaultFacts

Bases: object

Deterministic facts about one candidate vault.

One entry of the facts file the agent reads. The agent cites the file, with observed_at, as evidence, so the observation time must be the time of the reads.

vault_id: str

Vault id, {chain_id}-{address}

probe: str

Probe used, e.g. morpho_v1, or unsupported, see select_probe()

block_number: Optional[int]

Block the onchain reads used, the chain head when the probe started

observed_at: Optional[str]

When the facts were read, naive UTC ISO timestamp; liquidity evidence older than seven days before the data date is rejected, see eth_defi.vault_report.vault_checks.read_check_decisions()

total_assets: Optional[float]

Total assets in denomination token units

idle_assets: Optional[float]

Assets held idle by the vault: its own denomination token balance, or the cash() of an Euler EVK vault

redeemable_assets: Optional[float]

Assets a depositor could redeem now: idle plus what the vault can pull from its positions

redeemable_share: Optional[float]

Redeemable assets as a share of total assets

exposures: list[eth_defi.vault_report.vault_probes.Exposure]

Where the assets are

history: dict

Liquidity and withdrawal history, see calculate_liquidity_history()

signals: list[str]

Deterministic suspicion signals for the agent to research

errors: list[str]

Reads that failed, or facts the probe cannot read; the agent must not take a missing figure as a good one

__init__(vault_id, probe, block_number=None, observed_at=None, total_assets=None, idle_assets=None, redeemable_assets=None, redeemable_share=None, exposures=<factory>, history=<factory>, signals=<factory>, errors=<factory>)
Parameters
Return type

None

fetch_dex_liquidity_usd(chain_id, token, timeout=20.0)

Read the largest DEX pool liquidity of a token from DexScreener.

Calls the public, keyless GET /token-pairs/v1/{chainId}/{tokenAddress} endpoint, see the DexScreener API. It is rate limited per IP, which is why callers look each token up only once per run, see fetch_vault_facts().

The largest single pool, not the sum over pools, is returned: it is what a liquidator could sell into in one place, and it is not inflated by many dust pools. The result separates “no market” (0, which can raise a signal) from “unknown” (None, which never does), so an API outage cannot make a vault look suspicious.

Parameters
Returns

Largest pool liquidity in US dollars, 0 when the token has no pools, None when the chain is not on DexScreener or the lookup failed.

Return type

Optional[float]

fetch_morpho_v1_facts(web3, vault_address, chain_id, block)

Read the markets of a MetaMorpho V1 vault.

For each market on the withdraw queue: the vault’s supply position, the market’s free liquidity, utilisation, collateral token, oracle and LLTV. The vault can redeem the smaller of its position and the market’s free liquidity from each market. The withdraw queue is read because a MetaMorpho vault can only pull assets from the markets on it.

Contract reads, all at block:

  • vault MORPHO(), withdrawQueueLength() and withdrawQueue(i);

  • Morpho Blue idToMarketParams(id) for the loan and collateral tokens, oracle and LLTV; market(id) for the total supply and borrow assets and supply shares; position(id, vault) for the vault’s supply shares.

The vault’s position is its supply shares converted at the market’s stored totals, without the interest accrued since the market’s last update, so it can be slightly low. Free liquidity is total supply minus total borrow. A market without a collateral token is an idle market and is reported with kind idle.

See the MetaMorpho documentation.

Parameters
  • web3 (web3.main.Web3) – Web3 connection of the vault’s chain.

  • vault_address (eth_typing.evm.HexAddress) – Vault address.

  • chain_id (int) – Chain id of the vault.

  • block (int) – Block number to read at, so all reads are consistent.

Returns

Exposures, total assets and idle assets, in denomination token units.

Return type

tuple[list[eth_defi.vault_report.vault_probes.Exposure], float, float]

fetch_euler_earn_facts(web3, vault_address, chain_id, block)

Read the strategies of an Euler Earn vault.

Each strategy is an ERC-4626 vault, usually an Euler EVK lending vault. maxWithdrawFromStrategy gives what the Earn vault can pull from it now.

Contract reads, all at block: the Earn vault’s withdrawQueueLength() and withdrawQueue(i); each strategy’s balanceOf(earn vault) and convertToAssets() for the position; the Earn vault’s maxWithdrawFromStrategy(strategy), or the strategy’s maxWithdraw(earn vault) on versions without it, for the redeemable amount. For each EVK strategy, its collateral tokens are listed too, see _fetch_euler_collateral().

See the Euler Earn documentation.

Parameters
  • web3 (web3.main.Web3) – Web3 connection of the vault’s chain.

  • vault_address (eth_typing.evm.HexAddress) – Vault address.

  • chain_id (int) – Chain id of the vault.

  • block (int) – Block number to read at, so all reads are consistent.

Returns

Exposures, total assets and idle assets, in denomination token units.

Return type

tuple[list[eth_defi.vault_report.vault_probes.Exposure], float, float]

fetch_simple_pool_facts(web3, vault_address, chain_id, block, cash_function)

Read a single-pool lending vault: Euler EVK (cash()) or 40acres (idle balance).

An Euler EVK vault’s redeemable liquidity is its cash(), the assets not lent out; other single-pool vaults report their idle balance. Also used as a lower bound for Morpho V2 vaults, whose adapters are not probed yet.

For a 40acres pool the free liquidity depends on loan repayments, so the current idle balance alone says little; raise_signals() also uses its 14-day history from the price Parquet.

Parameters
  • web3 (web3.main.Web3) – Web3 connection of the vault’s chain.

  • vault_address (eth_typing.evm.HexAddress) – Vault address.

  • chain_id (int) – Chain id of the vault.

  • block (int) – Block number to read at, so all reads are consistent.

  • cash_function (bool) – Read redeemable liquidity with the EVK cash() function and list the EVK collateral tokens.

Returns

Exposures, total assets and redeemable assets in denomination token units: with cash_function, the EVK’s collateral tokens at the whole vault’s share and cash(); otherwise no exposures and the idle balance.

Return type

tuple[list[eth_defi.vault_report.vault_probes.Exposure], float, float]

select_probe(protocol_slug, features)

Pick the probe for a vault.

One protocol slug covers several contract families, so the scanner’s detected ERC-4626 features choose between them: morpho_v2_like separates Morpho V2 from MetaMorpho V1, and euler_earn_like separates Euler Earn aggregators from EVK lending pools.

Parameters
  • protocol_slug (str) – Protocol slug from the vault metadata.

  • features (Optional[list[str]]) – ERC-4626 feature names from the vault metadata, as in the top vaults JSON.

Returns

Probe name.

Return type

str

calculate_liquidity_history(prices_path, vault_ids, end_at, days=30)

Summarise liquidity, utilisation and served withdrawals over recent days.

The probes see one moment; this history shows whether low liquidity is lasting and whether depositors actually got out. TVL changes include deposits and returns, so withdrawals are measured from the scanner’s withdrawal counters instead.

Only the needed columns and rows are read: the Parquet filters push the vault ids and the window down to the reader, which matters for a file of several hundred megabytes. The hourly rows are resampled to the last value of each day. idle_share_max_14d is the highest daily available_liquidity / total_assets over the last LOW_LIQUIDITY_DAYS days with data: if even the maximum is below the threshold, liquidity stayed low for the whole period.

Parameters
  • prices_path (pathlib.Path) – Vault price Parquet with available_liquidity, utilisation, total_assets, daily_withdrawal_count and daily_withdrawal_usd columns.

  • vault_ids (list[str]) – Vaults to summarise.

  • end_at (datetime.datetime) – End of the window.

  • days (int) – Window length.

Returns

Vault id -> summary with days_with_data, idle_share_max_14d, utilisation_median, withdrawal_count and withdrawal_usd; a figure is None when its column has no data in the window. Vaults without rows in the window are missing.

Return type

dict[str, dict]

raise_signals(facts)

Deterministic suspicion signals for the agent to research.

Signals are triggers, never verdicts: the agent decides, and a known asset with another price source, such as a tokenised fund with a primary-market NAV, may have no DEX market and still be fine. In the prescreen of the average yield charts a vault with any signal is escalated to the agent, so a missing figure must not raise one.

The signals:

  • Illiquid collateral. A position of at least SUSPICIOUS_POSITION_SHARE of the assets lent against a collateral token, or its underlying asset, with less than MIN_COLLATERAL_DEX_LIQUIDITY_USD of DEX liquidity. Unknown DEX liquidity raises nothing. Duplicates are dropped, because an Euler Earn vault can reach the same collateral through several strategies.

  • No redeemable liquidity now. Redeemable assets below MIN_REDEEMABLE_SHARE of the total.

  • Lasting low liquidity, 40acres only. Free liquidity below MIN_REDEEMABLE_SHARE on every day of the last LOW_LIQUIDITY_DAYS days. For 40acres pools the price Parquet’s available liquidity is the idle balance, which is what a depositor can redeem. For Morpho and Euler Earn it is only the vault’s own idle balance, not what it can pull from its markets, see eth_defi/erc_4626/vault_protocol/README-vault-redeemable.md, so it would raise false signals.

Parameters

facts (eth_defi.vault_report.vault_probes.VaultFacts) – Facts with exposures and history filled in.

Returns

Human-readable signals; empty when nothing looks suspicious.

Return type

list[str]

fetch_vault_facts(vault_id, protocol_slug, features, dex_cache)

Read the onchain facts of one vault.

Runs the probe chosen by select_probe() at the current chain head, then derives the vault-wide redeemable liquidity:

  • Morpho V1: idle balance plus what each withdraw-queue market can return;

  • Euler Earn: idle balance plus what each strategy can return; the euler_collateral exposures hold no assets of their own and are not added;

  • Euler EVK: cash(); 40acres: the idle balance;

  • Morpho V2: unknown, recorded in errors, because its adapters are not read yet.

Then each collateral token’s DEX liquidity is looked up on DexScreener. A collateral that is itself an ERC-4626 vault share, e.g. a Morpho vault token, has no DEX pools of its own, so its underlying asset is looked up instead. That read is at the latest block, not block, which is harmless for a vault’s immutable asset().

Signals are not raised here: they also need the liquidity history, which fetch_candidate_facts() adds for all vaults at once.

Parameters
  • vault_id (str) – {chain_id}-{address}.

  • protocol_slug (str) – Protocol slug.

  • features (Optional[list[str]]) – ERC-4626 feature names.

  • dex_cache (dict) – Shared (chain_id, token) -> liquidity cache, so each collateral token is looked up once. Shared between the probe threads without a lock: a race only costs a duplicate lookup.

Returns

Facts; failed reads are recorded in errors instead of raising.

Return type

eth_defi.vault_report.vault_probes.VaultFacts

fetch_candidate_facts(candidates, prices_path, end_at, max_workers=8)

Read facts for all in-scope candidates in parallel.

Called by the investability check for each agent round and for the prescreen of the average yield charts. The work is network-bound (JSON-RPC and DexScreener), so a joblib threading pool runs one vault per thread, with a tqdm progress bar so a slow RPC is visible in the log. After the probes, the liquidity history is read from the price Parquet in one pass for all vaults, and the signals are raised.

Parameters
  • candidates (list[dict]) – Candidate records with vault_id, protocol_slug and features, e.g. CheckCandidate as a dict.

  • prices_path (Optional[pathlib.Path]) – Vault price Parquet for the liquidity history, or None to skip it.

  • end_at (datetime.datetime) – Report data date.

  • max_workers (int) – Parallel threads, MAX_WORKERS in the scripts.

Returns

Vault id -> facts.

Return type

dict[str, eth_defi.vault_report.vault_probes.VaultFacts]

facts_to_json(facts)

Convert facts to plain JSON data.

Parameters

facts (dict[str, eth_defi.vault_report.vault_probes.VaultFacts]) – Output of fetch_candidate_facts().

Returns

Vault id -> fact dictionary.

Return type

dict