From 0533debae9b467e4eb3588becf7b172059fcc80a Mon Sep 17 00:00:00 2001 From: spalen0 Date: Mon, 17 Aug 2026 21:24:10 +0200 Subject: [PATCH 1/3] Add Infinifi escrow context to LLM reports --- protocols/infinifi/README.md | 2 + tests/test_ai_explainer.py | 21 ++ tests/test_infinifi_context.py | 194 ++++++++++++++++ tests/test_llm_report.py | 7 + utils/llm/README.md | 13 ++ utils/llm/ai_explainer.py | 46 +++- utils/llm/infinifi_context.py | 408 +++++++++++++++++++++++++++++++++ utils/llm/report.py | 9 +- 8 files changed, 693 insertions(+), 7 deletions(-) create mode 100644 tests/test_infinifi_context.py create mode 100644 utils/llm/infinifi_context.py diff --git a/protocols/infinifi/README.md b/protocols/infinifi/README.md index 11b9d950..f950e6cb 100644 --- a/protocols/infinifi/README.md +++ b/protocols/infinifi/README.md @@ -65,6 +65,8 @@ Governance monitoring will be monitored via Tenderly alerts on the following add - `TIMELOCK_SHORT`: [`0x4B174afbeD7b98BA01F50E36109EEE5e6d327c32`](https://etherscan.io/address/0x4B174afbeD7b98BA01F50E36109EEE5e6d327c32) - `TIMELOCK_LONG`: [`0x3D18480CC32B6AB3B833dCabD80E76CfD41c48a9`](https://etherscan.io/address/0x3D18480CC32B6AB3B833dCabD80E76CfD41c48a9) +For RWA escrow governance calls, the linked AI report resolves the owning farm, accounting asset, normalized `totalAssets`, and configured non-accounting ERC20 targets into a deterministic `Protocol Context` section. + **Deployer Address**: - `0xdecaDAc8778D088A30eE811b8Cc4eE72cED9Bf22` diff --git a/tests/test_ai_explainer.py b/tests/test_ai_explainer.py index 3a6eb7db..f1a70617 100644 --- a/tests/test_ai_explainer.py +++ b/tests/test_ai_explainer.py @@ -334,6 +334,27 @@ def test_sole_token_map_skips_ambiguous_targets(self) -> None: self.assertEqual(mapping, {"0xaaa": one}) +class TestProtocolContextSection(unittest.TestCase): + """Protocol adapters can add verified facts to the prompt.""" + + def test_section_included(self) -> None: + calls = [DecodedCall(function_name="setRate", signature="setRate(address,uint256)")] + result = _build_prompt( + target="0xT", + value=0, + decoded_calls=calls, + simulation=None, + protocol_context="Farm: New Silver 2 Senior\nAccounting asset: USDC", + ) + self.assertIn("--- Protocol Context", result) + self.assertIn("Farm: New Silver 2 Senior", result) + self.assertIn("Accounting asset: USDC", result) + + def test_system_prompt_distinguishes_whitelist_from_accounting_asset(self) -> None: + self.assertIn("Distinguish", SYSTEM_INSTRUCTIONS) + self.assertIn("non-accounting ERC20 targets", SYSTEM_INSTRUCTIONS) + + class TestCollectUniqueAddresses(unittest.TestCase): """Targets and address args are gathered once, deduped, checksummed.""" diff --git a/tests/test_infinifi_context.py b/tests/test_infinifi_context.py new file mode 100644 index 00000000..f9a7b67e --- /dev/null +++ b/tests/test_infinifi_context.py @@ -0,0 +1,194 @@ +"""Tests for Infinifi-specific LLM farm and token enrichment.""" + +import unittest +from unittest.mock import MagicMock, patch + +from utils.calldata.decoder import DecodedCall +from utils.erc20_metadata import ERC20Metadata +from utils.llm import infinifi_context +from utils.llm.infinifi_context import ( + InfinifiEscrowContext, + TokenContext, + _EscrowState, + _farm_matches_escrow, + _FarmRecord, + _fetch_whitelist_targets, + _resolve_configured_tokens, + _TokenCandidate, + format_infinifi_prompt, + format_infinifi_report, + resolve_infinifi_context, +) + +MANAGER = "0x11F6FAb3f4D8635880C3e80cbae8AEF8136D4189" +ESCROW = "0x6439eb9DADC7977BC1ADC027B10Fb1749AF869A5" +FARM = "0x79e1B8e45932A7C802eA3dAb3844e5DEa68d971f" +USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" +DROP = "0xE4C72b4dE5b0F9ACcEA880Ad0b1F944F85A9dAA0" + + +def _set_rate_call() -> DecodedCall: + return DecodedCall( + function_name="setRate", + signature="setRate(address,uint256)", + params=[("address", ESCROW), ("uint256", 1_067_660_000_000_000_000)], + ) + + +def _resolved_context() -> InfinifiEscrowContext: + return InfinifiEscrowContext( + escrow_address=ESCROW, + farm_address=FARM, + farm_name="New Silver 2 Senior", + farm_slug="new-silver-senior", + accounting_asset=TokenContext(USDC, "USD Coin", "USDC", 6), + total_assets_raw=3_003_294_554_623, + configured_tokens=( + TokenContext( + DROP, + "New Silver Series 2 DROP", + "NS2DRP", + 18, + ), + ), + ) + + +class TestResolveInfinifiContext(unittest.TestCase): + def setUp(self) -> None: + infinifi_context.reset_cache() + + @patch.object(infinifi_context, "_resolve_configured_tokens") + @patch.object(infinifi_context, "_farm_matches_escrow", return_value=True) + @patch.object(infinifi_context, "_read_token") + @patch.object(infinifi_context, "_fetch_farm_records") + @patch.object(infinifi_context, "_read_escrow_state") + def test_resolves_farm_accounting_asset_and_configured_token( + self, + mock_escrow: MagicMock, + mock_farms: MagicMock, + mock_token: MagicMock, + _mock_relationship: MagicMock, + mock_configured_tokens: MagicMock, + ) -> None: + state = _EscrowState(ESCROW, FARM, USDC, 3_003_294_554_623) + mock_escrow.side_effect = lambda _chain, address: state if address.lower() == ESCROW.lower() else None + mock_farms.return_value = (_FarmRecord(FARM, "New Silver 2 Senior", "new-silver-senior"),) + mock_token.return_value = TokenContext(USDC, "USD Coin", "USDC", 6) + mock_configured_tokens.return_value = _resolved_context().configured_tokens + + result = resolve_infinifi_context("INFINIFI", 1, [(MANAGER, _set_rate_call())]) + + self.assertEqual(result, [_resolved_context()]) + self.assertIn(DROP, result[0].addresses) + self.assertIn("New Silver Series 2 DROP", result[0].labels[DROP]) + + @patch.object(infinifi_context, "_read_escrow_state") + def test_skips_other_protocols_without_lookups(self, mock_read: MagicMock) -> None: + self.assertEqual(resolve_infinifi_context("AAVE", 1, [(MANAGER, _set_rate_call())]), []) + mock_read.assert_not_called() + + @patch.object(infinifi_context, "_read_escrow_state", side_effect=RuntimeError("RPC down")) + def test_lookup_failure_does_not_block_alert(self, _mock_read: MagicMock) -> None: + self.assertEqual(resolve_infinifi_context("INFINIFI", 1, [(MANAGER, _set_rate_call())]), []) + + @patch.object(infinifi_context, "_farm_matches_escrow") + @patch.object(infinifi_context, "_read_token", return_value=TokenContext(USDC, "USD Coin", "USDC", 6)) + @patch.object(infinifi_context, "_fetch_farm_records", return_value=()) + @patch.object(infinifi_context, "_read_escrow_state", return_value=_EscrowState(ESCROW, FARM, USDC, 1)) + def test_rejects_escrow_without_infinifi_farm( + self, + _mock_escrow: MagicMock, + _mock_farms: MagicMock, + _mock_token: MagicMock, + mock_relationship: MagicMock, + ) -> None: + self.assertEqual(resolve_infinifi_context("INFINIFI", 1, [(MANAGER, _set_rate_call())]), []) + mock_relationship.assert_not_called() + + +class TestFarmRelationship(unittest.TestCase): + @patch.object(infinifi_context.ChainManager, "get_client") + def test_requires_farm_escrow_getter_to_match_candidate(self, mock_client: MagicMock) -> None: + escrow_call = MagicMock() + escrow_call.call.return_value = ESCROW + farm = MagicMock() + farm.functions.escrow.return_value = escrow_call + mock_client.return_value.get_contract.return_value = farm + + self.assertTrue(_farm_matches_escrow(1, FARM, ESCROW)) + self.assertFalse(_farm_matches_escrow(1, FARM, MANAGER)) + + +class TestConfiguredTokenDiscovery(unittest.TestCase): + @patch.object(infinifi_context.ChainManager, "get_client") + def test_reconstructs_current_whitelist_from_events(self, mock_client: MagicMock) -> None: + event_reader = MagicMock() + event_reader.get_logs.return_value = [ + {"args": {"target": DROP, "enabled": True}}, + {"args": {"target": USDC, "enabled": True}}, + {"args": {"target": DROP, "enabled": False}}, + {"args": {"target": DROP, "enabled": True}}, + ] + contract = MagicMock() + contract.events.WhitelistUpdated.return_value = event_reader + mock_client.return_value.get_contract.return_value = contract + + self.assertEqual(_fetch_whitelist_targets(1, ESCROW), [DROP, USDC]) + + @patch.object(infinifi_context, "_read_token") + @patch.object(infinifi_context, "_fetch_whitelist_targets") + def test_keeps_only_non_accounting_erc20_targets( + self, + mock_candidates: MagicMock, + mock_read: MagicMock, + ) -> None: + zero_token = "0x333333330522F64EE8d0b3039c460b41670e3404" + mock_candidates.return_value = [ + USDC, + DROP, + zero_token, + ] + drop = TokenContext(DROP, "New Silver Series 2 DROP", "NS2DRP", 18) + mock_read.side_effect = [drop, None] + + state = _EscrowState(ESCROW, FARM, USDC, 0) + self.assertEqual(_resolve_configured_tokens(1, state), (drop,)) + self.assertEqual(mock_read.call_count, 2) + + +class TestInfinifiContextFormatting(unittest.TestCase): + def test_prompt_names_farm_and_drop_token(self) -> None: + result = format_infinifi_prompt([_resolved_context()]) + self.assertIn("New Silver 2 Senior", result) + self.assertIn("New Silver Series 2 DROP", result) + self.assertIn("3,003,294.554623 USDC", result) + self.assertIn("Configured non-accounting ERC20 target", result) + + def test_report_links_all_context_addresses(self) -> None: + context = _resolved_context() + report = format_infinifi_report([context], 1, context.labels) + self.assertIn("**Farm:** New Silver 2 Senior", report) + self.assertIn(f"https://etherscan.io/address/{FARM}", report) + self.assertIn(f"https://etherscan.io/address/{DROP}", report) + self.assertIn("New Silver Series 2 DROP", report) + self.assertIn("Configured non-accounting ERC-20 targets", report) + + +class TestReadToken(unittest.TestCase): + @patch.object(infinifi_context.ChainManager, "get_client") + @patch.object(infinifi_context, "fetch_erc20_metadata", return_value=ERC20Metadata("NS2DRP", 18)) + def test_reads_name_on_chain(self, _mock_meta: MagicMock, mock_client: MagicMock) -> None: + name_call = MagicMock() + name_call.call.return_value = "New Silver Series 2 DROP" + contract = MagicMock() + contract.functions.name.return_value = name_call + mock_client.return_value.get_contract.return_value = contract + + token = infinifi_context._read_token(1, _TokenCandidate(DROP, "fallback")) + + self.assertEqual(token, TokenContext(DROP, "New Silver Series 2 DROP", "NS2DRP", 18)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_llm_report.py b/tests/test_llm_report.py index f2c57c4f..53065d65 100644 --- a/tests/test_llm_report.py +++ b/tests/test_llm_report.py @@ -264,6 +264,13 @@ def test_sections_in_order(self) -> None: self.assertLess(report.index("## Summary"), report.index("## Call Flow")) self.assertLess(report.index("## Call Flow"), report.index("## Analysis")) + def test_protocol_context_is_deterministic_section_before_analysis(self) -> None: + ctx = _add_farms_ctx(protocol_context="- **Farm:** New Silver 2 Senior") + report = build_report("Updates the rate.", "Long analysis.", ctx, "LOW") + self.assertIn("## Protocol Context\n\n- **Farm:** New Silver 2 Senior", report) + self.assertLess(report.index("## Call Flow"), report.index("## Protocol Context")) + self.assertLess(report.index("## Protocol Context"), report.index("## Analysis")) + def test_metadata_header(self) -> None: report = build_report("Summary.", "Analysis.", _add_farms_ctx(label_address=TIMELOCK), "HIGH") self.assertIn("- **Protocol:** INFINIFI", report) diff --git a/utils/llm/README.md b/utils/llm/README.md index 6611097e..85cd1c54 100644 --- a/utils/llm/README.md +++ b/utils/llm/README.md @@ -172,6 +172,19 @@ Filtering on "is it actually an ERC20" is what makes this need no configuration The system prompt treats **exactly one** resolved token as verified decimals — state the amount and symbol, no hedge. Zero or several tokens keeps the hedge, since normalizing would be a guess. The Call Flow also annotates raw `uint*` values with `(≈ 5,369,214.23 JANE)`, but only above `10 ** (decimals - 3)` so an epoch number like `43` isn't rendered as `0.000000000000000043 JANE`. +### 5e. Infinifi Escrow Context (`utils/llm/infinifi_context.py`) + +Infinifi RWA rate-manager calls target `RWAEscrowRateManager` and pass the affected escrow as an address argument. The generic Related Tokens resolver only inspects the direct call target, so it cannot identify the farm or tokens behind that escrow. + +For Infinifi mainnet alerts, the adapter: + +1. Identifies candidate `RWAEscrow` contracts by their verified ABI (`assetToken()`, `owner()`, and `totalAssets()`). +2. Matches the owner address to the public Infinifi farm API and verifies that the farm's on-chain `escrow()` getter returns the candidate. +3. Reads the accounting asset and current total assets on-chain. +4. Reconstructs the escrow's current whitelist from `WhitelistUpdated` events and identifies non-accounting targets that verify as ERC20 tokens. Token names, symbols, and decimals are read on-chain. + +The result is added to the LLM prompt as verified protocol context and rendered independently in the Wavey Gist under `## Protocol Context`. The report distinguishes the escrow's accounting asset from non-accounting ERC20 targets it is allowed to interact with; whitelist membership does not establish how a token is valued downstream. Failures are best-effort and never block the governance alert. + ### 6. LLM Prompt & Completion (`utils/llm/ai_explainer.py`) The prompt is split into a **system** prompt (static instructions) and a **user** prompt (per-tx context). `complete(prompt, system_prompt=...)` passes the system block via the provider's native system role, which improves instruction-following and lets the Anthropic provider mark it `cache_control: ephemeral` — repeated alerts within the cache window pay for the (large) instruction prompt only once. The static block (`SYSTEM_INSTRUCTIONS`) enforces brevity: diff --git a/utils/llm/ai_explainer.py b/utils/llm/ai_explainer.py index da5eefe8..9cc2875d 100644 --- a/utils/llm/ai_explainer.py +++ b/utils/llm/ai_explainer.py @@ -18,6 +18,11 @@ from utils.impl_diff import diff_implementations, format_impl_diff from utils.llm import get_llm_provider from utils.llm.base import LLMError, LLMProvider +from utils.llm.infinifi_context import ( + format_infinifi_prompt, + format_infinifi_report, + resolve_infinifi_context, +) from utils.llm.report import ( CallEntry, ReportContext, @@ -103,6 +108,10 @@ target, or several candidate tokens with different decimals — say so explicitly rather than guessing. Quote the raw value plus its 1e18-normalized form, and name the candidates when there are several. +- When a Protocol Context section is provided, treat its farm identity, accounting asset, + normalized totalAssets, and configured token targets as verified deterministic facts. Distinguish + the accounting asset from non-accounting ERC20 targets configured in an escrow whitelist; + whitelisting proves permission to interact, but not how a token is valued or used downstream. - Never assign HIGH/CRITICAL risk on the basis of a guessed unit interpretation. - When a Risk Anchors section is provided, treat it as a typical floor/ceiling, not a verdict. Adjust up or down based on the specific parameters (e.g. grantRole of a @@ -866,6 +875,7 @@ def _build_prompt( label: str = "", token_flows: str = "", related_tokens: str = "", + protocol_context: str = "", proxy_upgrade_info: str = "", source_contexts: list[SourceContext] | None = None, context_note: str = "", @@ -930,6 +940,13 @@ def _build_prompt( "are a VERIFIED fact, not an assumption.\n" + related_tokens ) + if protocol_context: + parts.append( + "\n--- Protocol Context (computed from protocol API and live on-chain reads) ---\n" + "These farm, asset, whitelist, and decimal facts are VERIFIED. Distinguish the accounting " + "asset from non-accounting ERC20 targets configured in the escrow whitelist.\n" + protocol_context + ) + if source_contexts: rendered = "\n\n".join(format_source_context(ctx) for ctx in source_contexts) parts.append(f"\n--- Contract Source Context ---\n{rendered}") @@ -1156,7 +1173,8 @@ def _generate_explanation( pass before expansion (~1 extra call). When ``report_ctx`` is given, the detail is wrapped into the full gist page - (metadata header, summary, deterministic call flow, analysis). + (metadata header, summary, deterministic call flow, optional protocol + context, analysis). """ summary_draft = _generate_summary(provider, prompt) if not summary_draft.summary: @@ -1246,6 +1264,10 @@ def explain_transaction( safety_notes = _collect_safety_checks([(target, decoded, value)], chain_id) token_flows = _collect_token_flows([(target, decoded)], chain_id, address_labels) related_tokens = _collect_related_tokens([(target, decoded)], chain_id) + infinifi_contexts = resolve_infinifi_context(protocol, chain_id, [(target, decoded)]) + for context in infinifi_contexts: + for address, context_label in context.labels.items(): + address_labels.setdefault(address, context_label) simulation: SimulationResult | None = None if not skip_simulation: @@ -1268,7 +1290,9 @@ def explain_transaction( else: logger.info("Simulation unavailable, proceeding with decoded calldata only") - address_links = format_address_links_block(collect_unique_addresses([(target, decoded)]), chain_id, address_labels) + context_addresses = [address for context in infinifi_contexts for address in context.addresses] + addresses = list(dict.fromkeys([*collect_unique_addresses([(target, decoded)]), *context_addresses])) + address_links = format_address_links_block(addresses, chain_id, address_labels) prompt = _build_prompt( target=target, @@ -1288,6 +1312,7 @@ def explain_transaction( description=description, address_links=address_links, related_tokens=format_related_tokens_block(related_tokens, address_labels), + protocol_context=format_infinifi_prompt(infinifi_contexts), ) logger.info("Full AI context for %s:\n%s", target, prompt) @@ -1307,6 +1332,7 @@ def explain_transaction( label=label, from_address=from_address, label_address=label_address or from_address, + protocol_context=format_infinifi_report(infinifi_contexts, chain_id, address_labels), ) try: @@ -1413,10 +1439,16 @@ def explain_batch_transaction( safety_notes = _collect_safety_checks(targets_calls_values, chain_id) token_flows = _collect_token_flows(decoded_with_target, chain_id, address_labels) related_tokens = _collect_related_tokens(decoded_with_target, chain_id) + infinifi_contexts = resolve_infinifi_context(protocol, chain_id, decoded_with_target) + for context in infinifi_contexts: + for address, context_label in context.labels.items(): + address_labels.setdefault(address, context_label) targets = ", ".join(c.get("target", "?") for c in calls) total_value = sum(int(c.get("value", "0")) for c in calls) - address_links = format_address_links_block(collect_unique_addresses(decoded_with_target), chain_id, address_labels) + context_addresses = [address for context in infinifi_contexts for address in context.addresses] + addresses = list(dict.fromkeys([*collect_unique_addresses(decoded_with_target), *context_addresses])) + address_links = format_address_links_block(addresses, chain_id, address_labels) prompt = _build_prompt( target=targets, @@ -1436,6 +1468,7 @@ def explain_batch_transaction( description=description, address_links=address_links, related_tokens=format_related_tokens_block(related_tokens, address_labels), + protocol_context=format_infinifi_prompt(infinifi_contexts), ) logger.info("Full AI context for batch (%s calls):\n%s", len(calls), prompt) @@ -1457,6 +1490,7 @@ def explain_batch_transaction( label=label, from_address=from_address, label_address=label_address or from_address, + protocol_context=format_infinifi_report(infinifi_contexts, chain_id, address_labels), ) try: @@ -1475,9 +1509,9 @@ def format_explanation_line(explanation: Explanation) -> str: """Format the AI explanation for inclusion in a Telegram alert message. Uses the short summary for the Telegram message. The full report — metadata, - summary, call flow, and analysis — is uploaded to Wavey Gist and linked. The - bare detail is published instead when no report was built (explanations - generated without report context). + summary, call flow, optional protocol context, and analysis — is uploaded + to Wavey Gist and linked. The bare detail is published instead when no report + was built (explanations generated without report context). """ line = f"\n🤖 *AI Summary:*\n{escape_markdown(explanation.summary)}" if explanation.detail: diff --git a/utils/llm/infinifi_context.py b/utils/llm/infinifi_context.py new file mode 100644 index 00000000..115aa42e --- /dev/null +++ b/utils/llm/infinifi_context.py @@ -0,0 +1,408 @@ +"""Resolve Infinifi farm and configured-token context for governance calls. + +Infinifi's RWA rate manager receives an escrow address, while the useful farm +identity and configured token sit behind that escrow. The generic related-token +resolver only inspects the call target's getters, so it cannot discover this +relationship. + +This adapter is deliberately narrow: it runs only for Infinifi on Ethereum, +identifies RWAEscrow contracts from their verified ABI, reads their accounting +asset and owner on-chain, matches and verifies the owning farm, and resolves +configured non-accounting ERC20 targets from the escrow's whitelist events. +""" + +from dataclasses import dataclass +from functools import lru_cache + +from eth_utils import to_checksum_address + +from utils.calldata.decoder import DecodedCall +from utils.chains import Chain +from utils.erc20_metadata import fetch_erc20_metadata +from utils.formatting import format_decimal_amount, normalize_token_amount +from utils.http_client import fetch_json +from utils.llm.report import address_link, iter_address_values +from utils.logger import get_logger +from utils.source_context import fetch_abi_entries +from utils.web3_wrapper import ChainManager + +logger = get_logger("utils.llm.infinifi_context") + +INFINIFI_API_URL = "https://api.infinifi.xyz/api/protocol/data" +MAX_CANDIDATE_ADDRESSES = 8 + +_ESCROW_GETTERS_ABI = [ + { + "name": "assetToken", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "address"}], + }, + { + "name": "owner", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "address"}], + }, + { + "name": "totalAssets", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + }, +] + +_TOKEN_NAME_ABI = [ + { + "name": "name", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "string"}], + }, +] + +_FARM_ESCROW_ABI = [ + { + "name": "escrow", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "address"}], + } +] + +_WHITELIST_EVENT_ABI = [ + { + "anonymous": False, + "name": "WhitelistUpdated", + "type": "event", + "inputs": [ + {"indexed": True, "name": "timestamp", "type": "uint256"}, + {"indexed": False, "name": "target", "type": "address"}, + {"indexed": False, "name": "enabled", "type": "bool"}, + ], + } +] + + +@dataclass(frozen=True) +class TokenContext: + """Verified token metadata.""" + + address: str + name: str + symbol: str + decimals: int + + +@dataclass(frozen=True) +class InfinifiEscrowContext: + """Resolved context for one Infinifi RWA escrow.""" + + escrow_address: str + farm_address: str + farm_name: str + farm_slug: str + accounting_asset: TokenContext + total_assets_raw: int + configured_tokens: tuple[TokenContext, ...] + + @property + def addresses(self) -> list[str]: + """Addresses introduced by this context for explorer-link generation.""" + return [ + self.escrow_address, + self.farm_address, + self.accounting_asset.address, + *(token.address for token in self.configured_tokens), + ] + + @property + def labels(self) -> dict[str, str]: + """Useful labels for addresses not present in the original calldata.""" + labels = { + self.farm_address: self.farm_name or self.farm_slug, + self.accounting_asset.address: _token_label(self.accounting_asset), + } + labels.update({token.address: _token_label(token) for token in self.configured_tokens}) + return {address: label for address, label in labels.items() if label} + + +@dataclass(frozen=True) +class _EscrowState: + address: str + farm_address: str + asset_address: str + total_assets_raw: int + + +@dataclass(frozen=True) +class _FarmRecord: + address: str + label: str + slug: str + + +@dataclass(frozen=True) +class _TokenCandidate: + address: str + fallback_name: str + + +def _token_label(token: TokenContext) -> str: + """Human label that includes both descriptive name and ticker metadata.""" + name = token.name or token.symbol + return f"{name} ({token.symbol}, {token.decimals} dec)" + + +def _abi_function_names(entries: list[dict]) -> set[str]: + """Function names present in a verified ABI.""" + return {str(entry.get("name")) for entry in entries if entry.get("type") == "function" and entry.get("name")} + + +def _looks_like_escrow(entries: list[dict]) -> bool: + """Return whether an ABI exposes the required RWAEscrow state getters.""" + return {"assetToken", "owner", "totalAssets"}.issubset(_abi_function_names(entries)) + + +def _candidate_addresses(targets_and_calls: list[tuple[str, DecodedCall]]) -> list[str]: + """Targets and address arguments that could be an Infinifi escrow.""" + addresses: list[str] = [] + seen: set[str] = set() + for target, call in targets_and_calls: + raw_addresses = [target] + for type_str, value in call.params: + raw_addresses.extend(iter_address_values(type_str, value)) + for raw in raw_addresses: + if not raw or raw.lower() in seen: + continue + try: + checksum = to_checksum_address(raw) + except ValueError: + continue + seen.add(raw.lower()) + addresses.append(checksum) + if len(addresses) >= MAX_CANDIDATE_ADDRESSES: + return addresses + return addresses + + +def _read_escrow_state(chain_id: int, address: str) -> _EscrowState | None: + """Read the three state values needed to identify and describe an escrow.""" + entries = fetch_abi_entries(chain_id, address) or [] + if not _looks_like_escrow(entries): + return None + + client = ChainManager.get_client(Chain.from_chain_id(chain_id)) + contract = client.get_contract(to_checksum_address(address), _ESCROW_GETTERS_ABI) + with client.batch_requests() as batch: + batch.add(contract.functions.assetToken()) + batch.add(contract.functions.owner()) + batch.add(contract.functions.totalAssets()) + asset_address, farm_address, total_assets = client.execute_batch(batch) + return _EscrowState( + address=to_checksum_address(address), + farm_address=to_checksum_address(str(farm_address)), + asset_address=to_checksum_address(str(asset_address)), + total_assets_raw=int(total_assets), + ) + + +@lru_cache(maxsize=1) +def _fetch_farm_records() -> tuple[_FarmRecord, ...]: + """Fetch the current Infinifi farm list used by its public analytics API.""" + data = fetch_json(INFINIFI_API_URL, timeout=10) + payload = data.get("data") if isinstance(data, dict) and data.get("code") == "OK" else None + farms = payload.get("farms") if isinstance(payload, dict) else None + if not isinstance(farms, list): + return () + + records: list[_FarmRecord] = [] + for farm in farms: + if not isinstance(farm, dict): + continue + address = farm.get("address") + if not isinstance(address, str): + continue + try: + checksum = to_checksum_address(address) + except ValueError: + continue + records.append( + _FarmRecord( + address=checksum, + label=str(farm.get("label") or ""), + slug=str(farm.get("name") or ""), + ) + ) + return tuple(records) + + +def _farm_by_address(address: str, farms: tuple[_FarmRecord, ...]) -> _FarmRecord | None: + """Find an API farm record by its checksummed or lowercase address.""" + return next((farm for farm in farms if farm.address.lower() == address.lower()), None) + + +def _farm_matches_escrow(chain_id: int, farm_address: str, escrow_address: str) -> bool: + """Verify that an Infinifi farm identifies the candidate as its escrow.""" + client = ChainManager.get_client(Chain.from_chain_id(chain_id)) + farm = client.get_contract(to_checksum_address(farm_address), _FARM_ESCROW_ABI) + configured_escrow = str(to_checksum_address(str(farm.functions.escrow().call()))) + return configured_escrow.lower() == escrow_address.lower() + + +def _fetch_whitelist_targets(chain_id: int, escrow_address: str) -> list[str]: + """Reconstruct the escrow's current whitelist from its emitted updates.""" + client = ChainManager.get_client(Chain.from_chain_id(chain_id)) + escrow = client.get_contract(to_checksum_address(escrow_address), _WHITELIST_EVENT_ABI) + events = escrow.events.WhitelistUpdated().get_logs(from_block=0, to_block="latest") + enabled_by_address: dict[str, tuple[str, bool]] = {} + for event in events: + args = event.get("args", {}) + raw_address = args.get("target") + enabled = args.get("enabled") + if not isinstance(raw_address, str) or not isinstance(enabled, bool): + continue + try: + address = to_checksum_address(raw_address) + except ValueError: + continue + enabled_by_address[address.lower()] = (address, enabled) + return [address for address, enabled in enabled_by_address.values() if enabled] + + +def _read_token(chain_id: int, candidate: _TokenCandidate) -> TokenContext | None: + """Verify token metadata and read its descriptive name.""" + metadata = fetch_erc20_metadata(chain_id, candidate.address) + if metadata is None: + return None + + client = ChainManager.get_client(Chain.from_chain_id(chain_id)) + token = client.get_contract(candidate.address, _TOKEN_NAME_ABI) + try: + name = str(token.functions.name().call()) + except Exception: # noqa: BLE001 - some older ERC20s return bytes32 names + name = candidate.fallback_name + + return TokenContext( + address=candidate.address, + name=name or candidate.fallback_name or metadata.symbol, + symbol=metadata.symbol, + decimals=metadata.decimals, + ) + + +def _resolve_configured_tokens(chain_id: int, escrow: _EscrowState) -> tuple[TokenContext, ...]: + """Whitelisted non-accounting addresses that verify as ERC20 tokens.""" + tokens: list[TokenContext] = [] + for address in _fetch_whitelist_targets(chain_id, escrow.address): + if address.lower() == escrow.asset_address.lower(): + continue + token = _read_token(chain_id, _TokenCandidate(address, "")) + if token is not None: + tokens.append(token) + return tuple(tokens) + + +def resolve_infinifi_context( + protocol: str, + chain_id: int, + targets_and_calls: list[tuple[str, DecodedCall]], +) -> list[InfinifiEscrowContext]: + """Resolve deterministic farm context for Infinifi escrow-related calls.""" + if protocol.lower() != "infinifi" or chain_id != Chain.MAINNET.chain_id: + return [] + + contexts: list[InfinifiEscrowContext] = [] + farms: tuple[_FarmRecord, ...] | None = None + for address in _candidate_addresses(targets_and_calls): + try: + escrow = _read_escrow_state(chain_id, address) + if escrow is None: + continue + accounting_asset = _read_token(chain_id, _TokenCandidate(escrow.asset_address, "")) + if accounting_asset is None: + continue + if farms is None: + farms = _fetch_farm_records() + farm = _farm_by_address(escrow.farm_address, farms) + if farm is None or not _farm_matches_escrow(chain_id, farm.address, escrow.address): + continue + try: + configured_tokens = _resolve_configured_tokens(chain_id, escrow) + except Exception as error: # noqa: BLE001 - optional token context must not block farm context + logger.info("Infinifi token resolution failed for %s: %s", escrow.address, error) + configured_tokens = () + contexts.append( + InfinifiEscrowContext( + escrow_address=escrow.address, + farm_address=escrow.farm_address, + farm_name=farm.label, + farm_slug=farm.slug, + accounting_asset=accounting_asset, + total_assets_raw=escrow.total_assets_raw, + configured_tokens=configured_tokens, + ) + ) + except Exception as error: # noqa: BLE001 - enrichment must never block an alert + logger.info("Infinifi context resolution failed for %s: %s", address, error) + return contexts + + +def format_infinifi_prompt(contexts: list[InfinifiEscrowContext]) -> str: + """Render verified Infinifi context for the LLM prompt.""" + sections: list[str] = [] + for context in contexts: + asset = context.accounting_asset + total_assets = format_decimal_amount(normalize_token_amount(context.total_assets_raw, asset.decimals)) + lines = [ + f"Escrow: {context.escrow_address}", + f"Farm: {context.farm_address} ({context.farm_name or context.farm_slug or 'name unavailable'})", + f"Accounting asset: {asset.address} ({asset.name}, {asset.symbol}, {asset.decimals} decimals)", + f"Current escrow totalAssets: {context.total_assets_raw} raw units = {total_assets} {asset.symbol}", + ] + for token in context.configured_tokens: + lines.append( + f"Configured non-accounting ERC20 target: {token.address} " + f"({token.name}, {token.symbol}, {token.decimals} decimals)" + ) + sections.append("\n".join(lines)) + return "\n\n".join(sections) + + +def format_infinifi_report( + contexts: list[InfinifiEscrowContext], + chain_id: int, + labels: dict[str, str], +) -> str: + """Render the deterministic Infinifi farm section for the gist report.""" + sections: list[str] = [] + for context in contexts: + asset = context.accounting_asset + total_assets = format_decimal_amount(normalize_token_amount(context.total_assets_raw, asset.decimals)) + farm_name = context.farm_name or context.farm_slug or "Unknown farm" + lines = [ + f"- **Farm:** {farm_name} — {address_link(context.farm_address, chain_id)}", + f"- **Escrow:** {address_link(context.escrow_address, chain_id, labels)}", + f"- **Accounting asset:** {asset.name} (`{asset.symbol}`, {asset.decimals} decimals) — " + f"{address_link(asset.address, chain_id)}", + f"- **Current `totalAssets`:** `{total_assets} {asset.symbol}` (`{context.total_assets_raw:,}` raw units)", + ] + if context.configured_tokens: + lines.append("- **Configured non-accounting ERC-20 targets:**") + for token in context.configured_tokens: + lines.append( + f" - {token.name} (`{token.symbol}`, {token.decimals} decimals) — " + f"{address_link(token.address, chain_id)}" + ) + sections.append("\n".join(lines)) + return "\n\n".join(sections) + + +def reset_cache() -> None: + """Reset process caches for tests or long-running workers.""" + _fetch_farm_records.cache_clear() diff --git a/utils/llm/report.py b/utils/llm/report.py index 25302999..4a626af3 100644 --- a/utils/llm/report.py +++ b/utils/llm/report.py @@ -64,6 +64,10 @@ class ReportContext: # Safe multisend batch labels the utility contract instead. Linked from the # report's Contract header line. label_address: str = "" + # Deterministic protocol-specific facts that belong in the full gist but + # are not part of the raw calldata flow (for example an Infinifi farm and + # the non-accounting ERC20 targets configured in its escrow). + protocol_context: str = "" def checksum_or_none(addr: object) -> str | None: @@ -347,7 +351,8 @@ def build_report(summary: str, detail: str, ctx: ReportContext, risk_tag: str = """Assemble the full markdown gist body. Sections: metadata header, the Telegram-visible summary (so the gist is - self-contained), the deterministic call flow, and the LLM's analysis. + self-contained), the deterministic call flow, optional protocol context, + and the LLM's analysis. Args: summary: The authoritative TLDR, risk tag already stripped by the caller. @@ -370,6 +375,8 @@ def build_report(summary: str, detail: str, ctx: ReportContext, risk_tag: str = call_flow = format_call_flow(ctx) if call_flow: sections.append(f"## Call Flow\n\n{call_flow}") + if ctx.protocol_context: + sections.append(f"## Protocol Context\n\n{ctx.protocol_context}") if detail: sections.append(f"## Analysis\n\n{_REDUNDANT_ANALYSIS_HEADING_RE.sub('', detail)}") return "\n\n".join(sections) From dcc98f4c42d94c78aed0ef3f617a8ee727bc467a Mon Sep 17 00:00:00 2001 From: spalen0 Date: Mon, 17 Aug 2026 22:41:21 +0200 Subject: [PATCH 2/3] fix: resolve code review findings for PR #340 - add a User-Agent header to the Infinifi farm API request, consistent with the existing protocol monitor's request convention - log previously-silent context-resolution skips (non-ERC20 accounting asset, empty farm records, unknown farm, farm/escrow mismatch, non-ERC20 whitelist target) so transient enrichment failures are visible instead of vanishing - extend unit coverage for _candidate_addresses, _looks_like_escrow, _farm_by_address, _fetch_farm_records parsing, _token_label, the empty-configured-tokens report, and the non-ERC20 accounting asset path Verification: pytest tests/ (803 passed, 4 skipped), ruff check, ruff format --check, mypy on utils/llm/infinifi_context.py all clean. --- tests/test_infinifi_context.py | 96 ++++++++++++++++++++++++++++++++++ utils/llm/infinifi_context.py | 21 +++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/tests/test_infinifi_context.py b/tests/test_infinifi_context.py index f9a7b67e..709e31e2 100644 --- a/tests/test_infinifi_context.py +++ b/tests/test_infinifi_context.py @@ -9,11 +9,16 @@ from utils.llm.infinifi_context import ( InfinifiEscrowContext, TokenContext, + _candidate_addresses, _EscrowState, + _farm_by_address, _farm_matches_escrow, _FarmRecord, + _fetch_farm_records, _fetch_whitelist_targets, + _looks_like_escrow, _resolve_configured_tokens, + _token_label, _TokenCandidate, format_infinifi_prompt, format_infinifi_report, @@ -190,5 +195,96 @@ def test_reads_name_on_chain(self, _mock_meta: MagicMock, mock_client: MagicMock self.assertEqual(token, TokenContext(DROP, "New Silver Series 2 DROP", "NS2DRP", 18)) +class TestCandidateAddresses(unittest.TestCase): + def test_collects_target_and_address_params(self) -> None: + call = DecodedCall( + function_name="setRate", + signature="setRate(address,uint256)", + params=[("address", ESCROW), ("uint256", 1)], + ) + result = _candidate_addresses([(MANAGER, call)]) + self.assertEqual(result, [MANAGER, ESCROW]) + + def test_dedupes_across_calls(self) -> None: + call = DecodedCall(function_name="setRate", signature="setRate(address,uint256)", params=[("address", ESCROW)]) + self.assertEqual(_candidate_addresses([(ESCROW, call), (MANAGER, call)]), [ESCROW, MANAGER]) + + +class TestLooksLikeEscrow(unittest.TestCase): + def test_requires_all_three_getters(self) -> None: + abi = [ + {"type": "function", "name": "assetToken", "outputs": []}, + {"type": "function", "name": "owner", "outputs": []}, + {"type": "function", "name": "totalAssets", "outputs": []}, + ] + self.assertTrue(_looks_like_escrow(abi)) + self.assertFalse(_looks_like_escrow(abi[:-1])) + self.assertFalse(_looks_like_escrow([])) + + +class TestFarmLookupAndParsing(unittest.TestCase): + def setUp(self) -> None: + infinifi_context.reset_cache() + + def test_farm_by_address_matches_checksummed_and_lowercase(self) -> None: + farms = (_FarmRecord(FARM, "New Silver 2 Senior", "new-silver-senior"),) + self.assertEqual(_farm_by_address(FARM, farms), farms[0]) + self.assertEqual(_farm_by_address(FARM.lower(), farms), farms[0]) + self.assertIsNone(_farm_by_address(MANAGER, farms)) + + @patch.object(infinifi_context, "fetch_json") + def test_fetch_farm_records_parses_api_shape(self, mock_fetch: MagicMock) -> None: + mock_fetch.return_value = { + "code": "OK", + "data": { + "farms": [ + {"name": "new-silver-senior", "label": "New Silver 2 Senior", "address": FARM}, + {"name": "broken", "label": "", "address": "not-an-address"}, + {"name": "no-address", "label": "", "address": None}, + ] + }, + } + records = _fetch_farm_records() + self.assertEqual(records, (_FarmRecord(FARM, "New Silver 2 Senior", "new-silver-senior"),)) + + @patch.object(infinifi_context, "fetch_json", return_value={"code": "ERROR"}) + def test_fetch_farm_records_empty_on_bad_response(self, _mock_fetch: MagicMock) -> None: + self.assertEqual(_fetch_farm_records(), ()) + + +class TestTokenLabel(unittest.TestCase): + def test_combines_name_symbol_and_decimals(self) -> None: + token = TokenContext(USDC, "USD Coin", "USDC", 6) + self.assertEqual(_token_label(token), "USD Coin (USDC, 6 dec)") + + +class TestFormattingEdgeCases(unittest.TestCase): + def test_report_omits_configured_tokens_section_when_empty(self) -> None: + context = InfinifiEscrowContext( + escrow_address=ESCROW, + farm_address=FARM, + farm_name="New Silver 2 Senior", + farm_slug="new-silver-senior", + accounting_asset=TokenContext(USDC, "USD Coin", "USDC", 6), + total_assets_raw=3_003_294_554_623, + configured_tokens=(), + ) + report = format_infinifi_report([context], 1, context.labels) + self.assertIn("**Farm:** New Silver 2 Senior", report) + self.assertNotIn("Configured non-accounting ERC-20 targets", report) + + @patch.object(infinifi_context, "_fetch_farm_records") + @patch.object(infinifi_context, "_read_token", return_value=None) + @patch.object(infinifi_context, "_read_escrow_state", return_value=_EscrowState(ESCROW, FARM, USDC, 1)) + def test_skips_escrow_with_non_erc20_accounting_asset( + self, + _mock_escrow: MagicMock, + _mock_token: MagicMock, + mock_farms: MagicMock, + ) -> None: + self.assertEqual(resolve_infinifi_context("INFINIFI", 1, [(MANAGER, _set_rate_call())]), []) + mock_farms.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/utils/llm/infinifi_context.py b/utils/llm/infinifi_context.py index 115aa42e..cf648d68 100644 --- a/utils/llm/infinifi_context.py +++ b/utils/llm/infinifi_context.py @@ -215,7 +215,11 @@ def _read_escrow_state(chain_id: int, address: str) -> _EscrowState | None: @lru_cache(maxsize=1) def _fetch_farm_records() -> tuple[_FarmRecord, ...]: """Fetch the current Infinifi farm list used by its public analytics API.""" - data = fetch_json(INFINIFI_API_URL, timeout=10) + data = fetch_json( + INFINIFI_API_URL, + timeout=10, + headers={"User-Agent": "Mozilla/5.0 (compatible; Yearn Monitoring)"}, + ) payload = data.get("data") if isinstance(data, dict) and data.get("code") == "OK" else None farms = payload.get("farms") if isinstance(payload, dict) else None if not isinstance(farms, list): @@ -305,6 +309,8 @@ def _resolve_configured_tokens(chain_id: int, escrow: _EscrowState) -> tuple[Tok token = _read_token(chain_id, _TokenCandidate(address, "")) if token is not None: tokens.append(token) + else: + logger.debug("Infinifi whitelist target %s is not an ERC20; skipping", address) return tuple(tokens) @@ -326,11 +332,22 @@ def resolve_infinifi_context( continue accounting_asset = _read_token(chain_id, _TokenCandidate(escrow.asset_address, "")) if accounting_asset is None: + logger.info( + "Infinifi escrow %s: accounting asset %s is not an ERC20", escrow.address, escrow.asset_address + ) continue if farms is None: farms = _fetch_farm_records() + if not farms: + logger.info("Infinifi farm records unavailable; skipping escrow %s", escrow.address) farm = _farm_by_address(escrow.farm_address, farms) - if farm is None or not _farm_matches_escrow(chain_id, farm.address, escrow.address): + if farm is None: + logger.info( + "Infinifi escrow %s: owner %s is not a known Infinifi farm", escrow.address, escrow.farm_address + ) + continue + if not _farm_matches_escrow(chain_id, farm.address, escrow.address): + logger.info("Infinifi escrow %s: farm %s does not reference this escrow", escrow.address, farm.address) continue try: configured_tokens = _resolve_configured_tokens(chain_id, escrow) From 560548db08f55edc66eecd42ba6cae65667b3f93 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Mon, 17 Aug 2026 22:50:30 +0200 Subject: [PATCH 3/3] Clarify Infinifi context failure logging --- tests/test_infinifi_context.py | 14 -------------- utils/llm/infinifi_context.py | 7 +++++-- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/tests/test_infinifi_context.py b/tests/test_infinifi_context.py index 709e31e2..fa3731ee 100644 --- a/tests/test_infinifi_context.py +++ b/tests/test_infinifi_context.py @@ -11,14 +11,12 @@ TokenContext, _candidate_addresses, _EscrowState, - _farm_by_address, _farm_matches_escrow, _FarmRecord, _fetch_farm_records, _fetch_whitelist_targets, _looks_like_escrow, _resolve_configured_tokens, - _token_label, _TokenCandidate, format_infinifi_prompt, format_infinifi_report, @@ -226,12 +224,6 @@ class TestFarmLookupAndParsing(unittest.TestCase): def setUp(self) -> None: infinifi_context.reset_cache() - def test_farm_by_address_matches_checksummed_and_lowercase(self) -> None: - farms = (_FarmRecord(FARM, "New Silver 2 Senior", "new-silver-senior"),) - self.assertEqual(_farm_by_address(FARM, farms), farms[0]) - self.assertEqual(_farm_by_address(FARM.lower(), farms), farms[0]) - self.assertIsNone(_farm_by_address(MANAGER, farms)) - @patch.object(infinifi_context, "fetch_json") def test_fetch_farm_records_parses_api_shape(self, mock_fetch: MagicMock) -> None: mock_fetch.return_value = { @@ -252,12 +244,6 @@ def test_fetch_farm_records_empty_on_bad_response(self, _mock_fetch: MagicMock) self.assertEqual(_fetch_farm_records(), ()) -class TestTokenLabel(unittest.TestCase): - def test_combines_name_symbol_and_decimals(self) -> None: - token = TokenContext(USDC, "USD Coin", "USDC", 6) - self.assertEqual(_token_label(token), "USD Coin (USDC, 6 dec)") - - class TestFormattingEdgeCases(unittest.TestCase): def test_report_omits_configured_tokens_section_when_empty(self) -> None: context = InfinifiEscrowContext( diff --git a/utils/llm/infinifi_context.py b/utils/llm/infinifi_context.py index cf648d68..51f01a35 100644 --- a/utils/llm/infinifi_context.py +++ b/utils/llm/infinifi_context.py @@ -310,7 +310,7 @@ def _resolve_configured_tokens(chain_id: int, escrow: _EscrowState) -> tuple[Tok if token is not None: tokens.append(token) else: - logger.debug("Infinifi whitelist target %s is not an ERC20; skipping", address) + logger.debug("ERC20 metadata unavailable or incompatible for Infinifi whitelist target %s", address) return tuple(tokens) @@ -333,13 +333,16 @@ def resolve_infinifi_context( accounting_asset = _read_token(chain_id, _TokenCandidate(escrow.asset_address, "")) if accounting_asset is None: logger.info( - "Infinifi escrow %s: accounting asset %s is not an ERC20", escrow.address, escrow.asset_address + "Infinifi escrow %s: ERC20 metadata unavailable or incompatible for accounting asset %s", + escrow.address, + escrow.asset_address, ) continue if farms is None: farms = _fetch_farm_records() if not farms: logger.info("Infinifi farm records unavailable; skipping escrow %s", escrow.address) + continue farm = _farm_by_address(escrow.farm_address, farms) if farm is None: logger.info(