Technical deep dive into KYA's contract architecture, design decisions, and implementation details.
- System Overview
- Contract Specifications
- State Variables
- Key Functions
- Design Patterns
- Security Considerations
- Gas Optimization
┌──────────────────────────────────────────────────────┐
│ 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 │ │
│ └──────────────────┘ │
└────────────────────────┘
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 sharesKey Functions:
deposit(uint256 amount)- Mint shares, deploy to strategywithdraw(uint256 sharesAmount)- Burn shares, return assets + yieldwithdrawAll()- Convenience function for full withdrawaltotalAssets()- Vault balance + deployed strategy balance
Access Control:
- King:
assignAdmin(),assignStrategyManager() - Admin:
assignStrategyManager() - Anyone:
deposit(),withdraw()
Purpose: Orchestrate strategy deployment and switching
Inheritance:
StrategyManager is IStrategyManager, Utils, ManagerTypes, KingablePausableCore 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 rebalancerKey Functions:
addStrategy(address strategy)- Register new strategyremoveStrategy(address strategy)- Unregister (must have 0 balance)setActiveStrategy(uint256 id)- Switch active strategydepositToActive(uint256 amount)- Send funds to active strategywithdrawFromActive(uint256 amount)- Pull funds from active strategyaddRebalancer(address rebalancer)- Authorize rebalancer contract
Access Control:
- King/Admin:
addStrategy(),removeStrategy(),setActiveStrategy(),addRebalancer() - Vault only:
depositToActive(),withdrawFromActive() - Rebalancer only:
rebalanceFromTo()
Purpose: Monitor APRs and trigger fund reallocation
Inheritance:
Rebalancer is Utils, KingablePausableCore 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()
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 lendingCompoundStrategyMock.sol- Simulates Compound lending
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)
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_lifetimeStrategiesseparately
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
/// @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:
- Why calculate shares first?
totalAssets()includes strategy balance, which could change during call - Why
nonReentrant? Token transfer could callback (ERC777, malicious tokens) - Why update state before transfer? Checks-Effects-Interactions pattern
- Why allow 0 strategy manager? Vault can work standalone (just holds tokens)
/// @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:
- Why convert before burning? Conversion uses
s_totalSharesin denominator - Why check vault balance? Gas optimization - avoid strategy call if not needed
- Why not revert if strategy fails? Better to revert (via
withdrawFromActive()) than partial withdraw
/// @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:
- Why cooldown? Prevent griefing (spamming performRebalance costs gas)
- Why restricted? Ensures only authorized can call, preventing unneccesary rebalancing.
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.
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.
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().
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
- Mitigation:
nonReentranton deposit/withdraw - Why needed: ERC777 tokens can callback during
transferFrom()
- 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
- Attack: Deposit tiny amounts to exploit rounding
- Mitigation:
ZeroAmountcheck prevents dust deposits - Residual risk: Precision loss on very large balances
- 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
- Attack: Compromised Admin switches to malicious strategy
- Mitigation: King can revoke Admin
- Missing: Timelock on strategy changes
- No strategy withdrawal limits - Admin could drain by switching to malicious strategy
- No governance - King has permanent control
- No insurance - User funds not covered if strategy fails
- 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
IERC20 public immutable s_asset; // 800 gas per read
// vs
IERC20 public s_asset; // 2100 gas per readSavings: 1300 gas per read
unchecked { ++i; } // No overflow check
// vs
i++; // Includes overflow checkSavings: ~30 gas per increment
revert ZeroAmount(); // ~200 gas
// vs
revert("Amount cannot be zero"); // ~2000 gasSavings: ~1800 gas per revert
if (balance == 0 || balance < required) return;
// Checks balance == 0 first (cheaper) before expensive comparisonuint256 supply = s_totalShares; // 2100 gas (SLOAD)
// Use supply 3 times = 3 gas each (MLOAD)
// vs reading s_totalShares 3 times = 6300 gasSavings: ~6000 gas for 3 reads
| 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 |
- Read CONCEPTS.md for high-level understanding
- Follow USAGE.md for deployment
- Review SECURITY.md for risk assessment