Skip to content

fix(llm): simulate Safe multisend batches, read Vyper before-state, describe control transfers - #396

Merged
spalen0 merged 2 commits into
mainfrom
worktree-safe-batch-sim-and-role-context
Sep 29, 2026
Merged

spalen0 merged 2 commits into
mainfrom
worktree-safe-batch-sim-and-role-context

Conversation

@spalen0

@spalen0 spalen0 commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The yChad (Yearn 6-of-9) Safe queued nonce 2296: a MultiSendCallOnly batch that nominates Executor as Funding Distributor management, then pays three late yETH-recovery claimants from yChad's own vault shares. The AI report got the mechanics right but missed the facts that matter:

Report said Actually Cause
"Tenderly simulation was skipped" All 7 calls succeed as one bundle from the Safe Every multisend batch skipped simulation. MultiSend makes each operation == 0 inner tx a plain CALL from the Safe, so a bundle from the Safe is the real execution.
"Prior claimable amounts were not provided" 29.01 / 5.45 / 1.09 (public on-chain). Each payout is exactly claimable × recovery_rate. Before-state reads only understood Solidity (brace bodies, mapping(...) public), and never read array-keyed setters like set_claimable(address[], uint256[]).
Management change described only as "pending handover" Control moves from yChad 6-of-9 to Executor, managed by a 2-of-4 Safe, with an operator whitelist Nothing resolved who the new controller is.

Changes

  • protocols/safe/multisend.py, protocols/safe/main.py:
    • is_simulatable_multisend(): the outer call is a DELEGATECALL into a canonical MultiSend utility and every inner op is a CALL.
    • Such batches are bundle-simulated from the Safe. The context note says so.
    • Batches with an inner DELEGATECALL, or an unknown delegate target, still skip simulation.
    • extract_inner_calls now carries each inner operation.
  • utils/source_context.py:
    • Vyper function bodies, and writes via self.<name> (with index/member chains, augmented assignments, and one level of nested index).
    • Module-level Vyper storage declarations.
    • Source context now labels the listed variables as "State variables this function writes". The model had claimed set_claimable leaves unclaimed stale.
  • utils/on_chain_state.py:
    • Vyper public(T) / public(HashMap[K, V]) parsing; non-public storage is skipped.
    • Array-key expansion: set_claimable(address[], …) reads claimable(account) per element, capped at MAX_ARRAY_KEY_READS = 12, with overflow marked unavailable. Solidity batch setters benefit too.
  • utils/llm/control_transfer_context.py (new adapter, any protocol):
    • Covers set_management / transferOwnership / setPendingOwner / setGovernance / setAdmin / role-manager setters and grantRole(bytes32,address).
    • Classifies the current and proposed holder: EOA, EIP-7702 account, Safe (m-of-n), or contract followed one hop to its own controller.
    • Flags an operator whitelist and states the change in signing threshold.
    • Decides whether a transfer is two-step from the call itself, not from the mere existence of a pending slot (review fix). Nominate-only setters are two-step. BoringOwnable's transferOwnership(owner, direct, renounce) follows direct. Otherwise the setter's source decides: writing only the pending slot (including private _pendingOwner) means two-step, writing only the role slot means immediate. An unresolved case is reported as "could not be determined".
  • Docs: utils/llm/README.md.

Result on the same batch (local end-to-end run)

Nominates new management for Funding Distributor from 6-of-9 Safe to 2-of-4 Safe via Executor contract, lowering signing threshold. Clears three claimable entries on yETH recovery claim contract. Withdraws ~11.36 WETH from yETH Recovery Vault to three addresses (9.27, 1.74, 0.35 WETH each). Management change is pending acceptance; if accepted, security posture weakens. MEDIUM

The call flow now shows Batch simulation: SUCCESS for all seven calls. The gist gains a Current State section (claimable per account, unclaimed, pending_management) and a Protocol Context section describing both controllers.

Not addressed

  • The claim deadline having passed (2026-06-01) is not surfaced. That fact is specific to the yETH recovery claim, so it wasn't worth an adapter unless these payouts recur.
  • Four TestSafeApiQuota tests fail locally. They blank SAFE_API_KEY / SAFE_API_KEY_2 but not SAFE_API_KEY_3, so a real key in .env leaks in. With the extra keys blanked, all 38 Safe tests pass. This predates this PR and is unrelated to it.

Testing

  • uv run pytest tests/: 1572 passed. The 4 failures are the environment-dependent quota tests above.
  • New tests:
    • Multisend: simulatable/not, context note, _explain_safe_tx routing.
    • Vyper: declarations, array-keyed reads and the cap, self. write detection, nested index.
    • Control-transfer adapter: Safe → contract behind a smaller Safe, EOA, EIP-7702, zero address, no-RPC for unrelated calls, rendering.
  • ruff format and ruff check are clean. mypy is clean on the new module.
  • Live on mainnet: the bundle simulation (7/7 succeed), Vyper before-state reads, the control-transfer adapter output, and a full explain_batch_transaction run.

🤖 Generated with Claude Code

spalen0 and others added 2 commits September 29, 2026 11:32
…escribe control transfers

A yChad multisend (Funding Distributor set_management + three yETH recovery
payouts) was reported with "simulation skipped", "prior claimable amounts
were not provided", and the management change described only as a
pending handover. All three gaps were fixable from on-chain data.

- Simulate multisend batches whose inner transactions are all CALLs as
  one ordered bundle from the Safe. MultiSend makes each such call from
  the Safe, so the bundle is the real execution. Only batches with an
  inner DELEGATECALL or an unknown delegate target still skip simulation.
- Read before-state for Vyper setters: find writes through `self.`,
  parse `public(...)` / `public(HashMap[K, V])` declarations, and
  expand array key arguments (`set_claimable(address[], ...)`) into one
  getter read per element (capped at 12, overflow marked unavailable).
  Solidity batch setters benefit from the array expansion too.
- Add a control-transfer adapter (any protocol). For set_management,
  transferOwnership, setGovernance, setAdmin, role-manager setters and
  grantRole it reports the current and proposed holder: EOA, EIP-7702
  account, Safe (m-of-n), or contract followed one hop to its own
  controller, plus any operator whitelist, whether the transfer is
  two-step, and the change in signing threshold (6-of-9 -> 2-of-4 here).
- Label the source-context state variables as the ones the function
  writes. The model had claimed set_claimable leaves `unclaimed` stale.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nding slot

Review of #396: the control-transfer adapter called any transfer
two-step when the target exposed a pending getter. BoringOwnable's
transferOwnership(owner, direct, renounce) has pendingOwner() yet
transfers immediately with direct=true, so the report would say the call
"only nominates" when control actually moves at once.

Two-step is now decided from the call:
- nominate-only setters (setPendingOwner, ...) are two-step;
- BoringOwnable follows its `direct` flag;
- otherwise the setter's source decides: writing only the pending slot
  (private `_pendingOwner` included) means two-step, writing only the
  role slot means immediate;
- a pending slot the source doesn't resolve is reported as
  undetermined, never asserted; no pending slot means immediate.

find_state_var_writes gains include_private for the Ownable2Step case,
and on_chain_state's source resolver is now public for reuse.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@spalen0
spalen0 marked this pull request as ready for review September 29, 2026 09:59
@spalen0
spalen0 merged commit 307feca into main Sep 29, 2026
3 checks passed
@spalen0
spalen0 deleted the worktree-safe-batch-sim-and-role-context branch September 29, 2026 09:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant