From 44f9fcd4c3f15ed861ae98abc692baa82f68a253 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 25 Aug 2026 22:07:21 +0200 Subject: [PATCH 1/4] feat(llm): add 3Jane governance context to AI reports 3Jane timelock calls reach the LLM as opaque data: setConfig names its parameter only by keccak256 hash, and RewardsDistributor allocates JANE without revealing whether a claim mints new supply or moves a balance. A recent 24h-timelock report hedged four separate times over facts that are public getters. - reverse bytes32 arguments against a checked-in table of ProtocolConfig keys and Jane/EmergencyController roles, with the value stored on-chain for config keys - read RewardsDistributor distribution mode, MINTER_ROLE authority, token supply and transferability, claim accounting, root, and the emissions of the preceding epochs so an allocation is judged against recent ones - follow EIP-1967 to the implementation ABI, since ProtocolConfig and MorphoCredit sit behind transparent proxies - fan protocol adapters out from one registry instead of wiring each into the explainer, and make the Protocol Context prompt header generic Co-Authored-By: Claude Opus 5 (1M context) --- protocols/3jane/README.md | 8 + tests/test_protocol_context.py | 86 ++++++ tests/test_threejane_context.py | 198 ++++++++++++ utils/llm/README.md | 20 ++ utils/llm/ai_explainer.py | 46 ++- utils/llm/protocol_context.py | 105 +++++++ utils/llm/threejane_context.py | 533 ++++++++++++++++++++++++++++++++ 7 files changed, 972 insertions(+), 24 deletions(-) create mode 100644 tests/test_protocol_context.py create mode 100644 tests/test_threejane_context.py create mode 100644 utils/llm/protocol_context.py create mode 100644 utils/llm/threejane_context.py diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index 2ad1d283..96693675 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -99,6 +99,14 @@ Only HIGH and CRITICAL alerts dispatch. LOW and MEDIUM alerts—including insura [Internal timelock monitoring](../timelock/README.md) covers CallScheduled events from the [3Jane 24-hour timelock](https://etherscan.io/address/0x1dccd4628d48a50c1a7adea3848bcc869f08f8c2) and [7-day upgrade timelock](https://etherscan.io/address/0x3d3c41419ab401cd25055e8f9421d7d96d887885) on Mainnet. +Those alerts carry a 3Jane `Protocol Context` section built by +[`utils/llm/threejane_context.py`](../../utils/llm/threejane_context.py): + +- `bytes32` arguments are reversed to their `keccak256` pre-image, so a `setConfig` call names the parameter (`MAX_LTV`, `IS_PAUSED`, `DEBT_CAP`, …) and a `grantRole` names the role, each with what it controls. `ProtocolConfig` keys also carry the value stored on-chain right now. +- `RewardsDistributor` calls carry the distribution mode (`useMint`), whether the distributor holds `MINTER_ROLE` on JANE, JANE supply and whether transfers are globally enabled, `maxClaimable` / `totalClaimed` / outstanding, the current `merkleRoot` and epoch, and the emissions stored for the three preceding epochs. + +Add a key or role to `_HASHED_LABELS` in that module when 3Jane introduces one; the hash is derived from the name, so the table cannot drift. + ## Running ```bash diff --git a/tests/test_protocol_context.py b/tests/test_protocol_context.py new file mode 100644 index 00000000..556a5d75 --- /dev/null +++ b/tests/test_protocol_context.py @@ -0,0 +1,86 @@ +"""Tests for the protocol-context adapter registry.""" + +import unittest +from unittest.mock import patch + +from utils.calldata.decoder import DecodedCall +from utils.llm import protocol_context +from utils.llm.protocol_context import _Adapter, resolve_protocol_context + +TARGET = "0x6b276A2A7dd8b629adBA8A06AD6573d01C84f34E" +TOKEN = "0x333333330522F64EE8d0b3039c460b41670e3404" + + +class _FakeContext: + def __init__(self, addresses: list[str], labels: dict[str, str]) -> None: + self.addresses = addresses + self.labels = labels + + +def _call() -> DecodedCall: + return DecodedCall("setConfig", "setConfig(bytes32,uint256)", [("uint256", 1)]) + + +def _adapter(name: str, contexts: list[_FakeContext]) -> _Adapter: + return _Adapter( + name=name, + resolve=lambda protocol, chain_id, calls: contexts, + format_prompt=lambda ctxs: f"{name} prompt", + format_report=lambda ctxs, chain_id, labels: f"{name} report", + ) + + +class TestResolveProtocolContext(unittest.TestCase): + """Every registered adapter contributes; a failing one is skipped.""" + + def test_adapters_are_combined(self) -> None: + adapters = ( + _adapter("alpha", [_FakeContext([TARGET], {TARGET: "Config"})]), + _adapter("beta", [_FakeContext([TOKEN], {TOKEN: "Token"})]), + ) + with patch.object(protocol_context, "_ADAPTERS", adapters): + resolved = resolve_protocol_context("3JANE", 1, [(TARGET, _call())]) + + self.assertEqual(resolved.prompt, "alpha prompt\n\nbeta prompt") + self.assertEqual(resolved.report, "alpha report\n\nbeta report") + self.assertEqual(resolved.addresses, [TARGET, TOKEN]) + self.assertEqual(resolved.labels, {TARGET: "Config", TOKEN: "Token"}) + + def test_empty_adapter_contributes_nothing(self) -> None: + with patch.object(protocol_context, "_ADAPTERS", (_adapter("alpha", []),)): + resolved = resolve_protocol_context("3JANE", 1, [(TARGET, _call())]) + + self.assertEqual(resolved.prompt, "") + self.assertEqual(resolved.report, "") + self.assertEqual(resolved.addresses, []) + + def test_failing_adapter_does_not_block_the_others(self) -> None: + def explode(protocol: str, chain_id: int, calls: list) -> list: + raise RuntimeError("etherscan down") + + broken = _Adapter("broken", explode, lambda c: "x", lambda contexts, chain_id, labels: "x") + adapters = (broken, _adapter("beta", [_FakeContext([TOKEN], {})])) + with patch.object(protocol_context, "_ADAPTERS", adapters): + resolved = resolve_protocol_context("3JANE", 1, [(TARGET, _call())]) + + self.assertEqual(resolved.prompt, "beta prompt") + + def test_existing_labels_reach_the_report_renderer(self) -> None: + seen: dict[str, str] = {} + + def capture(contexts: list, chain_id: int, labels: dict[str, str]) -> str: + seen.update(labels) + return "report" + + adapter = _Adapter("alpha", lambda p, c, t: [_FakeContext([TOKEN], {TOKEN: "Token"})], lambda c: "p", capture) + with patch.object(protocol_context, "_ADAPTERS", (adapter,)): + resolve_protocol_context("3JANE", 1, [(TARGET, _call())], {TARGET: "Timelock"}) + + self.assertEqual(seen, {TARGET: "Timelock", TOKEN: "Token"}) + + def test_registered_adapters_cover_the_known_protocols(self) -> None: + self.assertEqual({adapter.name for adapter in protocol_context._ADAPTERS}, {"infinifi", "3jane"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_threejane_context.py b/tests/test_threejane_context.py new file mode 100644 index 00000000..2b09e29e --- /dev/null +++ b/tests/test_threejane_context.py @@ -0,0 +1,198 @@ +"""Tests for 3Jane-specific LLM governance context.""" + +import unittest +from unittest.mock import patch + +from eth_utils import keccak + +from utils.calldata.decoder import DecodedCall +from utils.llm import threejane_context +from utils.llm.threejane_context import ( + HashedLabelContext, + RewardsDistributorContext, + _bytes32_arguments, + _requested_epochs, + format_threejane_prompt, + format_threejane_report, + resolve_threejane_context, +) + +DISTRIBUTOR = "0xaC6985D4dBcd89CCAD71DB9bf0309eaF57F064e8" +JANE = "0x333333330522F64EE8d0b3039c460b41670e3404" +PROTOCOL_CONFIG = "0x6b276A2A7dd8b629adBA8A06AD6573d01C84f34E" +SAFE = "0x33333333Bd7045F1A601A1E289D7AB21036fB5EF" + +WAD = 10**18 + + +def _set_emissions_call(epoch: int = 45, emissions: int = 5_564_323 * WAD) -> DecodedCall: + return DecodedCall( + function_name="setEpochEmissions", + signature="setEpochEmissions(uint256,uint256)", + params=[("uint256", epoch), ("uint256", emissions)], + ) + + +def _set_config_call(key: str = "MAX_LTV", value: int = 4 * 10**17) -> DecodedCall: + return DecodedCall( + function_name="setConfig", + signature="setConfig(bytes32,uint256)", + params=[("bytes32", keccak(text=key)), ("uint256", value)], + ) + + +def _distributor_context(use_mint: bool = True, is_minter: bool = True) -> RewardsDistributorContext: + return RewardsDistributorContext( + distributor_address=DISTRIBUTOR, + token_address=JANE, + token_symbol="JANE", + token_decimals=18, + use_mint=use_mint, + distributor_is_minter=is_minter, + token_transferable=False, + token_total_supply_raw=38_919_583 * WAD, + distributor_balance_raw=0, + merkle_root="0x" + "9a" * 32, + max_claimable_raw=84_649_011 * WAD, + total_claimed_raw=38_919_583 * WAD, + current_epoch=45, + epoch_emissions=((43, 5_369_214 * WAD), (44, 5_499_673 * WAD), (45, 0)), + ) + + +class TestGuards(unittest.TestCase): + """The adapter only claims 3Jane mainnet alerts.""" + + def test_other_protocol_resolves_nothing(self) -> None: + self.assertEqual(resolve_threejane_context("INFINIFI", 1, [(PROTOCOL_CONFIG, _set_config_call())]), []) + + def test_other_chain_resolves_nothing(self) -> None: + self.assertEqual(resolve_threejane_context("3JANE", 8453, [(PROTOCOL_CONFIG, _set_config_call())]), []) + + def test_protocol_name_is_case_insensitive(self) -> None: + with ( + patch.object(threejane_context, "_read_distributor_context", return_value=None) as distributor, + patch.object(threejane_context, "_resolve_hashed_labels", return_value=[]), + ): + resolve_threejane_context("3jane", 1, [(PROTOCOL_CONFIG, _set_config_call())]) + distributor.assert_called_once() + + def test_resolution_failure_does_not_raise(self) -> None: + with patch.object(threejane_context, "_read_distributor_context", side_effect=RuntimeError("rpc down")): + self.assertEqual(resolve_threejane_context("3JANE", 1, [(DISTRIBUTOR, _set_emissions_call())]), []) + + +class TestArgumentParsing(unittest.TestCase): + """bytes32 arguments and epoch numbers are read out of decoded calls.""" + + def test_bytes32_argument_normalized_to_hex(self) -> None: + call = _set_config_call("IS_PAUSED", 1) + self.assertEqual(_bytes32_arguments(call), ["0x" + keccak(text="IS_PAUSED").hex()]) + + def test_hex_string_argument_accepted(self) -> None: + as_hex = "0x" + keccak(text="DEBT_CAP").hex().upper() + call = DecodedCall("setConfig", "setConfig(bytes32,uint256)", [("bytes32", as_hex), ("uint256", 1)]) + self.assertEqual(_bytes32_arguments(call), [as_hex.lower()]) + + def test_undersized_bytes_ignored(self) -> None: + call = DecodedCall("setConfig", "setConfig(bytes32,uint256)", [("bytes32", b"\x01\x02"), ("uint256", 1)]) + self.assertEqual(_bytes32_arguments(call), []) + + def test_epoch_taken_from_call(self) -> None: + self.assertEqual(_requested_epochs([_set_emissions_call(epoch=45)], current_epoch=12), [45]) + + def test_epoch_falls_back_to_current(self) -> None: + call = DecodedCall("updateRoot", "updateRoot(bytes32)", [("bytes32", b"\x00" * 32)]) + self.assertEqual(_requested_epochs([call], current_epoch=45), [45]) + + +class TestHashedLabelRendering(unittest.TestCase): + """Known hashes are named; only config keys carry a stored value.""" + + def test_config_key_prompt_states_name_and_value(self) -> None: + context = HashedLabelContext( + target=PROTOCOL_CONFIG, + argument_hex="0x" + keccak(text="MAX_LTV").hex(), + name="MAX_LTV", + note="maximum loan-to-value accepted when setting a credit line (WAD)", + is_config_key=True, + current_value=350000000000000000, + ) + prompt = format_threejane_prompt([context]) + self.assertIn('keccak256("MAX_LTV")', prompt) + self.assertIn("value stored on-chain right now: 350000000000000000", prompt) + + def test_role_hash_has_no_value_line(self) -> None: + context = HashedLabelContext( + target=JANE, + argument_hex="0x" + keccak(text="MINTER_ROLE").hex(), + name="MINTER_ROLE", + note="minter role: can mint new JANE", + ) + prompt = format_threejane_prompt([context]) + report = format_threejane_report([context], 1, {}) + self.assertIn('keccak256("MINTER_ROLE")', prompt) + self.assertNotIn("value stored on-chain", prompt) + self.assertNotIn("Value stored on-chain", report) + + def test_report_links_the_target(self) -> None: + context = HashedLabelContext(PROTOCOL_CONFIG, "0xabc", "DEBT_CAP", "ceiling", True, 63_366_281_225_814) + report = format_threejane_report([context], 1, {PROTOCOL_CONFIG: "3Jane ProtocolConfig"}) + self.assertIn(f"https://etherscan.io/address/{PROTOCOL_CONFIG}", report) + self.assertIn("3Jane ProtocolConfig", report) + self.assertIn("`63,366,281,225,814`", report) + + def test_every_known_label_hashes_to_its_own_entry(self) -> None: + for as_hex, (name, note) in threejane_context._LABELS_BY_HASH.items(): + self.assertEqual(as_hex, "0x" + keccak(text=name).hex()) + self.assertTrue(note, f"{name} has no explanatory note") + + +class TestDistributorRendering(unittest.TestCase): + """The distribution mode is stated instead of hedged.""" + + def test_mint_mode_names_the_authority_and_dismisses_balance(self) -> None: + prompt = format_threejane_prompt([_distributor_context()]) + self.assertIn("claims MINT new JANE", prompt) + self.assertIn("holds MINTER_ROLE", prompt) + self.assertIn("not the funding source", prompt) + + def test_mint_mode_without_minter_role_is_flagged(self) -> None: + prompt = format_threejane_prompt([_distributor_context(is_minter=False)]) + self.assertIn("does NOT hold MINTER_ROLE", prompt) + + def test_transfer_mode_points_at_the_balance(self) -> None: + prompt = format_threejane_prompt([_distributor_context(use_mint=False)]) + self.assertIn("claims TRANSFER", prompt) + self.assertNotIn("MINT new JANE", prompt) + + def test_emission_history_is_included_for_comparison(self) -> None: + prompt = format_threejane_prompt([_distributor_context()]) + self.assertIn("epoch 43: 5,369,214 JANE", prompt) + self.assertIn("epoch 44: 5,499,673 JANE", prompt) + self.assertIn("epoch 45: 0 JANE", prompt) + + def test_amounts_are_truncated_to_whole_tokens(self) -> None: + context = _distributor_context() + self.assertEqual(context.amount(5_564_323 * WAD + 764_853_960_935_076_906), "5,564,323 JANE") + self.assertEqual(context.amount(0), "0 JANE") + self.assertEqual(context.amount(WAD // 2), "0.5 JANE") + self.assertEqual(context.amount(1), "<0.1 JANE") + + def test_outstanding_is_allocated_minus_claimed(self) -> None: + self.assertEqual(_distributor_context().outstanding_raw, (84_649_011 - 38_919_583) * WAD) + + def test_report_lists_accounting_and_links_token(self) -> None: + report = format_threejane_report([_distributor_context()], 1, {}) + self.assertIn("**Distribution mode:**", report) + self.assertIn("maxClaimable 84,649,011 JANE", report) + self.assertIn(f"https://etherscan.io/address/{JANE}", report) + + def test_context_contributes_addresses_and_labels(self) -> None: + context = _distributor_context() + self.assertEqual(context.addresses, [DISTRIBUTOR, JANE]) + self.assertEqual(context.labels[JANE], "JANE token") + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/llm/README.md b/utils/llm/README.md index 85cd1c54..74c580b3 100644 --- a/utils/llm/README.md +++ b/utils/llm/README.md @@ -185,6 +185,23 @@ For Infinifi mainnet alerts, the adapter: 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. +### 5f. 3Jane Governance Context (`utils/llm/threejane_context.py`) + +Both 3Jane timelocks schedule calls that arrive as opaque data. `ProtocolConfig.setConfig(bytes32,uint256)` names the parameter it changes only by `keccak256("")`, and `RewardsDistributor.setEpochEmissions` / `updateRoot` allocate JANE without revealing whether a claim mints new supply or moves an existing balance. + +For 3Jane mainnet alerts, the adapter: + +1. Reverses every `bytes32` argument against a checked-in name table (`ProtocolConfig` keys plus the Jane / EmergencyController roles), so the prompt carries `keccak256("MAX_LTV")` and what that key controls instead of a bare hash. Hashes outside the table stay unresolved rather than being guessed at. +2. Reads the current stored value for resolved `ProtocolConfig` keys, following EIP-1967 to the implementation ABI since the config sits behind a transparent proxy. Role hashes get no value line — there is nothing to read. +3. Identifies a `RewardsDistributor` by its verified getters and reads `useMint`, the reward token's metadata and `totalSupply`, whether the distributor holds `MINTER_ROLE`, whether token transfers are globally enabled, `maxClaimable` / `totalClaimed`, the current `merkleRoot`, and the current epoch. +4. Reads emissions already stored for the epoch being set and the three before it, so a new allocation is judged against recent ones rather than called "substantial in absolute terms". + +Token amounts are truncated to whole tokens, matching the call flow's amount hints. Failures are best-effort and never block the governance alert. + +### 5g. Adapter Registry (`utils/llm/protocol_context.py`) + +Adapters register in `_ADAPTERS`; `resolve_protocol_context()` fans one call out to all of them and merges the rendered prompt text, report text, introduced addresses, and address labels. Each adapter guards its own protocol and chain, so registration order carries no meaning and one adapter raising is logged and skipped rather than dropping the 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: @@ -406,8 +423,11 @@ utils/llm/ ├── anthropic_provider.py # Anthropic (Claude) native API provider ├── base.py # Abstract LLMProvider base class + LLMError ├── factory.py # Provider factory with env-based config + singleton +├── infinifi_context.py # Infinifi adapter: escrow → farm, accounting asset, whitelisted tokens ├── openai_compat.py # OpenAI-compatible provider (Venice, OpenAI, etc.) +├── protocol_context.py # Registry fanning one call out to every protocol adapter ├── report.py # Gist report: metadata header + deterministic call flow + analysis +├── threejane_context.py # 3Jane adapter: hashed config keys/roles, rewards distribution mode └── README.md # This file utils/related_tokens.py # Token discovery from a contract's own zero-arg address getters diff --git a/utils/llm/ai_explainer.py b/utils/llm/ai_explainer.py index 9cc2875d..28056a27 100644 --- a/utils/llm/ai_explainer.py +++ b/utils/llm/ai_explainer.py @@ -18,11 +18,7 @@ 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.protocol_context import resolve_protocol_context from utils.llm.report import ( CallEntry, ReportContext, @@ -112,6 +108,12 @@ 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. +- A bytes32 argument the Protocol Context resolves to a keccak256 pre-image IS identified. + Name the parameter or role, reason about what it controls, and never call it unknown or + unnamed. A bytes32 the section does NOT resolve stays unidentified — say so plainly. +- When the Protocol Context states a token distribution mode, use it instead of hedging about + funding: minting expands supply on claim, transferring draws down the stated balance. + Compare a new allocation against the prior values the section lists before calling it large. - 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 @@ -942,9 +944,9 @@ def _build_prompt( 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 + "\n--- Protocol Context (computed from protocol APIs and live on-chain reads) ---\n" + "Every fact below is VERIFIED for this protocol: identities, resolved hashes, decimals, " + "and current values. State them; do not hedge about them or call them unavailable.\n" + protocol_context ) if source_contexts: @@ -1264,10 +1266,9 @@ 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) + protocol_ctx = resolve_protocol_context(protocol, chain_id, [(target, decoded)], address_labels) + for address, context_label in protocol_ctx.labels.items(): + address_labels.setdefault(address, context_label) simulation: SimulationResult | None = None if not skip_simulation: @@ -1290,8 +1291,7 @@ def explain_transaction( else: logger.info("Simulation unavailable, proceeding with decoded calldata only") - context_addresses = [address for context in infinifi_contexts for address in context.addresses] - addresses = list(dict.fromkeys([*collect_unique_addresses([(target, decoded)]), *context_addresses])) + addresses = list(dict.fromkeys([*collect_unique_addresses([(target, decoded)]), *protocol_ctx.addresses])) address_links = format_address_links_block(addresses, chain_id, address_labels) prompt = _build_prompt( @@ -1312,7 +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), + protocol_context=protocol_ctx.prompt, ) logger.info("Full AI context for %s:\n%s", target, prompt) @@ -1332,7 +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), + protocol_context=protocol_ctx.report, ) try: @@ -1439,15 +1439,13 @@ 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) + protocol_ctx = resolve_protocol_context(protocol, chain_id, decoded_with_target, address_labels) + for address, context_label in protocol_ctx.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) - 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])) + addresses = list(dict.fromkeys([*collect_unique_addresses(decoded_with_target), *protocol_ctx.addresses])) address_links = format_address_links_block(addresses, chain_id, address_labels) prompt = _build_prompt( @@ -1468,7 +1466,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), + protocol_context=protocol_ctx.prompt, ) logger.info("Full AI context for batch (%s calls):\n%s", len(calls), prompt) @@ -1490,7 +1488,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), + protocol_context=protocol_ctx.report, ) try: diff --git a/utils/llm/protocol_context.py b/utils/llm/protocol_context.py new file mode 100644 index 00000000..27072392 --- /dev/null +++ b/utils/llm/protocol_context.py @@ -0,0 +1,105 @@ +"""Registry of protocol-specific LLM context adapters. + +Some governance calls carry facts the generic resolvers cannot reach: an +Infinifi escrow hides the farm that owns it, a 3Jane ``setConfig`` identifies +its parameter only by ``keccak256`` hash. Each protocol adapter resolves those +facts deterministically — verified ABIs, on-chain reads, checked-in name +tables — and this module fans one call out to whichever adapters claim the +alert's protocol. + +Adapters are responsible for their own guards: each returns an empty list for +protocols and chains it does not handle, so registration order carries no +meaning and adding a protocol is one row in ``_ADAPTERS``. +""" + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from utils.calldata.decoder import DecodedCall +from utils.llm.infinifi_context import ( + format_infinifi_prompt, + format_infinifi_report, + resolve_infinifi_context, +) +from utils.llm.threejane_context import ( + format_threejane_prompt, + format_threejane_report, + resolve_threejane_context, +) +from utils.logger import get_logger + +logger = get_logger("utils.llm.protocol_context") + + +@dataclass(frozen=True) +class _Adapter: + """One protocol's resolver plus its prompt and report renderers.""" + + name: str + resolve: Callable[[str, int, list[tuple[str, DecodedCall]]], list[Any]] + format_prompt: Callable[[list[Any]], str] + format_report: Callable[[list[Any], int, dict[str, str]], str] + + +_ADAPTERS: tuple[_Adapter, ...] = ( + _Adapter("infinifi", resolve_infinifi_context, format_infinifi_prompt, format_infinifi_report), + _Adapter("3jane", resolve_threejane_context, format_threejane_prompt, format_threejane_report), +) + + +@dataclass(frozen=True) +class ResolvedProtocolContext: + """Rendered protocol context for one alert, empty when no adapter matched.""" + + prompt: str = "" + report: str = "" + addresses: list[str] = field(default_factory=list) + labels: dict[str, str] = field(default_factory=dict) + + +def resolve_protocol_context( + protocol: str, + chain_id: int, + targets_and_calls: list[tuple[str, DecodedCall]], + labels: dict[str, str] | None = None, +) -> ResolvedProtocolContext: + """Resolve and render protocol-specific context from every matching adapter. + + Args: + protocol: Alert protocol name, matched case-insensitively by each adapter. + chain_id: Chain the transaction executes on. + targets_and_calls: Decoded calls paired with the address each one targets. + labels: Address labels used when rendering the report section. + + Returns: + Rendered prompt and report text plus the addresses and labels the + adapters introduced. Adapter failures are logged and skipped — context + is an enrichment and must never block a governance alert. + """ + prompts: list[str] = [] + reports: list[str] = [] + addresses: list[str] = [] + resolved_labels: dict[str, str] = {} + + for adapter in _ADAPTERS: + try: + contexts = adapter.resolve(protocol, chain_id, targets_and_calls) + except Exception as error: # noqa: BLE001 - one adapter must not break the alert + logger.info("Protocol context adapter %s failed: %s", adapter.name, error) + continue + if not contexts: + continue + for context in contexts: + addresses.extend(context.addresses) + for address, label in context.labels.items(): + resolved_labels.setdefault(address, label) + prompts.append(adapter.format_prompt(contexts)) + reports.append(adapter.format_report(contexts, chain_id, {**(labels or {}), **resolved_labels})) + + return ResolvedProtocolContext( + prompt="\n\n".join(part for part in prompts if part), + report="\n\n".join(part for part in reports if part), + addresses=list(dict.fromkeys(addresses)), + labels=resolved_labels, + ) diff --git a/utils/llm/threejane_context.py b/utils/llm/threejane_context.py new file mode 100644 index 00000000..a3a09bd4 --- /dev/null +++ b/utils/llm/threejane_context.py @@ -0,0 +1,533 @@ +"""Resolve 3Jane governance context for timelock calls. + +3Jane routes its configuration and rewards operations through two +TimelockControllers, and both call shapes arrive at the LLM as opaque data: + +- ``ProtocolConfig.setConfig(bytes32,uint256)`` identifies the parameter being + changed by ``keccak256("")`` only, so the decoded call shows a 32-byte + hash with no indication of whether it is a pause flag or the max LTV. +- ``RewardsDistributor.setEpochEmissions`` / ``updateRoot`` allocate JANE and + swap the Merkle root, but whether a claim mints new supply or transfers an + existing balance lives in ``useMint`` — state the calldata never carries. + +This adapter is deliberately narrow: it runs only for 3Jane on Ethereum, +identifies contracts from their verified ABI, reverses known hashed labels from +a checked-in name table, and reads the surrounding state on-chain. +""" + +from dataclasses import dataclass + +from eth_utils import keccak, to_checksum_address + +from utils.calldata.decoder import DecodedCall +from utils.chains import Chain +from utils.erc20_metadata import fetch_erc20_metadata +from utils.llm.report import address_link +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.threejane_context") + +PROTOCOL = "3jane" + +# Epochs of emission history rendered alongside the epoch being set. Enough to +# show whether a weekly allocation is in line with recent ones. +EMISSION_HISTORY_EPOCHS = 3 + +# Hashed labels 3Jane passes as bytes32 arguments. Names are the pre-image; the +# note explains what the value controls so the LLM does not have to guess from +# the name alone. Sourced from ProtocolConfigLib, IProtocolConfig's config +# structs, and the Jane / EmergencyController role declarations. +_HASHED_LABELS: dict[str, str] = { + # --- ProtocolConfig: market control --- + "IS_PAUSED": "protocol-wide pause flag for the credit market (non-zero pauses)", + "MAX_ON_CREDIT": "share of supplied assets allowed to be lent on credit", + "DEBT_CAP": "ceiling on total protocol debt", + # --- ProtocolConfig: credit line (CreditLineConfig) --- + "MAX_LTV": "maximum loan-to-value accepted when setting a credit line (WAD)", + "MAX_VV": "maximum vv (verified value) accepted when setting a credit line", + "MAX_CREDIT_LINE": "maximum size of a single borrower credit line", + "MIN_CREDIT_LINE": "minimum size of a single borrower credit line", + "MAX_DRP": "maximum borrower default-risk premium, per second in WAD", + # --- ProtocolConfig: market timing (MarketConfig) --- + "GRACE_PERIOD": "seconds after cycle end before a borrower counts as delinquent", + "DELINQUENCY_PERIOD": "seconds of delinquency before a borrower defaults", + "MIN_BORROW": "minimum outstanding loan balance, prevents dust positions", + "IRP": "penalty rate charged to delinquent borrowers, per second in WAD", + "CYCLE_DURATION": "length of a payment cycle in seconds", + "MIN_LOAN_DURATION": "minimum loan duration in seconds", + "LATE_REPAYMENT_THRESHOLD": "threshold at which a repayment counts as late", + "DEFAULT_THRESHOLD": "threshold at which a borrower is treated as defaulted", + # --- ProtocolConfig: interest rate model (IRMConfig) --- + "CURVE_STEEPNESS": "AdaptiveCurveIRM curve steepness", + "ADJUSTMENT_SPEED": "AdaptiveCurveIRM rate adjustment speed", + "TARGET_UTILIZATION": "utilization the IRM steers towards (WAD)", + "INITIAL_RATE_AT_TARGET": "IRM starting rate at target utilization", + "MIN_RATE_AT_TARGET": "IRM lower bound on the rate at target utilization", + "MAX_RATE_AT_TARGET": "IRM upper bound on the rate at target utilization", + # --- ProtocolConfig: tranches --- + "TRANCHE_RATIO": "junior/senior tranche ratio", + "TRANCHE_SHARE_VARIANT": "tranche share variant selector", + "MIN_SUSD3_BACKING_RATIO": "minimum sUSD3 backing ratio; 0 disables the ratio floor", + "SUSD3_NOMINAL_BACKING_FLOOR": "absolute sUSD3 backing floor; sUSD3 redemptions block below it", + # --- ProtocolConfig: timing and caps --- + "SUSD3_LOCK_DURATION": "sUSD3 lock duration in seconds", + "SUSD3_COOLDOWN_PERIOD": "sUSD3 cooldown period in seconds", + "SUSD3_WITHDRAWAL_WINDOW": "seconds after cooldown during which sUSD3 can be withdrawn", + "USD3_COMMITMENT_TIME": "USD3 deposit commitment period in seconds", + "USD3_SUPPLY_CAP": "cap on USD3 supply in asset units", + "FULL_MARKDOWN_DURATION": "seconds over which a defaulted loan is marked down to zero", + # --- Roles (Jane token, EmergencyController, MorphoCredit) --- + "OWNER_ROLE": "owner role: manages all other roles and contract parameters", + "MINTER_ROLE": "minter role: can mint new JANE", + "TRANSFER_ROLE": "transfer role: can move JANE while transfers are globally disabled", + "EMERGENCY_AUTHORIZED_ROLE": "emergency role: pause, zero caps, revoke credit lines — bypasses the timelocks", +} + +# keccak256(name) → (name, note). Derived so the table cannot drift from the hash. +_LABELS_BY_HASH: dict[str, tuple[str, str]] = { + "0x" + keccak(text=name).hex(): (name, note) for name, note in _HASHED_LABELS.items() +} + +_CONFIG_ABI = [ + { + "name": "config", + "type": "function", + "stateMutability": "view", + "inputs": [{"name": "key", "type": "bytes32"}], + "outputs": [{"name": "", "type": "uint256"}], + } +] + +_DISTRIBUTOR_ABI = [ + {"name": "useMint", "type": "function", "stateMutability": "view", "inputs": [], "outputs": [{"type": "bool"}]}, + { + "name": "merkleRoot", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"type": "bytes32"}], + }, + {"name": "jane", "type": "function", "stateMutability": "view", "inputs": [], "outputs": [{"type": "address"}]}, + { + "name": "maxClaimable", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"type": "uint256"}], + }, + { + "name": "totalClaimed", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"type": "uint256"}], + }, + {"name": "epoch", "type": "function", "stateMutability": "view", "inputs": [], "outputs": [{"type": "uint256"}]}, + { + "name": "epochEmissions", + "type": "function", + "stateMutability": "view", + "inputs": [{"type": "uint256"}], + "outputs": [{"type": "uint256"}], + }, +] + +_JANE_ABI = [ + { + "name": "totalSupply", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"type": "uint256"}], + }, + { + "name": "transferable", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"type": "bool"}], + }, + { + "name": "balanceOf", + "type": "function", + "stateMutability": "view", + "inputs": [{"type": "address"}], + "outputs": [{"type": "uint256"}], + }, + { + "name": "hasRole", + "type": "function", + "stateMutability": "view", + "inputs": [{"type": "bytes32"}, {"type": "address"}], + "outputs": [{"type": "bool"}], + }, +] + +_MINTER_ROLE = keccak(text="MINTER_ROLE") + +_DISTRIBUTOR_GETTERS = {"useMint", "merkleRoot", "jane", "maxClaimable", "totalClaimed", "epochEmissions"} + + +@dataclass(frozen=True) +class HashedLabelContext: + """A bytes32 argument resolved back to the name it hashes.""" + + target: str + argument_hex: str + name: str + note: str + # Set only for ProtocolConfig keys; a role hash has no value to read. + is_config_key: bool = False + current_value: int | None = None + + @property + def addresses(self) -> list[str]: + return [self.target] + + @property + def labels(self) -> dict[str, str]: + return {} + + +@dataclass(frozen=True) +class RewardsDistributorContext: + """Distribution mode and reward accounting around a RewardsDistributor call.""" + + distributor_address: str + token_address: str + token_symbol: str + token_decimals: int + use_mint: bool + distributor_is_minter: bool + token_transferable: bool + token_total_supply_raw: int + distributor_balance_raw: int + merkle_root: str + max_claimable_raw: int + total_claimed_raw: int + current_epoch: int + epoch_emissions: tuple[tuple[int, int], ...] + + @property + def addresses(self) -> list[str]: + return [self.distributor_address, self.token_address] + + @property + def labels(self) -> dict[str, str]: + return { + self.distributor_address: "RewardsDistributor", + self.token_address: f"{self.token_symbol} token", + } + + @property + def outstanding_raw(self) -> int: + """Allocated but not yet claimed — the distributor's remaining claim ceiling.""" + return max(self.max_claimable_raw - self.total_claimed_raw, 0) + + def amount(self, raw: int) -> str: + """Render a raw token amount with this token's verified decimals. + + Truncated to whole tokens, matching the call flow's amount hints — an + 18-decimal tail on a multi-million reward allocation is noise the LLM + then has to carry through its own arithmetic. + """ + scale = 10**self.token_decimals + whole = raw // scale + if whole >= 1 or raw == 0: + return f"{whole:,} {self.token_symbol}" + tenths = (raw * 10) // scale + return f"0.{tenths} {self.token_symbol}" if tenths else f"<0.1 {self.token_symbol}" + + +ThreeJaneContext = HashedLabelContext | RewardsDistributorContext + + +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 _exposes(chain_id: int, address: str, wanted: set[str]) -> bool: + """Whether a contract exposes every wanted getter, following EIP-1967. + + 3Jane's ProtocolConfig and MorphoCredit sit behind transparent proxies, so + the address's own verified ABI lists the proxy's functions, not `config` or + the distributor getters. Only pay for the implementation lookup when the + proxy ABI comes up short. + """ + names = _abi_function_names(fetch_abi_entries(chain_id, address) or []) + if wanted.issubset(names): + return True + + from utils.proxy import get_current_implementation + + implementation = get_current_implementation(address, chain_id) + if not implementation or implementation.lower() == address.lower(): + return False + return wanted.issubset(_abi_function_names(fetch_abi_entries(chain_id, implementation) or [])) + + +def _as_hex32(value: object) -> str | None: + """Normalize a decoded bytes32 argument to lowercase 0x-prefixed hex.""" + if isinstance(value, bytes): + return "0x" + value.hex() if len(value) == 32 else None + if isinstance(value, str) and value.startswith("0x") and len(value) == 66: + return value.lower() + return None + + +def _bytes32_arguments(call: DecodedCall) -> list[str]: + """Every bytes32 argument of a call, normalized to hex.""" + hexes = [] + for type_str, value in call.params: + if type_str != "bytes32": + continue + as_hex = _as_hex32(value) + if as_hex: + hexes.append(as_hex) + return hexes + + +def _requested_epochs(calls: list[DecodedCall], current_epoch: int) -> list[int]: + """Epochs named by setEpochEmissions calls, else the current epoch.""" + epochs = [ + int(value) + for call in calls + if call.function_name == "setEpochEmissions" + for type_str, value in call.params[:1] + if type_str.startswith("uint") and isinstance(value, int) + ] + return epochs or [current_epoch] + + +def _read_config_values(chain_id: int, target: str, keys: list[str]) -> dict[str, int]: + """Read ProtocolConfig values for hashed keys, batched. Empty dict on failure.""" + client = ChainManager.get_client(Chain.from_chain_id(chain_id)) + contract = client.get_contract(to_checksum_address(target), _CONFIG_ABI) + with client.batch_requests() as batch: + for key in keys: + batch.add(contract.functions.config(bytes.fromhex(key[2:]))) + values = client.execute_batch(batch) + return {key: int(value) for key, value in zip(keys, values)} + + +def _resolve_hashed_labels(chain_id: int, target: str, calls: list[DecodedCall]) -> list[HashedLabelContext]: + """Reverse known hashed labels passed as bytes32 arguments to one target.""" + hashes = [as_hex for call in calls for as_hex in _bytes32_arguments(call)] + known = [as_hex for as_hex in dict.fromkeys(hashes) if as_hex in _LABELS_BY_HASH] + if not known: + return [] + + is_config_key = _exposes(chain_id, target, {"config"}) + values: dict[str, int] = {} + if is_config_key: + try: + values = _read_config_values(chain_id, target, known) + except Exception as error: # noqa: BLE001 - the name alone is still useful + logger.info("3Jane config read failed for %s: %s", target, error) + + contexts = [] + for as_hex in known: + name, note = _LABELS_BY_HASH[as_hex] + contexts.append( + HashedLabelContext( + target=to_checksum_address(target), + argument_hex=as_hex, + name=name, + note=note, + is_config_key=is_config_key, + current_value=values.get(as_hex), + ) + ) + return contexts + + +def _read_distributor_context(chain_id: int, target: str, calls: list[DecodedCall]) -> RewardsDistributorContext | None: + """Read distribution mode, claim accounting, and emission history for a distributor.""" + if not _exposes(chain_id, target, _DISTRIBUTOR_GETTERS): + return None + + client = ChainManager.get_client(Chain.from_chain_id(chain_id)) + address = to_checksum_address(target) + distributor = client.get_contract(address, _DISTRIBUTOR_ABI) + with client.batch_requests() as batch: + batch.add(distributor.functions.useMint()) + batch.add(distributor.functions.merkleRoot()) + batch.add(distributor.functions.jane()) + batch.add(distributor.functions.maxClaimable()) + batch.add(distributor.functions.totalClaimed()) + batch.add(distributor.functions.epoch()) + use_mint, merkle_root, token_address, max_claimable, total_claimed, current_epoch = client.execute_batch(batch) + + token_address = to_checksum_address(str(token_address)) + metadata = fetch_erc20_metadata(chain_id, token_address) + if metadata is None: + logger.info("3Jane distributor %s: ERC20 metadata unavailable for %s", address, token_address) + return None + + epochs = sorted( + { + epoch - offset + for epoch in _requested_epochs(calls, int(current_epoch)) + for offset in range(EMISSION_HISTORY_EPOCHS + 1) + if epoch - offset >= 0 + } + ) + token = client.get_contract(token_address, _JANE_ABI) + with client.batch_requests() as batch: + batch.add(token.functions.totalSupply()) + batch.add(token.functions.balanceOf(address)) + batch.add(token.functions.hasRole(_MINTER_ROLE, address)) + batch.add(token.functions.transferable()) + for epoch in epochs: + batch.add(distributor.functions.epochEmissions(epoch)) + total_supply, balance, is_minter, transferable, *emissions = client.execute_batch(batch) + + return RewardsDistributorContext( + distributor_address=address, + token_address=token_address, + token_symbol=metadata.symbol, + token_decimals=metadata.decimals, + use_mint=bool(use_mint), + distributor_is_minter=bool(is_minter), + token_transferable=bool(transferable), + token_total_supply_raw=int(total_supply), + distributor_balance_raw=int(balance), + merkle_root="0x" + bytes(merkle_root).hex(), + max_claimable_raw=int(max_claimable), + total_claimed_raw=int(total_claimed), + current_epoch=int(current_epoch), + epoch_emissions=tuple((epoch, int(value)) for epoch, value in zip(epochs, emissions)), + ) + + +def resolve_threejane_context( + protocol: str, + chain_id: int, + targets_and_calls: list[tuple[str, DecodedCall]], +) -> list[ThreeJaneContext]: + """Resolve deterministic 3Jane governance context for the calls in one alert.""" + if protocol.lower() != PROTOCOL or chain_id != Chain.MAINNET.chain_id: + return [] + + calls_by_target: dict[str, list[DecodedCall]] = {} + for target, call in targets_and_calls: + try: + checksum = to_checksum_address(target) + except ValueError: + continue + calls_by_target.setdefault(checksum, []).append(call) + + contexts: list[ThreeJaneContext] = [] + for target, calls in calls_by_target.items(): + try: + distributor = _read_distributor_context(chain_id, target, calls) + if distributor is not None: + contexts.append(distributor) + contexts.extend(_resolve_hashed_labels(chain_id, target, calls)) + except Exception as error: # noqa: BLE001 - enrichment must never block an alert + logger.info("3Jane context resolution failed for %s: %s", target, error) + return contexts + + +def _distribution_mode_line(context: RewardsDistributorContext) -> str: + """State where claimed tokens come from, and whether that path is authorized.""" + if context.use_mint: + authority = "holds" if context.distributor_is_minter else "does NOT hold" + return ( + f"Distribution mode: useMint = true — claims MINT new {context.token_symbol}. " + f"The distributor {authority} MINTER_ROLE on the token, so its own balance " + f"({context.amount(context.distributor_balance_raw)}) is not the funding source." + ) + return ( + f"Distribution mode: useMint = false — claims TRANSFER from the distributor's own balance " + f"of {context.amount(context.distributor_balance_raw)}." + ) + + +def _emissions_line(context: RewardsDistributorContext) -> str: + """Recent on-chain emissions, so a new allocation can be judged against them.""" + rendered = ", ".join(f"epoch {epoch}: {context.amount(value)}" for epoch, value in context.epoch_emissions) + return f"Epoch emissions currently stored on-chain — {rendered}" + + +def format_threejane_prompt(contexts: list[ThreeJaneContext]) -> str: + """Render verified 3Jane context for the LLM prompt.""" + sections: list[str] = [] + for context in contexts: + if isinstance(context, RewardsDistributorContext): + sections.append( + "\n".join( + [ + f"RewardsDistributor: {context.distributor_address}", + _distribution_mode_line(context), + f"Reward token: {context.token_address} ({context.token_symbol}, " + f"{context.token_decimals} decimals), current totalSupply " + f"{context.amount(context.token_total_supply_raw)}", + f"Token transfers globally enabled: {str(context.token_transferable).lower()} " + "(when false, only TRANSFER_ROLE holders can move the token)", + f"Claim accounting: maxClaimable {context.amount(context.max_claimable_raw)}, " + f"totalClaimed {context.amount(context.total_claimed_raw)}, " + f"outstanding claimable {context.amount(context.outstanding_raw)}", + f"Current merkleRoot: {context.merkle_root}", + f"Current epoch: {context.current_epoch}", + _emissions_line(context), + ] + ) + ) + else: + line = f'bytes32 {context.argument_hex} on {context.target} = keccak256("{context.name}") — {context.note}' + if context.is_config_key: + value = "not readable" if context.current_value is None else str(context.current_value) + line += f"; value stored on-chain right now: {value}" + sections.append(line) + return "\n\n".join(sections) + + +def format_threejane_report( + contexts: list[ThreeJaneContext], + chain_id: int, + labels: dict[str, str], +) -> str: + """Render the deterministic 3Jane section for the gist report.""" + sections: list[str] = [] + for context in contexts: + if isinstance(context, RewardsDistributorContext): + lines = [ + f"- **Rewards distributor:** {address_link(context.distributor_address, chain_id, labels)}", + f"- **Reward token:** `{context.token_symbol}` ({context.token_decimals} decimals) — " + f"{address_link(context.token_address, chain_id)}", + f"- **Distribution mode:** `useMint = {str(context.use_mint).lower()}` — " + + ( + f"claims mint new {context.token_symbol}" + + ( + " (distributor holds `MINTER_ROLE`)" + if context.distributor_is_minter + else " (distributor does NOT hold `MINTER_ROLE`)" + ) + if context.use_mint + else f"claims transfer from the distributor's balance of `{context.amount(context.distributor_balance_raw)}`" + ), + f"- **Token supply:** `{context.amount(context.token_total_supply_raw)}` total, " + f"transfers globally enabled: `{str(context.token_transferable).lower()}`", + f"- **Claim accounting:** `maxClaimable {context.amount(context.max_claimable_raw)}` | " + f"`totalClaimed {context.amount(context.total_claimed_raw)}` | " + f"`outstanding {context.amount(context.outstanding_raw)}`", + f"- **Current `merkleRoot`:** `{context.merkle_root}`", + f"- **Current epoch:** `{context.current_epoch}`", + "- **Epoch emissions on-chain now:**", + ] + lines.extend(f" - Epoch `{epoch}`: `{context.amount(value)}`" for epoch, value in context.epoch_emissions) + sections.append("\n".join(lines)) + else: + lines = [ + f'- **`{context.argument_hex}`** = `keccak256("{context.name}")` — {context.note}', + f" - Target: {address_link(context.target, chain_id, labels)}", + ] + if context.is_config_key: + value = "not readable" if context.current_value is None else f"`{context.current_value:,}`" + lines.append(f" - Value stored on-chain right now: {value}") + sections.append("\n".join(lines)) + return "\n\n".join(sections) From b4330742a8a2f7d26ac9b2e4b3841efc7a4c1dc5 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 25 Aug 2026 22:35:29 +0200 Subject: [PATCH 2/4] refactor(llm): load 3Jane ABIs from checked-in JSON files The three inline ABI literals were 75 of the module's lines and pushed the resolvers off screen. Move them next to the protocol's existing ABIs and reuse ProtocolConfig.json rather than restating `config(bytes32)`. Loading is lazy and cached: this module sits in the explainer's import chain, so an unreadable file degrades one protocol's context instead of breaking every AI alert. Co-Authored-By: Claude Opus 5 (1M context) --- protocols/3jane/abi/Jane.json | 33 +++++++ protocols/3jane/abi/RewardsDistributor.json | 51 +++++++++++ tests/test_threejane_context.py | 17 ++++ utils/llm/threejane_context.py | 96 ++++----------------- 4 files changed, 120 insertions(+), 77 deletions(-) create mode 100644 protocols/3jane/abi/Jane.json create mode 100644 protocols/3jane/abi/RewardsDistributor.json diff --git a/protocols/3jane/abi/Jane.json b/protocols/3jane/abi/Jane.json new file mode 100644 index 00000000..63cb374b --- /dev/null +++ b/protocols/3jane/abi/Jane.json @@ -0,0 +1,33 @@ +[ + { + "inputs": [], + "name": "totalSupply", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "transferable", + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{"name": "account", "type": "address"}], + "name": "balanceOf", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + {"name": "role", "type": "bytes32"}, + {"name": "account", "type": "address"} + ], + "name": "hasRole", + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function" + } +] diff --git a/protocols/3jane/abi/RewardsDistributor.json b/protocols/3jane/abi/RewardsDistributor.json new file mode 100644 index 00000000..54dcf6a2 --- /dev/null +++ b/protocols/3jane/abi/RewardsDistributor.json @@ -0,0 +1,51 @@ +[ + { + "inputs": [], + "name": "useMint", + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "merkleRoot", + "outputs": [{"name": "", "type": "bytes32"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "jane", + "outputs": [{"name": "", "type": "address"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxClaimable", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalClaimed", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "epoch", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{"name": "epoch", "type": "uint256"}], + "name": "epochEmissions", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function" + } +] diff --git a/tests/test_threejane_context.py b/tests/test_threejane_context.py index 2b09e29e..5bb6812b 100644 --- a/tests/test_threejane_context.py +++ b/tests/test_threejane_context.py @@ -106,6 +106,23 @@ def test_epoch_falls_back_to_current(self) -> None: self.assertEqual(_requested_epochs([call], current_epoch=45), [45]) +class TestCheckedInAbis(unittest.TestCase): + """The JSON ABIs cover exactly the getters the adapter reads.""" + + def test_distributor_abi_covers_the_detection_getters(self) -> None: + names = {entry["name"] for entry in threejane_context._abi("RewardsDistributor")} + self.assertTrue(threejane_context._DISTRIBUTOR_GETTERS.issubset(names)) + self.assertIn("epoch", names) + + def test_jane_abi_covers_the_token_reads(self) -> None: + names = {entry["name"] for entry in threejane_context._abi("Jane")} + self.assertEqual(names, {"totalSupply", "transferable", "balanceOf", "hasRole"}) + + def test_protocol_config_abi_exposes_config(self) -> None: + names = {entry["name"] for entry in threejane_context._abi("ProtocolConfig")} + self.assertIn("config", names) + + class TestHashedLabelRendering(unittest.TestCase): """Known hashes are named; only config keys carry a stored value.""" diff --git a/utils/llm/threejane_context.py b/utils/llm/threejane_context.py index a3a09bd4..92ac9993 100644 --- a/utils/llm/threejane_context.py +++ b/utils/llm/threejane_context.py @@ -16,9 +16,11 @@ """ from dataclasses import dataclass +from functools import lru_cache from eth_utils import keccak, to_checksum_address +from utils.abi import load_abi from utils.calldata.decoder import DecodedCall from utils.chains import Chain from utils.erc20_metadata import fetch_erc20_metadata @@ -90,80 +92,20 @@ "0x" + keccak(text=name).hex(): (name, note) for name, note in _HASHED_LABELS.items() } -_CONFIG_ABI = [ - { - "name": "config", - "type": "function", - "stateMutability": "view", - "inputs": [{"name": "key", "type": "bytes32"}], - "outputs": [{"name": "", "type": "uint256"}], - } -] - -_DISTRIBUTOR_ABI = [ - {"name": "useMint", "type": "function", "stateMutability": "view", "inputs": [], "outputs": [{"type": "bool"}]}, - { - "name": "merkleRoot", - "type": "function", - "stateMutability": "view", - "inputs": [], - "outputs": [{"type": "bytes32"}], - }, - {"name": "jane", "type": "function", "stateMutability": "view", "inputs": [], "outputs": [{"type": "address"}]}, - { - "name": "maxClaimable", - "type": "function", - "stateMutability": "view", - "inputs": [], - "outputs": [{"type": "uint256"}], - }, - { - "name": "totalClaimed", - "type": "function", - "stateMutability": "view", - "inputs": [], - "outputs": [{"type": "uint256"}], - }, - {"name": "epoch", "type": "function", "stateMutability": "view", "inputs": [], "outputs": [{"type": "uint256"}]}, - { - "name": "epochEmissions", - "type": "function", - "stateMutability": "view", - "inputs": [{"type": "uint256"}], - "outputs": [{"type": "uint256"}], - }, -] - -_JANE_ABI = [ - { - "name": "totalSupply", - "type": "function", - "stateMutability": "view", - "inputs": [], - "outputs": [{"type": "uint256"}], - }, - { - "name": "transferable", - "type": "function", - "stateMutability": "view", - "inputs": [], - "outputs": [{"type": "bool"}], - }, - { - "name": "balanceOf", - "type": "function", - "stateMutability": "view", - "inputs": [{"type": "address"}], - "outputs": [{"type": "uint256"}], - }, - { - "name": "hasRole", - "type": "function", - "stateMutability": "view", - "inputs": [{"type": "bytes32"}, {"type": "address"}], - "outputs": [{"type": "bool"}], - }, -] +ABI_DIR = "protocols/3jane/abi" + + +@lru_cache(maxsize=None) +def _abi(name: str) -> list[dict]: + """Load a checked-in 3Jane ABI once per process. + + Lazily, not at import: this module sits in the explainer's import chain, and + a missing or unreadable file should degrade one protocol's context rather + than break every AI alert. + """ + entries: list[dict] = load_abi(f"{ABI_DIR}/{name}.json") + return entries + _MINTER_ROLE = keccak(text="MINTER_ROLE") @@ -305,7 +247,7 @@ def _requested_epochs(calls: list[DecodedCall], current_epoch: int) -> list[int] def _read_config_values(chain_id: int, target: str, keys: list[str]) -> dict[str, int]: """Read ProtocolConfig values for hashed keys, batched. Empty dict on failure.""" client = ChainManager.get_client(Chain.from_chain_id(chain_id)) - contract = client.get_contract(to_checksum_address(target), _CONFIG_ABI) + contract = client.get_contract(to_checksum_address(target), _abi("ProtocolConfig")) with client.batch_requests() as batch: for key in keys: batch.add(contract.functions.config(bytes.fromhex(key[2:]))) @@ -351,7 +293,7 @@ def _read_distributor_context(chain_id: int, target: str, calls: list[DecodedCal client = ChainManager.get_client(Chain.from_chain_id(chain_id)) address = to_checksum_address(target) - distributor = client.get_contract(address, _DISTRIBUTOR_ABI) + distributor = client.get_contract(address, _abi("RewardsDistributor")) with client.batch_requests() as batch: batch.add(distributor.functions.useMint()) batch.add(distributor.functions.merkleRoot()) @@ -375,7 +317,7 @@ def _read_distributor_context(chain_id: int, target: str, calls: list[DecodedCal if epoch - offset >= 0 } ) - token = client.get_contract(token_address, _JANE_ABI) + token = client.get_contract(token_address, _abi("Jane")) with client.batch_requests() as batch: batch.add(token.functions.totalSupply()) batch.add(token.functions.balanceOf(address)) From 0e1a77b7f89cdf9de593a79a244eda26930f5831 Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 25 Aug 2026 22:44:09 +0200 Subject: [PATCH 3/4] perf(llm): read the 3Jane proxy slot once per alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One alert probes the same target for both shapes it can take, and each probe followed EIP-1967 independently — two identical eth_getStorageAt calls for a setConfig alert. Split the ABI lookup into own-ABI and implementation-ABI halves and memoize both, so the slot is read only when the proxy ABI comes up short and only once per address. A target that is not a proxy still never reads the slot: adapter cost for a setConfig alert drops from 3 round trips to 2, and the distributor path is unchanged at 2 batched requests. Context output is byte-identical. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_threejane_context.py | 36 ++++++++++++++++++++++++ utils/llm/threejane_context.py | 50 ++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/tests/test_threejane_context.py b/tests/test_threejane_context.py index 5bb6812b..e9a238b2 100644 --- a/tests/test_threejane_context.py +++ b/tests/test_threejane_context.py @@ -106,6 +106,42 @@ def test_epoch_falls_back_to_current(self) -> None: self.assertEqual(_requested_epochs([call], current_epoch=45), [45]) +class TestAbiProbeCaching(unittest.TestCase): + """One alert probes a target for several shapes; the slot is read once.""" + + def setUp(self) -> None: + threejane_context.reset_cache() + + def tearDown(self) -> None: + threejane_context.reset_cache() + + def test_implementation_is_read_once_per_address(self) -> None: + proxy_abi = [{"type": "function", "name": "upgradeToAndCall"}] + impl_abi = [{"type": "function", "name": "config"}] + + def abi_for(chain_id: int, address: str) -> list[dict]: + return impl_abi if address == "0ximpl" else proxy_abi + + with ( + patch.object(threejane_context, "fetch_abi_entries", side_effect=abi_for), + patch("utils.proxy.get_current_implementation", return_value="0ximpl") as lookup, + ): + self.assertFalse(threejane_context._exposes(1, PROTOCOL_CONFIG, {"useMint", "merkleRoot"})) + self.assertTrue(threejane_context._exposes(1, PROTOCOL_CONFIG, {"config"})) + + lookup.assert_called_once() + + def test_non_proxy_never_reads_the_slot(self) -> None: + own_abi = [{"type": "function", "name": "useMint"}] + with ( + patch.object(threejane_context, "fetch_abi_entries", return_value=own_abi), + patch("utils.proxy.get_current_implementation") as lookup, + ): + self.assertTrue(threejane_context._exposes(1, DISTRIBUTOR, {"useMint"})) + + lookup.assert_not_called() + + class TestCheckedInAbis(unittest.TestCase): """The JSON ABIs cover exactly the getters the adapter reads.""" diff --git a/utils/llm/threejane_context.py b/utils/llm/threejane_context.py index 92ac9993..eed5733c 100644 --- a/utils/llm/threejane_context.py +++ b/utils/llm/threejane_context.py @@ -186,9 +186,33 @@ def amount(self, raw: int) -> str: ThreeJaneContext = HashedLabelContext | RewardsDistributorContext -def _abi_function_names(entries: list[dict]) -> set[str]: +def _abi_function_names(entries: list[dict]) -> frozenset[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")} + return frozenset( + str(entry.get("name")) for entry in entries if entry.get("type") == "function" and entry.get("name") + ) + + +@lru_cache(maxsize=64) +def _own_function_names(chain_id: int, address: str) -> frozenset[str]: + """Function names on the address's own verified ABI. No RPC — Etherscan is cached.""" + return _abi_function_names(fetch_abi_entries(chain_id, address) or []) + + +@lru_cache(maxsize=64) +def _implementation_function_names(chain_id: int, address: str) -> frozenset[str]: + """Function names behind an EIP-1967 proxy, or empty when there is no proxy. + + Cached because one alert probes the same target for several shapes — the + slot read is identical every time, and one governance transaction cannot + change the implementation it is still only scheduled against. + """ + from utils.proxy import get_current_implementation + + implementation = get_current_implementation(address, chain_id) + if not implementation or implementation.lower() == address.lower(): + return frozenset() + return _abi_function_names(fetch_abi_entries(chain_id, implementation) or []) def _exposes(chain_id: int, address: str, wanted: set[str]) -> bool: @@ -196,19 +220,12 @@ def _exposes(chain_id: int, address: str, wanted: set[str]) -> bool: 3Jane's ProtocolConfig and MorphoCredit sit behind transparent proxies, so the address's own verified ABI lists the proxy's functions, not `config` or - the distributor getters. Only pay for the implementation lookup when the - proxy ABI comes up short. + the distributor getters. The implementation is only read when the proxy ABI + comes up short, and then only once per address. """ - names = _abi_function_names(fetch_abi_entries(chain_id, address) or []) - if wanted.issubset(names): + if wanted.issubset(_own_function_names(chain_id, address)): return True - - from utils.proxy import get_current_implementation - - implementation = get_current_implementation(address, chain_id) - if not implementation or implementation.lower() == address.lower(): - return False - return wanted.issubset(_abi_function_names(fetch_abi_entries(chain_id, implementation) or [])) + return wanted.issubset(_implementation_function_names(chain_id, address)) def _as_hex32(value: object) -> str | None: @@ -473,3 +490,10 @@ def format_threejane_report( lines.append(f" - Value stored on-chain right now: {value}") sections.append("\n".join(lines)) return "\n\n".join(sections) + + +def reset_cache() -> None: + """Reset process caches for tests or long-running workers.""" + _abi.cache_clear() + _own_function_names.cache_clear() + _implementation_function_names.cache_clear() From 641457af4b4d92905ea3090abf8552d7ad39b69d Mon Sep 17 00:00:00 2001 From: spalen0 Date: Tue, 25 Aug 2026 22:50:42 +0200 Subject: [PATCH 4/4] feat(llm): derive 3Jane emission cadence and cap headroom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three consecutive weeks of the identical setEpochEmissions + updateRoot pair scored LOW, MEDIUM, MEDIUM. Emissions move in a ±3% weekly band, so each allocation is unremarkable in series and "sizable" in isolation — the reports had no series to read. - derive how the proposed allocation compares to the epoch before it, and whether the funded epoch is past, current, or future; no extra reads, the history was already fetched - render a capping key beside what it caps, batched into the existing config request: USD3_SUPPLY_CAP next to USD3 totalAssets, which is what separates a routine ceiling raise from one that unblocks deposits DEBT_CAP is deliberately not registered — its denomination against the market's borrow accounting is unconfirmed, and a wrong unit is worse than no comparison. Co-Authored-By: Claude Opus 5 (1M context) --- protocols/3jane/README.md | 3 +- tests/test_threejane_context.py | 45 ++++++++++++++ utils/llm/README.md | 3 +- utils/llm/threejane_context.py | 105 +++++++++++++++++++++++++++----- 4 files changed, 139 insertions(+), 17 deletions(-) diff --git a/protocols/3jane/README.md b/protocols/3jane/README.md index 96693675..e62f1286 100644 --- a/protocols/3jane/README.md +++ b/protocols/3jane/README.md @@ -103,7 +103,8 @@ Those alerts carry a 3Jane `Protocol Context` section built by [`utils/llm/threejane_context.py`](../../utils/llm/threejane_context.py): - `bytes32` arguments are reversed to their `keccak256` pre-image, so a `setConfig` call names the parameter (`MAX_LTV`, `IS_PAUSED`, `DEBT_CAP`, …) and a `grantRole` names the role, each with what it controls. `ProtocolConfig` keys also carry the value stored on-chain right now. -- `RewardsDistributor` calls carry the distribution mode (`useMint`), whether the distributor holds `MINTER_ROLE` on JANE, JANE supply and whether transfers are globally enabled, `maxClaimable` / `totalClaimed` / outstanding, the current `merkleRoot` and epoch, and the emissions stored for the three preceding epochs. +- `RewardsDistributor` calls carry the distribution mode (`useMint`), whether the distributor holds `MINTER_ROLE` on JANE, JANE supply and whether transfers are globally enabled, `maxClaimable` / `totalClaimed` / outstanding, the current `merkleRoot` and epoch, the emissions stored for the three preceding epochs, and how the proposed allocation compares to the epoch before it. +- A key that caps a quantity is rendered next to that quantity — `USD3_SUPPLY_CAP` alongside USD3 `totalAssets`, batched into the same request — so a ceiling raise reads as slack or as unblocking deposits. Register more in `_USAGE_READS`, but only where both sides are denominated the same way. Add a key or role to `_HASHED_LABELS` in that module when 3Jane introduces one; the hash is derived from the name, so the table cannot drift. diff --git a/tests/test_threejane_context.py b/tests/test_threejane_context.py index e9a238b2..0a8d947d 100644 --- a/tests/test_threejane_context.py +++ b/tests/test_threejane_context.py @@ -1,6 +1,7 @@ """Tests for 3Jane-specific LLM governance context.""" import unittest +from dataclasses import replace from unittest.mock import patch from eth_utils import keccak @@ -57,6 +58,7 @@ def _distributor_context(use_mint: bool = True, is_minter: bool = True) -> Rewar total_claimed_raw=38_919_583 * WAD, current_epoch=45, epoch_emissions=((43, 5_369_214 * WAD), (44, 5_499_673 * WAD), (45, 0)), + proposed_emissions=((45, 5_564_323 * WAD),), ) @@ -195,6 +197,32 @@ def test_report_links_the_target(self) -> None: self.assertIn("3Jane ProtocolConfig", report) self.assertIn("`63,366,281,225,814`", report) + def test_capped_quantity_is_rendered_beside_the_cap(self) -> None: + context = HashedLabelContext( + target=PROTOCOL_CONFIG, + argument_hex="0x" + keccak(text="USD3_SUPPLY_CAP").hex(), + name="USD3_SUPPLY_CAP", + note="cap on USD3 supply in asset units", + is_config_key=True, + current_value=75_000_000_000_000, + usage_label="USD3 totalAssets", + current_usage=75_045_566_960_234, + ) + prompt = format_threejane_prompt([context]) + report = format_threejane_report([context], 1, {}) + self.assertIn("USD3 totalAssets right now: 75045566960234", prompt) + self.assertIn("directly comparable", prompt) + self.assertIn("`75,045,566,960,234`", report) + + def test_usage_line_absent_for_keys_without_one(self) -> None: + context = HashedLabelContext(PROTOCOL_CONFIG, "0xabc", "MAX_LTV", "ltv", True, 350000000000000000) + self.assertNotIn("directly comparable", format_threejane_prompt([context])) + self.assertNotIn("totalAssets", format_threejane_report([context], 1, {})) + + def test_usage_reads_are_registered_for_known_keys_only(self) -> None: + for as_hex in threejane_context._USAGE_READS: + self.assertIn(as_hex, threejane_context._LABELS_BY_HASH) + def test_every_known_label_hashes_to_its_own_entry(self) -> None: for as_hex, (name, note) in threejane_context._LABELS_BY_HASH.items(): self.assertEqual(as_hex, "0x" + keccak(text=name).hex()) @@ -241,6 +269,23 @@ def test_report_lists_accounting_and_links_token(self) -> None: self.assertIn("maxClaimable 84,649,011 JANE", report) self.assertIn(f"https://etherscan.io/address/{JANE}", report) + def test_cadence_line_compares_against_the_previous_epoch(self) -> None: + prompt = format_threejane_prompt([_distributor_context()]) + self.assertIn("Epoch 45 is the current epoch.", prompt) + self.assertIn("Proposed 5,564,323 JANE is +1.2% versus epoch 44's 5,499,673 JANE.", prompt) + + def test_cadence_line_flags_a_past_epoch(self) -> None: + context = replace(_distributor_context(), current_epoch=47) + self.assertIn("a past epoch (current is 47)", format_threejane_prompt([context])) + + def test_cadence_line_without_a_previous_allocation(self) -> None: + context = replace(_distributor_context(), epoch_emissions=((44, 0), (45, 0))) + self.assertIn("no allocation stored for epoch 44 to compare against", format_threejane_prompt([context])) + + def test_cadence_line_absent_when_no_emissions_proposed(self) -> None: + context = replace(_distributor_context(), proposed_emissions=()) + self.assertNotIn("Proposed", format_threejane_prompt([context])) + def test_context_contributes_addresses_and_labels(self) -> None: context = _distributor_context() self.assertEqual(context.addresses, [DISTRIBUTOR, JANE]) diff --git a/utils/llm/README.md b/utils/llm/README.md index 74c580b3..d251788e 100644 --- a/utils/llm/README.md +++ b/utils/llm/README.md @@ -194,7 +194,8 @@ For 3Jane mainnet alerts, the adapter: 1. Reverses every `bytes32` argument against a checked-in name table (`ProtocolConfig` keys plus the Jane / EmergencyController roles), so the prompt carries `keccak256("MAX_LTV")` and what that key controls instead of a bare hash. Hashes outside the table stay unresolved rather than being guessed at. 2. Reads the current stored value for resolved `ProtocolConfig` keys, following EIP-1967 to the implementation ABI since the config sits behind a transparent proxy. Role hashes get no value line — there is nothing to read. 3. Identifies a `RewardsDistributor` by its verified getters and reads `useMint`, the reward token's metadata and `totalSupply`, whether the distributor holds `MINTER_ROLE`, whether token transfers are globally enabled, `maxClaimable` / `totalClaimed`, the current `merkleRoot`, and the current epoch. -4. Reads emissions already stored for the epoch being set and the three before it, so a new allocation is judged against recent ones rather than called "substantial in absolute terms". +4. Reads emissions already stored for the epoch being set and the three before it, and derives how the proposed allocation compares to the epoch before it, so a new allocation is judged against recent ones rather than called "substantial in absolute terms". Three consecutive weeks of this same operation had previously scored LOW, MEDIUM, MEDIUM. +5. Renders a capping key beside the quantity it caps (`USD3_SUPPLY_CAP` next to USD3 `totalAssets`), batched into the config read, so a ceiling raise reads as slack or as unblocking deposits. `_USAGE_READS` holds only pairs whose denominations are known to match. Token amounts are truncated to whole tokens, matching the call flow's amount hints. Failures are best-effort and never block the governance alert. diff --git a/utils/llm/threejane_context.py b/utils/llm/threejane_context.py index eed5733c..96f71a09 100644 --- a/utils/llm/threejane_context.py +++ b/utils/llm/threejane_context.py @@ -109,6 +109,15 @@ def _abi(name: str) -> list[dict]: _MINTER_ROLE = keccak(text="MINTER_ROLE") +# A cap only reads as slack or binding next to what it is capping. USD3's cap +# and its totalAssets are both denominated in the vault's 6-decimal asset, so +# the two are directly comparable; DEBT_CAP is deliberately absent until its +# denomination against the market's borrow accounting is confirmed. +USD3_ADDRESS = "0x056B269Eb1f75477a8666ae8C7fE01b64dD55eCc" +_USAGE_READS: dict[str, tuple[str, str, str]] = { + "0x" + keccak(text="USD3_SUPPLY_CAP").hex(): (USD3_ADDRESS, "ERC4626Vault", "USD3 totalAssets"), +} + _DISTRIBUTOR_GETTERS = {"useMint", "merkleRoot", "jane", "maxClaimable", "totalClaimed", "epochEmissions"} @@ -123,6 +132,9 @@ class HashedLabelContext: # Set only for ProtocolConfig keys; a role hash has no value to read. is_config_key: bool = False current_value: int | None = None + # What the key is capping, when the two are denominated the same way. + usage_label: str = "" + current_usage: int | None = None @property def addresses(self) -> list[str]: @@ -151,6 +163,8 @@ class RewardsDistributorContext: total_claimed_raw: int current_epoch: int epoch_emissions: tuple[tuple[int, int], ...] + # (epoch, emissions) this transaction proposes, straight from the calldata. + proposed_emissions: tuple[tuple[int, int], ...] @property def addresses(self) -> list[str]: @@ -168,6 +182,33 @@ def outstanding_raw(self) -> int: """Allocated but not yet claimed — the distributor's remaining claim ceiling.""" return max(self.max_claimable_raw - self.total_claimed_raw, 0) + def cadence_lines(self) -> list[str]: + """State how each proposed allocation compares to the epoch before it. + + Emissions run in a tight weekly band, so the same routine allocation has + been called "substantial in absolute terms" one week and routine the + next. Deriving the comparison here means the verdict rests on the + series rather than on the model's own arithmetic. + """ + stored = dict(self.epoch_emissions) + lines = [] + for epoch, proposed in self.proposed_emissions: + timing = ( + "the current epoch" + if epoch == self.current_epoch + else f"a past epoch (current is {self.current_epoch})" + if epoch < self.current_epoch + else f"a future epoch (current is {self.current_epoch})" + ) + previous = stored.get(epoch - 1, 0) + if previous > 0: + delta = (proposed - previous) / previous * 100 + comparison = f"{delta:+.1f}% versus epoch {epoch - 1}'s {self.amount(previous)}" + else: + comparison = f"no allocation stored for epoch {epoch - 1} to compare against" + lines.append(f"Epoch {epoch} is {timing}. Proposed {self.amount(proposed)} is {comparison}.") + return lines + def amount(self, raw: int) -> str: """Render a raw token amount with this token's verified decimals. @@ -249,27 +290,46 @@ def _bytes32_arguments(call: DecodedCall) -> list[str]: return hexes +def _proposed_emissions(calls: list[DecodedCall]) -> list[tuple[int, int]]: + """(epoch, emissions) pairs each setEpochEmissions call proposes.""" + proposed = [] + for call in calls: + if call.function_name != "setEpochEmissions" or len(call.params) < 2: + continue + (epoch_type, epoch), (value_type, value) = call.params[0], call.params[1] + if not (epoch_type.startswith("uint") and value_type.startswith("uint")): + continue + if isinstance(epoch, int) and isinstance(value, int): + proposed.append((int(epoch), int(value))) + return proposed + + def _requested_epochs(calls: list[DecodedCall], current_epoch: int) -> list[int]: """Epochs named by setEpochEmissions calls, else the current epoch.""" - epochs = [ - int(value) - for call in calls - if call.function_name == "setEpochEmissions" - for type_str, value in call.params[:1] - if type_str.startswith("uint") and isinstance(value, int) - ] - return epochs or [current_epoch] - - -def _read_config_values(chain_id: int, target: str, keys: list[str]) -> dict[str, int]: - """Read ProtocolConfig values for hashed keys, batched. Empty dict on failure.""" + return [epoch for epoch, _ in _proposed_emissions(calls)] or [current_epoch] + + +def _read_config_state(chain_id: int, target: str, keys: list[str]) -> tuple[dict[str, int], dict[str, int]]: + """Read config values, plus what any capped quantity currently stands at. + + Both come back in one batched request: a cap read without its usage costs + the same round trip and leaves the reader unable to tell a routine ceiling + raise from one that unblocks a queue. + """ client = ChainManager.get_client(Chain.from_chain_id(chain_id)) contract = client.get_contract(to_checksum_address(target), _abi("ProtocolConfig")) + usage_keys = [key for key in keys if key in _USAGE_READS] with client.batch_requests() as batch: for key in keys: batch.add(contract.functions.config(bytes.fromhex(key[2:]))) - values = client.execute_batch(batch) - return {key: int(value) for key, value in zip(keys, values)} + for key in usage_keys: + address, abi_name, _ = _USAGE_READS[key] + batch.add(client.get_contract(to_checksum_address(address), _abi(abi_name)).functions.totalAssets()) + results = client.execute_batch(batch) + + values = {key: int(value) for key, value in zip(keys, results[: len(keys)])} + usage = {key: int(value) for key, value in zip(usage_keys, results[len(keys) :])} + return values, usage def _resolve_hashed_labels(chain_id: int, target: str, calls: list[DecodedCall]) -> list[HashedLabelContext]: @@ -281,9 +341,10 @@ def _resolve_hashed_labels(chain_id: int, target: str, calls: list[DecodedCall]) is_config_key = _exposes(chain_id, target, {"config"}) values: dict[str, int] = {} + usage: dict[str, int] = {} if is_config_key: try: - values = _read_config_values(chain_id, target, known) + values, usage = _read_config_state(chain_id, target, known) except Exception as error: # noqa: BLE001 - the name alone is still useful logger.info("3Jane config read failed for %s: %s", target, error) @@ -298,6 +359,8 @@ def _resolve_hashed_labels(chain_id: int, target: str, calls: list[DecodedCall]) note=note, is_config_key=is_config_key, current_value=values.get(as_hex), + usage_label=_USAGE_READS[as_hex][2] if as_hex in _USAGE_READS else "", + current_usage=usage.get(as_hex), ) ) return contexts @@ -359,6 +422,7 @@ def _read_distributor_context(chain_id: int, target: str, calls: list[DecodedCal total_claimed_raw=int(total_claimed), current_epoch=int(current_epoch), epoch_emissions=tuple((epoch, int(value)) for epoch, value in zip(epochs, emissions)), + proposed_emissions=tuple(_proposed_emissions(calls)), ) @@ -433,6 +497,7 @@ def format_threejane_prompt(contexts: list[ThreeJaneContext]) -> str: f"Current merkleRoot: {context.merkle_root}", f"Current epoch: {context.current_epoch}", _emissions_line(context), + *context.cadence_lines(), ] ) ) @@ -441,6 +506,11 @@ def format_threejane_prompt(contexts: list[ThreeJaneContext]) -> str: if context.is_config_key: value = "not readable" if context.current_value is None else str(context.current_value) line += f"; value stored on-chain right now: {value}" + if context.current_usage is not None: + line += ( + f"; {context.usage_label} right now: {context.current_usage} " + "(same units as the key, so the two are directly comparable)" + ) sections.append(line) return "\n\n".join(sections) @@ -479,6 +549,7 @@ def format_threejane_report( "- **Epoch emissions on-chain now:**", ] lines.extend(f" - Epoch `{epoch}`: `{context.amount(value)}`" for epoch, value in context.epoch_emissions) + lines.extend(f"- **Proposed:** {line}" for line in context.cadence_lines()) sections.append("\n".join(lines)) else: lines = [ @@ -488,6 +559,10 @@ def format_threejane_report( if context.is_config_key: value = "not readable" if context.current_value is None else f"`{context.current_value:,}`" lines.append(f" - Value stored on-chain right now: {value}") + if context.current_usage is not None: + lines.append( + f" - {context.usage_label} right now: `{context.current_usage:,}` (same units as the key)" + ) sections.append("\n".join(lines)) return "\n\n".join(sections)