Skip to content

docs: TIB for evm-simulation intent & effect verification#795

Draft
Foulks-Plb wants to merge 4 commits into
mainfrom
tib-tx-safety-skills
Draft

docs: TIB for evm-simulation intent & effect verification#795
Foulks-Plb wants to merge 4 commits into
mainfrom
tib-tx-safety-skills

Conversation

@Foulks-Plb

Copy link
Copy Markdown
Contributor

Motivation

evm-simulation today guarantees only "no value retained by bundler3" (assertNoBundlerRetention) plus ERC20/WETH Transfer parsing — which misses signed grants to untrusted spenders, actions nested in batches, side-effect allowances/authorizations (balance delta = 0 but future drain), and value in assets the parser never reads (native, vault shares, debt/LP).

Solution

Adds docs/tibs/TIB-2026-06-15-evm-simulation-intent-effect-verification.md, a Proposed TIB that freezes the design for expanding the package along two verification layers — static decode of declared intent (per-chain spender trust-list, signature/Permit2 interception, recursive batch decode) and dynamic simulation of effective result (multi-asset value diff, realized amounts, state-diff/event inspection for side-effect grants, non-liquidatable invariant) — prioritised vaults first then markets across two milestones. The plan is mirrored in the EVM simulation expansion Linear project (2 milestones, 10 issues). Docs-only change; no package source or changeset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Foulks-Plb Foulks-Plb added the documentation Improvements or additions to documentation label Jun 15, 2026
@Foulks-Plb Foulks-Plb self-assigned this Jun 15, 2026

@0xbulma 0xbulma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Web3 threat-model review (scoped to intent gaps)

Reviewed as a design doc, focused on web3/EVM gaps in the threat model — grounded in the bundler3 contract (transient initiator, adapter fund-pull, the reenter()/callbackHash callback commitment, "the dispatcher holds no approvals, adapters do") and the current evm-simulation code. Inline comments mark specific gaps. Two cross-cutting points below.

[Low] Name sim-vs-execution divergence as a Constraint/Non-Goal. Every guarantee holds only at the simulated block; slippage tolerance is the sole guard against a bundle that's safe in simulation but sandwiched at inclusion (MEV / state drift). Not a flaw, but it belongs in Non-Goals so "no value loss" isn't read as an execution-time guarantee.

Two strongest additions: (a) make "decode the bundler3 reenter/callbackHash callback sub-bundle" an explicit Layer-A requirement (see comment on the recursive-batch-decode bullet); (b) promote "residual-allowance baseline policy" and "EIP-7702 authorization interception" from implicit to first-class Goals / Open Questions.

Reusability: most Layer-A primitives already exist — Permit2 + EIP-2612 (incl. DAI variant) typed-data in blue-sdk-viem/src/signatures/{permit2,permit}.ts, address registry via getChainAddresses (blue-sdk/src/addresses.ts, exposes generalAdapter1/permit2/bundler3 per chain), ERC-4626 math in blue-sdk/src/vault/VaultUtils.ts, Morpho authorization typed-data in blue-sdk-viem/src/signatures/manager.ts. Only EIP-7702 and the bundler3 callback-bundle decoder are genuinely net-new.


- **Per-chain trust-list of spenders/operators.** `GeneralAdapter1`, the bundler3 adapters, and `Permit2`, scoped **by `chainId`** (sourced from `@morpho-org/blue-sdk`, same pattern as the bundler3 set). An `approve`, an EIP-2612 `permit`, or a Permit2 `SignatureTransfer` whose **spender** falls outside the chain's trust-list is flagged.
- **Intercept at the signature request, not only the calldata.** Off-chain signatures never produce a `Transfer` log, so a balance-only or calldata-only view misses them entirely. We decode the **typed-data being signed** — EIP-2612 `permit` and Permit2 `PermitTransferFrom` / `PermitBatchTransferFrom` — and check the granted `spender`/`amount`.
- **Recursive batch decode.** The sensitive action is usually nested. We unwrap **Multicall3**, **Safe `multiSend`**, the **bundler3 multicall**, and **ERC-4337 `userOp` / `handleOps`**, recursively, and run every leaf call through the same checks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Decode bundler3 reenter()/callbackHash callback sub-bundles, not just the outer Call[].

bundler3's sensitive leaves usually don't live in the outer multicall array — they arrive via reenter(Call[]) during a callback (flashloan, Paraswap buy/sell) and are committed in the outer call only as an opaque callbackHash (bytes32). A multicall-array walk sees the hash, not the committed sub-calls, so it declares the bundle clean while the dangerous action hides in the callback — the exact "hidden inside a batch so the outer to looks trusted" threat from the Context section. Layer A should reconstruct the callback sub-bundle and verify it against the committed hash. Also handle skipRevert=true leaves (the intent decoder may assume a call executes when it can silently no-op).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I’ll clarify.


What the user is *being asked to authorize*, checked **before** the bundle is sent.

- **Per-chain trust-list of spenders/operators.** `GeneralAdapter1`, the bundler3 adapters, and `Permit2`, scoped **by `chainId`** (sourced from `@morpho-org/blue-sdk`, same pattern as the bundler3 set). An `approve`, an EIP-2612 `permit`, or a Permit2 `SignatureTransfer` whose **spender** falls outside the chain's trust-list is flagged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Exclude the Bundler3 dispatcher from the approval trust-list — trust only adapters.

Per bundler3's security model: "Bundler3 receives no approvals; it's a dispatcher and could steal funds. Individual adapters are safe to approve." An approve/Permit2 grant whose spender is the bundler3 dispatcher is a red flag; the same grant to GeneralAdapter1 is expected. Reuse risk: the retention trust-set is built from all addresses.bundler3.* values (packages/evm-simulation/src/simulate/pipeline/bundler-retention.ts:44-48), which includes the dispatcher itself. Reusing that set verbatim as the approval trust-list would bless approvals to the one address that must never hold them.

@Foulks-Plb Foulks-Plb Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. But the model will be based on an allowlist rather than a denylist. The only contracts that will be authorized are the General Adapter and Permit2.

What the user is *being asked to authorize*, checked **before** the bundle is sent.

- **Per-chain trust-list of spenders/operators.** `GeneralAdapter1`, the bundler3 adapters, and `Permit2`, scoped **by `chainId`** (sourced from `@morpho-org/blue-sdk`, same pattern as the bundler3 set). An `approve`, an EIP-2612 `permit`, or a Permit2 `SignatureTransfer` whose **spender** falls outside the chain's trust-list is flagged.
- **Intercept at the signature request, not only the calldata.** Off-chain signatures never produce a `Transfer` log, so a balance-only or calldata-only view misses them entirely. We decode the **typed-data being signed** — EIP-2612 `permit` and Permit2 `PermitTransferFrom` / `PermitBatchTransferFrom` — and check the granted `spender`/`amount`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Intercept EIP-7702 authorization tuples, not only EIP-2612 / Permit2.

The most complete "zero balance change, future drain" is an EIP-7702 authorization delegating the EOA's code to an attacker contract — it hands over the entire account, dwarfs any single approval, and emits no Transfer/Approval log. 4337 / smart-account flows are explicitly in scope and the repo has zero 7702 handling today (net-new). An intent layer that catches off-chain grants but ignores 7702 has a hole exactly where the stakes are highest.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This operates at the wallet/account level rather than the application level. I’m not sure this is something we can reliably verify at the app layer.

What the user is *being asked to authorize*, checked **before** the bundle is sent.

- **Per-chain trust-list of spenders/operators.** `GeneralAdapter1`, the bundler3 adapters, and `Permit2`, scoped **by `chainId`** (sourced from `@morpho-org/blue-sdk`, same pattern as the bundler3 set). An `approve`, an EIP-2612 `permit`, or a Permit2 `SignatureTransfer` whose **spender** falls outside the chain's trust-list is flagged.
- **Intercept at the signature request, not only the calldata.** Off-chain signatures never produce a `Transfer` log, so a balance-only or calldata-only view misses them entirely. We decode the **typed-data being signed** — EIP-2612 `permit` and Permit2 `PermitTransferFrom` / `PermitBatchTransferFrom` — and check the granted `spender`/`amount`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Permit2 coverage omits AllowanceTransfer (standing) mode and any amount/deadline policy.

Listed: SignatureTransfer/PermitTransferFrom/PermitBatchTransferFrom (one-shot). Missing: Permit2 IAllowanceTransfer.permit/permitBatch (approve2) — the standing allowance with amount+expiration+nonce, the more dangerous future-drain vector, and one bundler3 supports. Also nothing checks unlimited (type(uint256).max) amounts or far-future deadlines even to a trusted spender. Trust-list membership ≠ safe amount/duration. Reuse getPermit2PermitTypedData from blue-sdk-viem/src/signatures/permit2.ts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about it more, I don’t think amount and expiration are part of our security model. GeneralAdapter is a static and trusted component, so the critical property is who receives the allowance, not its amount or duration.

- **Slippage & fees within tolerance**, and **PublicAllocator fee is correct** (vault/market reallocation).
- **No asset leaves to an unknown / unexpected address.** Every outflow recipient is either the user or an explicitly-allowed destination.
- **Legitimate non-`from` recipients are allowed.** A receiver different from the sender is expected and fine for: vault `deposit`/`mint` → shares to `receiver`, bridges, smart-account / 4337 flows, and callback adapters. These must pass, not trip the "unknown recipient" check.
- **State diff, not just balance diff.** A bundle can finish with **balance delta = 0 yet leave a standing allowance or authorization** → a future drain. We inspect **storage/state diffs and events** — `Approval` (ERC20 + Permit2), `AuthorizationSet` (Morpho), operator approvals — and assert that **no approval or authorization is granted as a side effect** beyond what the declared intent required.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Define the residual-allowance baseline — this check's correctness hinges on it.

bundler3 flows legitimately rely on a standing Permit2 allowance to GeneralAdapter1 (approve2/AllowanceTransfer) that persists across bundles by design. "No allowance/authorization granted as a side effect beyond what the declared intent required" needs an explicit policy separating expected residual adapter allowances from malicious side-effect grants — otherwise the check is either noisy (flags every legit standing allowance) or toothless. This is the single most important unspecified decision in the design; promote it to Open Questions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, I’ll clarify the Permit2 case.


What *actually happened*, from the post-simulation **state diff + events** — not balances alone.

- **No net value loss, across every asset type.** Net diff ≥ expected, computed over **ERC20, native ETH, ERC-4626 vault shares, and debt/LP positions** — not only ERC20. (Today only ERC20 is parsed.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] "No net value loss across asset types" has no price basis — it can only check against the caller's declared expectation.

For any asset-crossing flow (Paraswap swap, supplyCollateral A → borrow B), "net diff ≥ expected across all asset types" isn't computable without an exchange rate, but USD/oracle valuation is a Non-Goal. So the guarantee reduces to "matches the integrator-supplied expected amounts" — a malicious or buggy declared expectation passes. State plainly that Layer B verifies conformance to declared intent, not fairness, so integrators don't over-trust it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Net diffs across asset types are too complex to handle properly at the moment. Let’s avoid introducing this notion for now and focus on verifying conformance to the declared expectations.

What *actually happened*, from the post-simulation **state diff + events** — not balances alone.

- **No net value loss, across every asset type.** Net diff ≥ expected, computed over **ERC20, native ETH, ERC-4626 vault shares, and debt/LP positions** — not only ERC20. (Today only ERC20 is parsed.)
- **Realized amount, not encoded amount.** Measure what the receiver actually got, so **fee-on-transfer / rebasing** tokens are accounted correctly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] "Realized amount / every asset type" breaks on hostile ERC20s beyond fee-on-transfer/rebasing.

Also in scope: ERC-777 tokensReceived/tokensToSend hooks (a reentrancy vector that also double-counts Transfer logs); double-entry-point tokens (one balance, two proxy addresses — the Compound/TUSD class) that defeat per-address balance/Transfer accounting; and return-false-not-revert tokens. A value-diff built on Transfer logs + balance diff inherits all of these. Scope which token classes are in/out, and degrade-to-warning on detected hooks (same discipline as the native-trace gap).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe that’s out of scope for now. I’ve never seen any tokens other than standard ERC-20s on Morpho.

- **Realized amount, not encoded amount.** Measure what the receiver actually got, so **fee-on-transfer / rebasing** tokens are accounted correctly.
- **Slippage & fees within tolerance**, and **PublicAllocator fee is correct** (vault/market reallocation).
- **No asset leaves to an unknown / unexpected address.** Every outflow recipient is either the user or an explicitly-allowed destination.
- **Legitimate non-`from` recipients are allowed.** A receiver different from the sender is expected and fine for: vault `deposit`/`mint` → shares to `receiver`, bridges, smart-account / 4337 flows, and callback adapters. These must pass, not trip the "unknown recipient" check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Allow-list the resolved callback target, not the callback role.

Treating "callback adapters" as a permitted non-from recipient role is a hole if the target is attacker-chosen. The resolved callback target (flashloan provider, Paraswap augustus) routes value and must itself be trust-listed — tie the allowance to the resolved target address, not the role.


## Assumptions & Constraints

- Both backends expose **state/storage diffs and full event logs** for a simulated bundle (Tenderly natively; `eth_simulateV1` via `stateDiff` + per-call logs). Where one backend is thinner (e.g. `eth_simulateV1` internal native transfers), the check **degrades to a typed warning**, never a silent pass — same discipline as the retention skip on unknown chains.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Native-ETH no-loss degrades to a warning on eth_simulateV1 — for the highest-value asset.

Internal native transfers (call-value, selfdestruct refunds) are invisible to eth_simulateV1 and a prime exfil path through callback adapters. "Degrade to warning, never silent pass" is the right rule, but consider requiring Tenderly for native-bearing bundles rather than letting a user click through a warning on the most fungible, highest-value asset.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just found a solution that eliminates the blind spot around native ETH utilization. We’ll be able to remove this limitation from the TIB.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

## Open Questions

- Should trust-list checks be **hard errors or warnings by default**? Leaning: trust-list miss = error; backend-coverage gap (e.g. missing internal native trace) = warning.
- For ERC-4626 share valuation, do we compare **shares** directly, or convert to assets via `convertToAssets` at the simulated post-state? (Affects how slippage is expressed for vault flows.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Open Question 2 has a security edge. convertToAssets at the simulated post-state can be skewed by an intra-bundle donation/inflation move, so the vault value-diff should be robust to in-bundle share-price manipulation, not just read the post-state conversion (the root invariant set already calls out an inflation-attack guard). Reuse VaultUtils.toAssets/toShares from blue-sdk/src/vault/VaultUtils.ts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we shouldn’t implement inflation attack protection, it seems quite difficult to do properly. Unless someone has a quick and simple idea for how to implement it?

@Rubilmax Rubilmax left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great!

Comment thread docs/tibs/TIB-2026-06-15-evm-simulation-intent-effect-verification.md Outdated
Foulks-Plb and others added 2 commits June 17, 2026 11:39
…baseline

Integrate PR #795 review feedback:
- Layer A: reconstruct bundler3 reenter()/callbackHash callback sub-bundles
  and verify them against the committed hash (handle skipRevert leaves),
  so dangerous calls hidden in callbacks are not declared clean.
- Open Questions: promote the residual-allowance baseline (standing Permit2
  allowance to GeneralAdapter1) — the side-effect Approval check hinges on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apply PR #795 review resolutions: allowlist (GeneralAdapter1 + Permit2)
instead of trust-list with dispatcher; spender-only signature check
(amount/expiration out of model); per-asset conformance instead of
cross-asset net value; standard ERC-20 only; EIP-7702 out of scope;
native-ETH gap closed via #803; inflation-attack robustness likely
out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Foulks-Plb Foulks-Plb requested review from 0xbulma and Rubilmax June 17, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants