Skip to content

Feat: Generic ERC4626 Shield validator + comprehensive test suite#8

Open
ajag408 wants to merge 9 commits intomainfrom
eng-1236-implement-generic-erc-4626-shield-adapter
Open

Feat: Generic ERC4626 Shield validator + comprehensive test suite#8
ajag408 wants to merge 9 commits intomainfrom
eng-1236-implement-generic-erc-4626-shield-adapter

Conversation

@ajag408
Copy link
Contributor

@ajag408 ajag408 commented Feb 14, 2026

Summary by CodeRabbit

  • New Features

    • Added ERC4626 vault validation: multi-transaction support for approvals, deposits/mints, withdrawals/redeems, and ETH wrap/unwrap, with per-chain vault whitelists and WETH-aware handling.
  • Tests

    • Added comprehensive tests covering valid flows, negative cases (non-whitelisted/paused vaults, zero/tampered inputs, chain/recipient mismatches), and auto-detection of transaction types.

Introduces a new generic ERC-4626 transaction validator for Shield. This validator can validate unsigned transactions for any ERC4626 vault across all supported EVM chains, covering the full deposit/withdraw lifecycle. It is not yet registered in Shield's validator registry (that's PR2) — this PR focuses on getting the validator logic and test coverage reviewed and merged.

Why

Shield currently validates Lido, Solana, and Tron transactions. We have ~1,400 ERC4626 vault yield IDs (Euler, Morpho, Yearn V3, Fluid, Aave, etc.) with no Shield coverage. This validator provides a single, reusable adapter that works for all of them since ERC-4626 is a standardized interface.

How it works

Architecture

The ERC4626Validator extends BaseEVMValidator and validates 5 transaction types that represent the full ERC4626 lifecycle:

APPROVAL  →  WRAP (optional)  →  SUPPLY  →  WITHDRAW  →  UNWRAP (optional)
   │              │                  │           │              │
 ERC20         WETH            ERC4626       ERC4626         WETH
 approve()     deposit()    deposit/mint  withdraw/redeem  withdraw()
  • APPROVAL — ERC20 approve(spender, amount) allowing the vault to pull tokens from the user
  • WRAP — WETH deposit() converting native ETH to WETH (only for vaults that accept WETH as input)
  • SUPPLY — ERC4626 deposit(assets, receiver) or mint(shares, receiver) into a whitelisted vault
  • WITHDRAW — ERC4626 withdraw(assets, receiver, owner) or redeem(shares, receiver, owner) from a whitelisted vault
  • UNWRAP — WETH withdraw(amount) converting WETH back to native ETH after withdrawal

Validation flow

validate(unsignedTransaction, transactionType, userAddress)
  │
  ├─ Decode JSON → EVMTransaction
  ├─ Verify tx.from === userAddress
  ├─ Extract and validate chainId
  ├─ Verify tx.to exists
  │
  └─ Route to type-specific handler:
       ├─ validateApproval()  — spender is whitelisted vault, tx.to is vault's input token, amount > 0, no ETH
       ├─ validateWrap()      — tx.to is chain's WETH contract, ETH value > 0, selector is deposit()
       ├─ validateSupply()    — tx.to is whitelisted vault on correct chain, canEnter !== false, receiver === user, amount > 0
       ├─ validateWithdraw()  — tx.to is whitelisted vault on correct chain, canExit !== false, receiver === user, owner === user, amount > 0
       └─ validateUnwrap()    — tx.to is chain's WETH contract, no ETH, selector is withdraw(), amount > 0

Every path also runs calldata tamper detection: the raw calldata is parsed via the ABI, then re-encoded from the parsed args and compared byte-for-byte against the original. Any appended, modified, or truncated bytes are caught.

Configuration

The validator is instantiated with a VaultConfiguration containing an array of VaultInfo objects:

interface VaultInfo {
  address: string;           // Vault contract address (lowercase)
  chainId: number;           // EVM chain ID
  protocol: string;          // e.g., 'morpho', 'euler'
  yieldId: string;           // StakeKit yield ID
  inputTokenAddress: string; // Token being deposited (e.g., USDC)
  vaultTokenAddress: string; // Vault share token
  network: string;           // e.g., 'ethereum', 'arbitrum'
  isWethVault?: boolean;     // Whether this vault accepts WETH (allows ETH value on SUPPLY)
  canEnter?: boolean;        // Whether deposits are enabled (undefined = enabled)
  canExit?: boolean;         // Whether withdrawals are enabled (undefined = enabled)
}

Vault lookups use a composite chainId:address key, so the same contract address on two different chains is handled correctly.

WETH addresses

A hardcoded map of canonical WETH contract addresses per chain is used for WRAP/UNWRAP validation. Currently covers: Ethereum, Arbitrum, Optimism, Base, Polygon, Gnosis, Avalanche, Binance, Sonic, Unichain.

Integration with Shield

This validator is not yet registered in the validator registry. In production, Shield's validate() method doesn't receive a transactionType — it loops through all 5 types and expects exactly one to return valid. The auto-detection tests in this PR simulate that behavior to prove there's no ambiguity between types.

Registration happens in PR2, where each yieldId from an embedded vault-registry.json will be mapped to a shared ERC4626Validator instance.

Files

shield/parent/src/validators/evm/erc4626/
├── erc4626.validator.ts        # The validator (510 lines)
├── erc4626.validator.test.ts   # Test suite — 49 tests (749 lines)
├── types.ts                    # VaultInfo, VaultConfiguration interfaces
├── index.ts                    # Public exports
├── vault-config.ts             # Deleted — was runtime API fetching, replaced by embedded registry in PR2
└── scripts/
    └── discover-networks.ts    # Deleted — dev utility, not imported anywhere

Test coverage

49 tests across 7 describe blocks:

Block Tests What's covered
APPROVAL 8 Valid approval, wrong spender, zero amount, ETH attached, tampered calldata, wrong chain, wrong token contract (tx.to != inputTokenAddress), max uint256
WRAP 5 Valid WETH deposit, wrong WETH address, zero ETH, wrong selector, unconfigured chain
SUPPLY 12 Valid deposit/mint, unwhitelisted vault, wrong receiver, ETH on non-WETH vault, tampered calldata, malicious tx.to swap (core attack vector), wrong chain, wrong from, unknown selector, WETH vault with ETH value, zero amount
WITHDRAW 9 Valid withdraw/redeem, wrong receiver (funds redirected), wrong owner, unwhitelisted vault, ETH attached, tampered calldata, unknown selector, zero amount
UNWRAP 4 Valid WETH withdraw, zero amount, wrong WETH address, ETH attached
canEnter/canExit 2 Paused vault deposit blocked, disabled vault withdrawal blocked
Shared path edge cases 4 Invalid JSON, missing from, missing chainId, null tx.to
Auto-detection 5 Each tx type matches exactly one type across all 5 supported types (simulates Shield routing)

How to test

cd shield/parent
pnpm install
npx jest --testPathPattern=erc4626

What this PR does NOT do (deferred)

  • PR2: Embed vault-registry.json and register the validator in validators/index.ts so Shield routes real yieldIds to it
  • PR3: Documentation and deployment readiness

Security checklist

  • Vault whitelist is chain-scoped (chainId:address composite key)
  • tx.to validated against whitelist for SUPPLY/WITHDRAW
  • tx.to validated against inputTokenAddress for APPROVAL
  • Receiver address validated as user for SUPPLY
  • Receiver + owner validated as user for WITHDRAW
  • from address validated as user in shared path (all 5 types)
  • ETH value rejected where inappropriate (APPROVAL, WITHDRAW, UNWRAP)
  • ETH value required where expected (WRAP)
  • Calldata tamper detection via re-encoding comparison (all 5 types)
  • Unknown function selectors rejected (SUPPLY, WITHDRAW)
  • Zero amounts rejected (all 5 types)
  • Paused/disabled vaults blocked via canEnter/canExit flags
  • Auto-detection: each valid tx matches exactly one type (no ambiguity)
  • Standard ERC-4626 ABI only — no protocol-specific extensions

@coderabbitai
Copy link

coderabbitai bot commented Feb 14, 2026

📝 Walkthrough

Walkthrough

Adds a new ERC4626Validator module (implementation, types, exports) and a comprehensive test suite. The validator decodes EVM transactions, enforces per-chain vault whitelists, and validates APPROVAL, WRAP, SUPPLY, WITHDRAW, and UNWRAP flows with vault/amount/recipient/ETH checks.

Changes

Cohort / File(s) Summary
Type Definitions
src/validators/evm/erc4626/types.ts
Adds VaultInfo and VaultConfiguration interfaces describing vault metadata and configuration.
ERC4626 Validator Implementation
src/validators/evm/erc4626/erc4626.validator.ts
New ERC4626Validator class: loads vault config, resolves per-chain vaults/WETH, decodes transactions, and validates five transaction types (APPROVAL, WRAP, SUPPLY, WITHDRAW, UNWRAP) with ownership, amount, receiver, ETH, and whitelist checks.
Module Exports
src/validators/evm/erc4626/index.ts
Re-exports ERC4626Validator, VaultInfo, and VaultConfiguration from the module.
Test Suite
src/validators/evm/erc4626/erc4626.validator.test.ts
Adds extensive tests covering positive and negative paths for all supported transaction types, edge cases (tampered calldata, zero amounts, chain mismatches, paused vaults), and auto-detection of transaction types.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant ERC4626Validator
    participant TxDecoder
    participant VaultConfig
    participant TypeHandler

    Caller->>ERC4626Validator: validate(unsignedTx, txType?, user, args?)
    ERC4626Validator->>TxDecoder: decode unsignedTx -> to, data, value, chainId
    TxDecoder-->>ERC4626Validator: decoded tx fields
    ERC4626Validator->>VaultConfig: resolve vault by chainId/to & get WETH
    VaultConfig-->>ERC4626Validator: vaultInfo / wethAddress
    ERC4626Validator->>TypeHandler: dispatch to handler based on txType or auto-detect
    TypeHandler->>VaultConfig: check whitelist, canEnter/canExit, isWethVault
    TypeHandler-->>ERC4626Validator: validation result (allowed/blocked + reason)
    ERC4626Validator-->>Caller: return ValidationResult
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibble bytes and guard the gates,

I check each vault and vet the traits,
Wrap, unwrap, supply, withdraw in sight,
Whitelists snug and calldata tight,
Hopping onward — validations take flight 🥕

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the primary changes: implementing a generic ERC4626 validator and adding a comprehensive test suite.
Linked Issues check ✅ Passed The PR implements all coding requirements from ENG-1236: a functional ERC4626 validator that pattern-matches standard deposit/withdraw transactions, validates addresses, detects calldata tampering, supports whitelisted vaults, and includes comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly aligned with implementing the ERC4626 validator and its test suite as specified in ENG-1236; no unrelated modifications are present.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch eng-1236-implement-generic-erc-4626-shield-adapter

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
src/validators/evm/erc4626/erc4626.validator.ts (2)

114-123: Consider explicit null/undefined checks to satisfy the linter.

The static analysis flags these truthy checks because !chainId would also catch 0 (a potentially valid chainId in dev/test contexts), and !tx.to would also catch empty string "". While the current code works in practice, explicit checks improve clarity.

🔧 Proposed fix for explicit null checks
     // Get and validate chain ID from transaction
     const chainId = this.getNumericChainId(tx);
-    if (!chainId) {
+    if (chainId === null || chainId === undefined) {
       return this.blocked('Chain ID not found in transaction');
     }

     // Ensure destination address exists
-    if (!tx.to) {
+    if (tx.to === null || tx.to === undefined) {
       return this.blocked('Transaction has no destination address');
     }

486-505: Hardcoded WETH addresses — consider documenting maintenance requirements.

The WETH address lookup covers major chains (Ethereum, Arbitrum, Optimism, Base, Polygon, Gnosis, Avalanche, BSC, Sonic, Unichain). This is acceptable for known chains, but new chain support will require code changes.

Consider adding a comment noting this or extracting to configuration if chain additions are expected to be frequent.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/validators/evm/erc4626/erc4626.validator.test.ts`:
- Around line 1-18: Prettier is flagging formatting issues (e.g., trailing
whitespace) in the ERC4626 unit test file; run a formatting pass (npx prettier
--write) on the file and remove any trailing spaces and other style violations
so the file containing constants like USER_ADDRESS, VAULT_ADDRESS, INPUT_TOKEN,
WETH_ARBITRUM, etc. is properly formatted; ensure the updated file is saved and
committed so CI Prettier checks pass.

In `@src/validators/evm/erc4626/erc4626.validator.ts`:
- Around line 99-100: The function parameter args is unused and should be
renamed to _args to match the existing unused parameter convention (_context)
and satisfy the linter; update the function signature that currently declares
"args?: ActionArguments" to " _args?: ActionArguments" (keep the type
ValidationContext and the _context parameter as-is) so the unused parameter is
clearly prefixed with an underscore.
🧹 Nitpick comments (1)
src/validators/evm/erc4626/erc4626.validator.ts (1)

116-124: Consider more explicit null/undefined checks for better clarity.

Static analysis flags that !chainId may conflate 0, null, and undefined. While chain ID 0 is not valid for EVM chains in practice, being explicit improves readability and silences the warning.

♻️ Optional: More explicit checks
     // Get and validate chain ID from transaction
     const chainId = this.getNumericChainId(tx);
-    if (!chainId) {
+    if (chainId === null || chainId === undefined) {
       return this.blocked('Chain ID not found in transaction');
     }

     // Ensure destination address exists
-    if (!tx.to) {
+    if (tx.to === null || tx.to === undefined) {
       return this.blocked('Transaction has no destination address');
     }

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