Skip to content

Latest commit

 

History

History
653 lines (510 loc) · 22.6 KB

File metadata and controls

653 lines (510 loc) · 22.6 KB

King Yield Aggregator - Architecture

Technical deep dive into KYA's contract architecture, design decisions, and implementation details.


Table of Contents


System Overview

Component Diagram

┌──────────────────────────────────────────────────────┐
│                    User Interface                    │
│              (Web3 App / Direct Calls)               │
└────────────────────┬─────────────────────────────────┘
                     │
                     ▼
        ┌────────────────────────┐
        │      VaultCore         │
        │  ┌──────────────────┐  │
        │  │ Share Accounting │  │
        │  │ Deposit/Withdraw │  │
        │  │ ERC20 Interactions│ │
        │  └──────────────────┘  │
        └───────────┬────────────┘
                    │
                    ▼
        ┌────────────────────────┐
        │   StrategyManager      │
        │  ┌──────────────────┐  │
        │  │ Strategy Registry│  │
        │  │ Fund Deployment  │  │
        │  │ APR Tracking     │  │
        │  └──────────────────┘  │
        └───┬──────────────┬─────┘
            │              │
            ▼              ▼
    ┌──────────┐    ┌──────────┐
    │Strategy A│    │Strategy B│
    │ (Aave)   │    │(Compound)│
    └──────────┘    └──────────┘
            
        ┌────────────────────────┐
        │     Rebalancer         │
        │  ┌──────────────────┐  │
        │  │ APR Monitoring   │  │
        │  │ Threshold Checks │  │
        │  │ Cooldown Logic   │  │
        │  └──────────────────┘  │
        └────────────────────────┘

Contract Specifications

VaultCore.sol

Purpose: User-facing vault for deposits and withdrawals

Inheritance:

VaultCore is Utils, VaultTypes, KingablePausable 

Core State:

IERC20 public immutable s_asset;          // Underlying token
address public s_vaultAdmin;               // Operational admin
IStrategyManagerVault public s_strategyManager;  // Strategy orchestrator
mapping(address => uint256) public s_shares;     // User → share balance
uint256 public s_totalShares;              // Total minted shares

Key Functions:

  • deposit(uint256 amount) - Mint shares, deploy to strategy
  • withdraw(uint256 sharesAmount) - Burn shares, return assets + yield
  • withdrawAll() - Convenience function for full withdrawal
  • totalAssets() - Vault balance + deployed strategy balance

Access Control:

  • King: assignAdmin(), assignStrategyManager()
  • Admin: assignStrategyManager()
  • Anyone: deposit(), withdraw()

StrategyManager.sol

Purpose: Orchestrate strategy deployment and switching

Inheritance:

StrategyManager is IStrategyManager, Utils, ManagerTypes, KingablePausable

Core State:

IERC20 public immutable s_asset;          // Underlying token
mapping(uint256 => IStrategy) public s_strategies;  // ID → strategy
uint256 public s_activeStrategyId;         // Current active strategy's Id
uint256 public s_lifetimeStrategies;       // Total strategies added (counter)
address public s_rebalancer;               // Authorized rebalancer

Key Functions:

  • addStrategy(address strategy) - Register new strategy
  • removeStrategy(address strategy) - Unregister (must have 0 balance)
  • setActiveStrategy(uint256 id) - Switch active strategy
  • depositToActive(uint256 amount) - Send funds to active strategy
  • withdrawFromActive(uint256 amount) - Pull funds from active strategy
  • addRebalancer(address rebalancer) - Authorize rebalancer contract

Access Control:

  • King/Admin: addStrategy(), removeStrategy(), setActiveStrategy(), addRebalancer()
  • Vault only: depositToActive(), withdrawFromActive()
  • Rebalancer only: rebalanceFromTo()

Rebalancer.sol

Purpose: Monitor APRs and trigger fund reallocation

Inheritance:

Rebalancer is Utils, KingablePausable

Core State:

IStrategyManagerRebalancer public immutable s_strategyManager;
address public s_rebalancerAdmin; // Rebalancer admin's address. 
uint256 public s_lastRebalanceTimestamp;   // Cooldown tracking
uint256 public constant MIN_INTERVAL = 1 hours; // Cool down period. 

Key Functions:

  • performRebalance() - Compare APRs, switch if threshold exceeded

Access Control:

  • King: assignAdmin()
  • King/Admin: performRebalance()

IStrategy.sol (Interface)

Purpose: Standard interface all strategies must implement

interface IStrategy {
    // Deploy funds into underlying protocol
    function deposit(uint256 amount) external;
    
    // Withdraw funds from underlying protocol
    function withdrawTo(address to, uint256 amount) external returns (uint256);

    // Withdraw all funds from underlying protocol
    function withdrawAllTo(address to) external returns (uint256);

    // View total assets held by strategy
    function totalAssets() external view returns (uint256);
    
    // View current APR
    function currentApr() external view returns (uint256);

    // View strategy's name
    function name() external view returns (string memory);
}

Implementation Examples:

  • AaveStrategyMock.sol - Simulates Aave lending
  • CompoundStrategyMock.sol - Simulates Compound lending

State Variables

Share Accounting (VaultCore)

mapping(address => uint256) public s_shares;
  • Why mapping? O(1) lookup for any user
  • Why not array? Can't efficiently find user's position
  • Gas cost: ~20k for first write, ~5k for updates
uint256 public s_totalShares;
  • Why track total? Needed for share price calculation
  • Alternative: Loop through all users (prohibitively expensive)

Strategy Registry (StrategyManager)

mapping(uint256 => IStrategy) public s_strategies;
  • Why uint256 ID? More flexible than array (can delete without gaps)
  • Why not array? Deleting from array leaves gaps or requires shifting
  • Trade-off: Must track s_lifetimeStrategies separately
uint256 public s_activeStrategyId;
  • Why not address? ID allows existence check (ID 0 = no active strategy)
  • Edge case: Must validate ID exists before setting active

Key Functions

deposit() - VaultCore

    /// @notice Deposits `amount` into the vault.
    /// @dev Uses KingReentrancyGuard `nonReentrant` from KingClaimMistakenETH and `whenActive` modifier from KingablePausable.
    /// @param amount The amount of the asset to be deposited into the vault.
    /// @return mintedShares The caller's minted shares.
    function deposit(uint256 amount)
        external
        nonReentrant
        whenActive
        validateAmount(amount)
        returns (uint256 mintedShares)
    {
        // Read the asset data and store in a memory `asset`.
        IERC20 asset = s_asset;

        // Read the vault's balance before the deposit.
        uint256 balanceBefore = asset.balanceOf(address(this));

        /**
         * Call the internal `_safeTransferFrom` helper function
         *     and transfer the amount from the caller to the vault.
         */
        _safeTransferFrom(asset, msg.sender, address(this), amount);

        // Calculate the actual amount of token received.
        uint256 actualReceived = asset.balanceOf(address(this)) - balanceBefore;

        // Read the strategy manager's address.
        address manager = address(s_strategyManager);

        /*  Reads the strategy's total deployed funds.
            Returns zero if the manager is the zero address,
            otherwise returns the manager's total deployed funds.
        */
        uint256 deployed = manager == address(0) ? 0 : s_strategyManager.totalDeployed();

        // Assign the vault's total assets before by summing the vault balance before with the total deployed funds.
        uint256 tAssetsBefore = balanceBefore + deployed;

        // Read the total shares before.
        uint256 totalSharesBefore = s_totalShares;

            /**
         *  If this is the first deposit (no existing shares or the total assets is equal to 0),
         *  the depositor receives shares equal to the actual amount received in the vault.
         *
         */
        if (totalSharesBefore == 0 || tAssetsBefore == 0) {
            // Revert if the actual amount received is less than the minimum first deposit. 
            if(actualReceived < i_minimumFirstDeposit) {
                revert MinimumDepositNotMet(actualReceived, i_minimumFirstDeposit);
            }
            mintedShares = actualReceived;

            // Else `mintedShares` is equal to actual received multiplied by total shares before divided by total asset before.
        } else {
            mintedShares = actualReceived * totalSharesBefore / tAssetsBefore;
        }

        // Add the `mintedShares` to the `totalShares` and to the caller's `shares`.
        unchecked {
            s_totalShares += mintedShares;
            s_shares[msg.sender] += mintedShares;
        }

        // Deposit funds into the active strategy if strategy manager is not the zero address.
        if (manager != address(0)) {
            asset.approve(manager, actualReceived);
            s_strategyManager.depositToActive(actualReceived);
            asset.approve(manager, 0);
        }

        // Emit the event Deposited.
        emit Deposited(msg.sender, actualReceived, mintedShares);
    }

Design Decisions:

  1. Why calculate shares first? totalAssets() includes strategy balance, which could change during call
  2. Why nonReentrant? Token transfer could callback (ERC777, malicious tokens)
  3. Why update state before transfer? Checks-Effects-Interactions pattern
  4. Why allow 0 strategy manager? Vault can work standalone (just holds tokens)

withdraw() - VaultCore

    /// @notice Allows the caller to withdraw their deposited tokens.
    /// @dev Uses KingReentrancyGuard `nonReentrant` from KingClaimMistakenETH and `whenActive` modifier from KingablePausable.
    /// @param sharesAmount The caller's shares amount.
    /// @return assetsOut Amount of tokens withdrawn to the caller.
    function _withdraw(uint256 sharesAmount) internal nonReentrant whenActive returns (uint256 assetsOut) {
        // Revert if sharesAmount is equal to zero.
        if (sharesAmount == 0) {
            revert ZeroSharesInput();
        }

        // Read the caller's shares balance.
        uint256 userShares = s_shares[msg.sender];

        // Revert if the `sharesAmount` is greater than the caller's shares balance.
        if (sharesAmount > userShares) {
            revert InsufficientShares(userShares);
        }

        // Calculate the caller's asset to be returned.
        assetsOut = convertToAssets(sharesAmount);

        // Burn the caller's `sharesAmount`.
        // Unchecked was used since underflow can't occur here.
        unchecked {
            s_totalShares -= sharesAmount;

            // Subtract sharesAmount from the caller's shares.
            s_shares[msg.sender] = userShares - sharesAmount;
        }

        /**
         * Ensure the vault has enough balance, if not,
         * the strategyManager should be able to fulfill by withdrawing from strategies.
         */
        // Read the vault's balance.
        uint256 vaultBalance = s_asset.balanceOf(address(this));

        // If the caller's amount is greater than the vault's balance, withdraw from the strategy.
        if (assetsOut > vaultBalance) {
            try s_strategyManager.withdrawFromActive(assetsOut - vaultBalance) {
                // Revert if strategy withdrawal fails.
            } catch {
                revert StrategyWithdrawalFailed(assetsOut, vaultBalance);
            }
        }

        // Call the internal `_safeTransfer` helper function and transfer the amount to the caller.
        _safeTransfer(s_asset, msg.sender, assetsOut);

        // Emit the event Withdrawn.
        emit Withdrawn(msg.sender, assetsOut, sharesAmount);
    }

Design Decisions:

  1. Why convert before burning? Conversion uses s_totalShares in denominator
  2. Why check vault balance? Gas optimization - avoid strategy call if not needed
  3. Why not revert if strategy fails? Better to revert (via withdrawFromActive()) than partial withdraw

rebalance() - Rebalancer

  /// @notice Performs the rebalance to the strategy with the highest APR.
    /**
     * @dev Steps:
     *        1. Check last rebalanced timestamp.
     *        2. Ensure active strategy exists.
     *        3. Read the current strategy's Id.
     *        4. Read the best strategy's Id.
     *        5. Call the `rebalanceFromTo` function in strategy manager.
     *        6. Assign lastRebalance.
     */
    function performRebalance() external nonReentrant onlyRole(REBALANCER_ADMIN) {
        // Revert if the current time is less than the last rebalanced time plus the minimum interval.
        if (block.timestamp < lastRebalance + MIN_INTERVAL) {
            revert CooldownIsActive();
        }

        // Call the internal `_checkActiveStrategy` function.
        _checkActiveStrategy();

        // Read the current strategy's Id.
        uint256 currentStrategyId = s_manager.activeStrategyId();

        // Read the strategy with the best APR.
        uint256 bestStrategyId = _selectBestStrategy();

        // Emit the event RebalancePlanned.
        emit RebalancePlanned(currentStrategyId, bestStrategyId);

        // Assign the lastRebalance.
        lastRebalance = block.timestamp;

        // Call the `rebalanceFromTo` function in strategy manager.
        s_manager.rebalanceFromTo(currentStrategyId, bestStrategyId);

        // Emit the event Rebalanced.
        emit Rebalanced(currentStrategyId, bestStrategyId, lastRebalance, msg.sender);
    }

Design Decisions:

  1. Why cooldown? Prevent griefing (spamming performRebalance costs gas)
  2. Why restricted? Ensures only authorized can call, preventing unneccesary rebalancing.

Design Patterns

1. Checks-Effects-Interactions (CEI)

Pattern:

// ✅ GOOD
function withdraw(uint256 sharesAmount) external {
    // 1. Checks
    // Revert if the `sharesAmount` is greater than the caller's shares balance.
    if (sharesAmount > userShares) {
        revert InsufficientShares(userShares);
    }
    
    // 2. Effects
    s_totalShares -= sharesAmount;

    // Subtract sharesAmount from the caller's shares.
    s_shares[msg.sender] = userShares - sharesAmount;
    
    // 3. Interactions
    // Call the internal `_safeTransfer` helper function and transfer the amount to the caller.
    _safeTransfer(s_asset, msg.sender, assetsOut);
}

// ❌ BAD
function withdraw(uint256 shares) external {
    s_asset.transfer(msg.sender, assets);  // Interaction first!
    s_shares[msg.sender] -= shares;        // State change after external call
}

Why? Prevents reentrancy attacks where external call modifies state mid-function.


2. Pull Over Push

Pattern:

// ✅ GOOD - User pulls their yield
function withdraw() external {
    uint256 assets = calculateAssets(msg.sender);
    s_asset.transfer(msg.sender, assets);
}

// ❌ BAD - Contract pushes yield to all users
function distributeYield() external {
    for (uint i = 0; i < users.length; i++) {
        s_asset.transfer(users[i], yield[i]);  // Gas bomb!
    }
}

Why? Prevents DOS attacks and gas limit issues.


3. Interface Segregation

Pattern:

// VaultCore only needs these functions
interface IStrategyManagerVault {
    // ======================================================== External Write Functions ============================================
    /// @notice Deposits amount into the active strategy. Callable only by the vault. 
    /// @param amount The amount of token to be deposited.
    function depositToActive(uint256 amount) external;

    /// @notice Withdraws amount from the active strategy back to the caller. Callable only by the vault. 
    /// @param amount The amount of token to be withdrawn from the strategy.
    function withdrawFromActive(uint256 amount) external;

    // ===================================================== External Read Function =================================================
    /// @notice Returns total deployed strategy funds.
    /// @return Total deployed strategy funds.
    function totalDeployed() external view returns (uint256);
}

// Rebalancer needs different functions
 interface IStrategyManagerRebalancer {
    // ========================== Rebalancer's Write Function ===========================================
    /// @notice Rebalances asset from the current strategy id to the best strategy id.
    /// @dev Emits the event Rebalanced on the Rebalancer performRebalance function.
    /// @param fromId The current strategy's id.
    /// @param toId The best strategy's id.
    function rebalanceFromTo(uint256 fromId, uint256 toId) external;

    // ========================== External Read Functions ===============================================
    /// @notice Returns the active strategy's address.
    /// @return strategy The active strategy's address.
    function activeStrategy() external view returns (address strategy);
    
    /// @notice Returns the active strategy's Id. 
    /// @return id The active strategy's Id. 
    function activeStrategyId() external view returns (uint256 id);

    /// @notice Returns the strategy's Annual Percentage Rate (APRs).
    /// @return aprs The strategy's APRs.
    function strategyAPRs() external view returns (uint256[] memory aprs);
}

Why? Limits attack surface - VaultCore can't accidentally call addStrategy().


4. Immutable References

Pattern:

IERC20 public immutable s_asset;

Why?

  • Gas savings (~2100 gas per read vs 800 for storage)
  • Security - can't be changed after deployment
  • Clarity - readers know it never changes

Security Considerations

Attack Vectors Mitigated

1. Reentrancy

  • Mitigation: nonReentrant on deposit/withdraw
  • Why needed: ERC777 tokens can callback during transferFrom()

2. Share Inflation Attack

  • Attack: First depositor deposits 1 wei, then donates huge amount to inflate share price
  • Mitigation: First depositor gets 1:1 ratio (not rounded)
  • Residual risk: Still possible but requires large donation

3. Rounding Errors

  • Attack: Deposit tiny amounts to exploit rounding
  • Mitigation: ZeroAmount check prevents dust deposits
  • Residual risk: Precision loss on very large balances

4. Strategy Drain

  • Attack: Malicious strategy steals funds
  • Mitigation:
    • Only King/Admin can add strategies (trust assumption)
    • Strategies must be audited before adding
  • Missing: Strategy whitelist, withdrawal limits

5. Admin Abuse

  • Attack: Compromised Admin switches to malicious strategy
  • Mitigation: King can revoke Admin
  • Missing: Timelock on strategy changes

Known Limitations

  1. No strategy withdrawal limits - Admin could drain by switching to malicious strategy
  2. No governance - King has permanent control
  3. No insurance - User funds not covered if strategy fails
  4. No slippage protection - Users could receive fewer assets than expected if:
    • Share price changes between tx submission and execution
    • Strategy experiences losses during withdrawal
    • Large deposits/withdrawals happen before their tx

Gas Optimization

Optimization Techniques Used

1. Immutable Variables

IERC20 public immutable s_asset;  // 800 gas per read
// vs
IERC20 public s_asset;            // 2100 gas per read

Savings: 1300 gas per read

2. Unchecked Arithmetic

unchecked { ++i; }  // No overflow check
// vs
i++;                 // Includes overflow check

Savings: ~30 gas per increment

3. Custom Errors

revert ZeroAmount();              // ~200 gas
// vs
revert("Amount cannot be zero");  // ~2000 gas

Savings: ~1800 gas per revert

4. Short-Circuit Logic

if (balance == 0 || balance < required) return;
// Checks balance == 0 first (cheaper) before expensive comparison

5. Caching Storage Reads

uint256 supply = s_totalShares;  // 2100 gas (SLOAD)
// Use supply 3 times = 3 gas each (MLOAD)
// vs reading s_totalShares 3 times = 6300 gas

Savings: ~6000 gas for 3 reads


Gas Costs (Estimated)

Operation Gas Cost
First deposit ~150k
Subsequent deposit ~100k
Withdraw (vault has balance) ~80k
Withdraw (needs strategy pull) ~180k
Add strategy ~90k
Set active strategy ~50k
Rebalance ~250k

Next Steps