Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions protocols/pendle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ Additionally, other contracts like vePENDLE, PENDLE, RewardDistributor, and Voti
Arbitrum Safe Multisig: 0x7877AdFaDEd756f3248a0EBfe8Ac2E2eF87b75Ac

The owner of SY contracts was changed to [governance proxy contract](https://etherscan.io/address/0x2aD631F72fB16d91c4953A7f4260A97C2fE2f31e) with an additional guardian role that can only pause SY contracts. The governance proxy contract owner is multisig defined above.

## AI governance context

PendleSwap upgrade alerts on Ethereum and Arbitrum include the proxy's live owner,
current/proposed verified swap and authorization code, and a pinned reference for
the standard router integration. This distinguishes the optional aggregator leg
from market, PT/YT and SY contracts, and makes swap payload/enum changes visible
even when the external ABI is unchanged. The context is included in the AI prompt
and full report; it does not impose a risk rating. See
[the LLM context documentation](../../utils/llm/README.md#5f-4-pendleswap-upgrade-context-utilsllmpendle_contextpy).
233 changes: 233 additions & 0 deletions tests/test_pendle_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"""PendleSwap context behavior using small, synthetic source bundles."""

from collections.abc import Iterator
from dataclasses import replace
from unittest.mock import MagicMock, patch

import pytest

from utils.calldata.decoder import DecodedCall
from utils.llm import pendle_context
from utils.llm.pendle_context import (
PENDLE_SWAP,
_source_evidence,
format_pendle_prompt,
resolve_pendle_context,
)
from utils.llm.protocol_context import resolve_protocol_context
from utils.verified_contract import VerifiedContract

OLD = "0xBC17404b7bb500051c75C83E4aA5aE447D967811"
NEW = "0xD14feb6Aaf8650BbfcC8aBEc299B249a80FE7C78"
OWNER = "0x8119EC16F0573B7dAc7C0CB94EB504FB32456ee1"
type Boundaries = tuple[MagicMock, MagicMock, MagicMock]

_OLD_SOURCE = """
contract PendleSwap {
function swap() external { _getScaledInputData(SwapType.ODOS); }
function _getScaledInputData(SwapType swapType) internal {
if (swapType == SwapType.ODOS) { _odosScaling(); } else { assert(false); }
}
function _authorizeUpgrade(address) internal onlyOwner {}
}
"""
_NEW_SOURCE = """
contract PendleSwap {
function swap() external { _zeroExSwap(); }
function _zeroExSwap() internal { _transferOut(tokenOut, msg.sender, netOut); }
function _getScaledInputData(SwapType swapType) internal { assert(false); }
function _authorizeUpgrade(address) internal onlyOwner {}
}
"""
_OWNERSHIP_SOURCE = """
abstract contract BoringOwnableUpgradeableV2 {
modifier onlyOwner() { require(msg.sender == owner, "not owner"); _; }
}
"""


def _record(name: str) -> VerifiedContract:
"""Build only the source declarations the adapter consumes, without cache metadata."""
swap_types = "NONE, KYBERSWAP, RESERVE_1, ZEROX" if name == "new" else "NONE, KYBERSWAP, ODOS, RESERVE_2"
return VerifiedContract(
contract_name="PendleSwap",
compiler_version="",
language="Solidity",
contract_file="PendleSwap.sol",
sources={
"PendleSwap.sol": _NEW_SOURCE if name == "new" else _OLD_SOURCE,
"IPSwapAggregator.sol": (
"struct SwapData { SwapType swapType; address extRouter; bytes extCalldata; bool needScale; }\n"
f"enum SwapType {{ {swap_types} }}\ninterface IPSwapAggregator {{}}"
),
"BoringOwnableUpgradeableV2.sol": _OWNERSHIP_SOURCE,
},
)


def _upgrade(migrate: bool = False) -> DecodedCall:
"""A decoded upgrade with or without an initialization payload."""
if migrate:
return DecodedCall(
"upgradeToAndCall", "upgradeToAndCall(address,bytes)", [("address", NEW), ("bytes", b"\x01")]
)
return DecodedCall("upgradeTo", "upgradeTo(address)", [("address", NEW)])


@pytest.fixture
def boundaries() -> Iterator[Boundaries]:
"""Replace RPC and source fetches while retaining all context resolution logic."""
with (
patch.object(pendle_context, "get_current_implementation", return_value=OLD) as implementation,
patch.object(pendle_context, "fetch_verified_contract") as source,
patch.object(pendle_context.ChainManager, "get_client") as client,
):
source.side_effect = lambda chain, address: _record("old" if address.lower() == OLD.lower() else "new")
client.return_value.eth.contract.return_value.functions.owner.return_value.call.return_value = OWNER
yield implementation, source, client


@pytest.mark.parametrize("chain_id", [1, 42161])
def test_upgrade_includes_scope_controls_and_route_semantics(boundaries: Boundaries, chain_id: int) -> None:
"""Unchanged ABI must not hide enum changes, unsupported scaling or existing authorization."""
resolved = resolve_protocol_context("PENDLE", chain_id, [(PENDLE_SWAP.lower(), _upgrade())])
prompt = resolved.prompt
assert "not a live trace" in prompt
assert f"Current proxy owner() (live read): {OWNER}" in prompt
assert "_authorizeUpgrade(address) internal onlyOwner" in prompt
assert 'require(msg.sender == owner, "not owner")' in prompt
before, after = prompt.split("Proposed implementation evidence:")
assert "SwapType.ODOS" in before
assert "RESERVE_2" in before
assert "RESERVE_1" in after
assert "ZEROX" in after
assert "SwapType.ODOS" not in after
assert "assert(false)" in after
assert "_transferOut(tokenOut, msg.sender, netOut)" in after
explorer = "etherscan.io" if chain_id == 1 else "arbiscan.io"
for address in (PENDLE_SWAP, OLD, NEW, OWNER):
assert address in resolved.addresses
assert f"https://{explorer}/address/{address}" in resolved.report
assert resolved.labels[PENDLE_SWAP] == "PendleSwap"
contract_call = boundaries[2].return_value.eth.contract.call_args
assert contract_call.kwargs["address"] == PENDLE_SWAP


@pytest.mark.parametrize(
"protocol,chain,target,call",
[
("yearn", 1, PENDLE_SWAP, _upgrade()),
("pendle", 10, PENDLE_SWAP, _upgrade()),
("pendle", 1, OWNER, _upgrade()),
("pendle", 1, PENDLE_SWAP, DecodedCall("swap", "swap(address,uint256)", [])),
("pendle", 1, PENDLE_SWAP, DecodedCall("upgradeTo", "upgradeTo(address)", [])),
("pendle", 1, PENDLE_SWAP, DecodedCall("upgradeTo", "upgradeTo(address)", [("uint256", 1)])),
("pendle", 1, PENDLE_SWAP, DecodedCall("upgradeTo", "upgradeTo(address)", [("address", "bad")])),
],
)
def test_unrelated_or_malformed_calls_make_no_network_requests(
boundaries: Boundaries,
protocol: str,
chain: int,
target: str,
call: DecodedCall,
) -> None:
"""Scope checks run before all RPC/source work."""
assert resolve_pendle_context(protocol, chain, [(target, call)]) == []
for boundary in boundaries:
boundary.assert_not_called()


def test_empty_batch_makes_no_network_requests(boundaries: Boundaries) -> None:
"""Empty alerts need no enrichment."""
assert resolve_pendle_context("pendle", 1, []) == []
for boundary in boundaries:
boundary.assert_not_called()


def test_duplicate_calls_and_migration_are_distinguished(boundaries: Boundaries) -> None:
"""A Safe batch retains migration context while repeated identical upgrades are coalesced."""
contexts = resolve_pendle_context(
"pendle",
1,
[
(PENDLE_SWAP, _upgrade()),
(PENDLE_SWAP, _upgrade()),
(PENDLE_SWAP, _upgrade(True)),
],
)
assert len(contexts) == 2
prompt = format_pendle_prompt(contexts)
assert "upgradeTo has no initialization/migration payload" in prompt
assert "upgradeToAndCall includes a bytes payload" in prompt


def test_owner_read_failure_preserves_source_evidence(boundaries: Boundaries) -> None:
"""An unavailable owner is explicit and cannot remove the code comparison."""
boundaries[2].return_value.eth.contract.return_value.functions.owner.return_value.call.side_effect = RuntimeError(
"RPC failed"
)
contexts = resolve_pendle_context("pendle", 1, [(PENDLE_SWAP, _upgrade())])
prompt = format_pendle_prompt(contexts)
assert "Current proxy owner() (live read): unavailable" in prompt
assert "_zeroExSwap" in prompt
assert contexts[0].owner is None


def test_missing_source_and_implementation_are_explicit(boundaries: Boundaries) -> None:
"""Missing evidence never turns into a guessed old implementation or safe-storage claim."""
boundaries[0].return_value = None
boundaries[1].side_effect = None
boundaries[1].return_value = None
contexts = resolve_pendle_context("pendle", 1, [(PENDLE_SWAP, _upgrade())])
prompt = format_pendle_prompt(contexts)
assert "Current implementation: unavailable" in prompt
assert prompt.count("target source unavailable") == 2


def test_failed_batch_member_does_not_hide_next_upgrade(boundaries: Boundaries) -> None:
"""Source/RPC errors remain local to a batch member."""
boundaries[0].side_effect = [RuntimeError("RPC failed"), OLD]
contexts = resolve_pendle_context("pendle", 1, [(PENDLE_SWAP, _upgrade()), (PENDLE_SWAP, _upgrade(True))])
assert len(contexts) == 1
assert contexts[0].is_upgrade_and_call


@pytest.mark.parametrize("flattened", [False, True])
def test_source_excerpts_exclude_unrelated_contracts(flattened: bool) -> None:
"""Only the deployed target and relevant declarations belong in context, in either source format."""
record = _record("new")
sources = dict(record.sources)
sources["PendleSwap.sol"] += '\ncontract Decoy { function swap() external { revert("DECOY"); } }'
if flattened:
record = replace(record, sources={"flat.sol": "\n".join(sources.values())}, contract_file="flat.sol")
else:
record = replace(record, sources=sources)
evidence = _source_evidence(record)
assert "DECOY" not in evidence
assert "enum SwapType" in evidence
assert "struct SwapData" in evidence
assert "_zeroExSwap" in evidence
assert 'require(msg.sender == owner, "not owner")' in evidence


def test_other_replacement_and_unresolved_target_are_not_assumed_to_be_pendle() -> None:
"""Labels and imported interfaces cannot establish replacement behavior."""
record = _record("new")
for candidate in (None, replace(record, contract_name="OtherContract"), replace(record, contract_file=None)):
evidence = _source_evidence(candidate)
assert "unavailable" in evidence
assert "_zeroExSwap" not in evidence


def test_ambiguous_interfaces_and_owner_bases_are_not_guessed() -> None:
"""Duplicate declarations in a bundle leave their facts unresolved."""
record = _record("new")
sources = dict(record.sources)
for path, source in record.sources.items():
if path.endswith(("IPSwapAggregator.sol", "BoringOwnableUpgradeableV2.sol")):
sources[f"decoy/{path}"] = source
evidence = _source_evidence(replace(record, sources=sources))
assert "Verified swap payload/enum" not in evidence
assert "Verified ownership guard" not in evidence
assert "_zeroExSwap" in evidence
2 changes: 1 addition & 1 deletion tests/test_protocol_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def capture(contexts: list, chain_id: int, labels: dict[str, str]) -> str:
def test_registered_adapters_cover_the_known_protocols(self) -> None:
self.assertEqual(
{adapter.name for adapter in protocol_context._ADAPTERS},
{"infinifi", "infinifi-outland", "3jane", "yearn-v3", "control-transfer"},
{"infinifi", "infinifi-outland", "3jane", "pendle", "yearn-v3", "control-transfer"},
)


Expand Down
26 changes: 26 additions & 0 deletions utils/llm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,31 @@ For any protocol, calls that hand over control (`set_management`, `transferOwner

Involved Safes get `Safe m-of-n` labels. These are applied with `setdefault`, so curated names win. Failures are best-effort and never block the alert.

### 5f-4. PendleSwap Upgrade Context (`utils/llm/pendle_context.py`)

For Pendle alerts on Ethereum and Arbitrum, direct `upgradeTo` and `upgradeToAndCall`
calls to the published PendleSwap proxy (including calls in Safe multisend batches)
receive adapter-specific context. This identifies the optional aggregator leg separately
from markets, PT/YT and SY contracts, and cites a pinned upstream `ActionBase` integration
reference for input pre-funding, output returned to the calling router, aggregator bypass
branches and caller-supplied output constraints. The architecture reference is explicitly
separate from a live execution trace or a claim about current balances or route usage.

The adapter reads `owner()` at the proxy and includes complete, contract-scoped verified
members from both current and proposed implementations: swap dispatch, output handling,
scaling, approvals and the upgrade authorization hook. The verified swap payload/enum and
ownership guard are also included when uniquely resolved in the bundle. This exposes
semantic enum changes despite an unchanged ABI, unsupported scaling reverts, and unchanged
authorization/approval code that a diff alone omits. Missing source or owner reads remain
explicitly unavailable; a replacement with a different contract name is not assumed to
retain PendleSwap behavior. Context is rendered in both the prompt and gist and sets no
fixed risk tag or storage-safety verdict. Nested governance wrappers are not resolved by
this adapter.

The summary critique also checks functional claims against supplied code: removing a
scaling branch does not establish removal of unscaled routes, and an UNKNOWN storage
verdict is not evidence that collisions are more likely.

### 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 itself, so registration order carries no meaning and one adapter raising is logged and skipped rather than dropping the alert. Most guard on protocol and chain. The Yearn V3 adapter guards on call shape and `apiVersion()`, and the control-transfer adapter on call shape alone.
Expand Down Expand Up @@ -548,6 +573,7 @@ utils/llm/
├── factory.py # Provider factory with env-based config + singleton
├── infinifi_context.py # Infinifi adapter: escrow → farm, custody, setRate APR, whitelist calls
├── openai_compat.py # OpenAI-compatible provider (Venice, OpenAI, etc.)
├── pendle_context.py # PendleSwap upgrades: router integration, owner, complete source members
├── protocol_context.py # Registry fanning one call out to every protocol adapter
├── report.py # Gist report: metadata header + deterministic call flow + analysis
├── threejane_abi.py # 3Jane checked-in ABIs + verified-ABI probes (proxy-aware)
Expand Down
12 changes: 8 additions & 4 deletions utils/llm/ai_explainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@
confirmed?
5. Does the risk tag match the magnitude of change shown in the context?
(A 10× change to a critical parameter is rarely LOW; a no-op is rarely HIGH.)
6. Are functional claims supported by the full supplied code and context? Do not turn
removal of one scaling branch into removal of all unscaled routes, or treat UNKNOWN
storage compatibility as evidence of a collision or increased collision likelihood.

Hard rules for the revision (if you choose to revise):
- Do NOT introduce a unit/scale assumption that wasn't supported by the context.
Expand All @@ -290,7 +293,7 @@
- Do NOT remove an explicit hedge ("unit cannot be confirmed", "without source
context", etc.).
- Do NOT polish for style alone. Only edit if there's a concrete, specific issue
from items 1-5.
from items 1-6.

If every check is satisfied AND no hard rule would be violated by the draft as-is,
output exactly:
Expand Down Expand Up @@ -1567,9 +1570,10 @@ def _build_prompt(

if protocol_context:
parts.append(
"\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
"\n--- Protocol Context (verified source, integration references and live on-chain reads) ---\n"
"Resolved identities, hashes, decimals, units and current values are VERIFIED facts. "
"State supplied facts; do not call them unavailable. Distinguish documented integration "
"architecture from live observations, and preserve any explicit validation limits.\n" + protocol_context
)

if source_contexts:
Expand Down
Loading
Loading