# Silo Finance V2 Audit Report – `DynamicKinkModel` **Auditor:** [zark](https://x.com/zarkk01) **Date:** 29/9/2025 **Commit Hash:** 499f0ce6828b796af85dbc9bc14bfbcb98e15123 **Repository:** [silo-contracts-v2](https://github.com/silo-finance/silo-contracts-v2/tree/499f0ce6828b796af85dbc9bc14bfbcb98e15123) ## Overview ### Dynamic Kind Model Overview Silo’s Dynamic Kink Model adapts the slope `k` of a kinked rate curve to utilization: `k` decays below the optimal band `[u1,u2]` and grows above it, within bounds `kMin <= k <= kMax`. A permissionless factory deploys minimal-proxy instances (CREATE2), then initializes each with silo address, owner, and immutable limits (timelock, per-second compounding cap). Configurations are updatable via timelock with pending/current tracking and the ability to cancel before activation. Safety measures cap both current APR and compounded rate and clamp `k`, with validation guarding parameter ranges and overflow paths. ### Audit Scope The audit focused on verifying the security and proper functionality of : - `DynamicKinkModel.sol` - `DynamicKinkModelFactory.sol` - `DynamicKinkModelConfig.sol` - `KinkMath.sol` ### Disclaimer This audit is not a guarantee of the absence of vulnerabilities. While every reasonable effort has been made to identify and analyze potential issues, smart contracts carry inherent risk. This is a time, resource and expertise bound effort aimed at identifying as many vulnerabilities as possible within the given constraints. ## Findings | Severity | Count | |------------|-------| | High | 1 | | Medium | 1 | | Low | 3 | ### High Severity #### High-1. Missing access control in `getCompoundInterestRateAndUpdate` lets anyone rewrite slope state `k`. **Location:** [DynamicKinkModel.sol::getCompoundInterestRateAndUpdate()](https://github.com/silo-finance/silo-contracts-v2/blob/499f0ce6828b796af85dbc9bc14bfbcb98e15123/silo-core/contracts/interestRateModel/kink/DynamicKinkModel.sol#L116-L133) **Details:** `getCompoundInterestRateAndUpdate` updates the single shared `modelState.k` using whatever inputs the caller supplies, yet the function is external and lacks an `OnlySilo` gate. Any account can invoke it, pick arbitrary collateral/debt and timestamps, and force the slope to any value within `[kmin, kmax]`. That poisoned slope is then used for the next real accrual, corrupting interest calculations and letting attackers harvest or suppress interest on demand. The interface explicitly warns this entry point “should only be called by the associated Silo contract,” but the implementation never enforces it. ```solidity function getCompoundInterestRateAndUpdate( uint256 _collateralAssets, uint256 _debtAssets, uint256 _interestRateTimestamp ) external virtual returns (uint256 rcomp) { (rcomp, modelState.k) = _getCompoundInterestRate(CompoundInterestRateArgs({ // ... })); } ``` **Recommendation:** Require `msg.sender` to be the bound Silo before mutating state (e.g. `require(msg.sender == modelState.silo, OnlySilo());`). Optionally, follow the [InterestRateModelV2.sol pattern](https://github.com/silo-finance/silo-contracts-v2/blob/499f0ce6828b796af85dbc9bc14bfbcb98e15123/silo-core/contracts/interestRateModel/InterestRateModelV2.sol#L101-L104) and partition mutable state by caller to eliminate global writes altogether. --- ### Medium Severity #### Medium-1. Pending config resets `k` immediately in `_updateConfiguration`. **Location:** [DynamicKinkModel.sol::_updateConfiguration()](https://github.com/silo-finance/silo-contracts-v2/blob/499f0ce6828b796af85dbc9bc14bfbcb98e15123/silo-core/contracts/interestRateModel/kink/DynamicKinkModel.sol#L356-L374) **Details:** `_updateConfiguration` always sets `modelState.k = _config.kmin` when a new config is queued, even when `_init == false` and the new parameters remain pending behind a timelock. During that window the Silo still fetches the old config from `irmConfig()`, but it now combines it with the freshly reset slope. Every accrual and rate query runs on a mismatched `(config, k)`. ```solidity function _updateConfiguration( IDynamicKinkModel.Config memory _config, IDynamicKinkModel.ImmutableConfig memory _immutableConfig, bool _init ) internal virtual { require(activateConfigAt <= block.timestamp, PendingUpdate()); activateConfigAt = _init ? block.timestamp : block.timestamp + _immutableConfig.timelock; verifyConfig(_config); IDynamicKinkModelConfig newCfg = IDynamicKinkModelConfig(new DynamicKinkModelConfig(_config, _immutableConfig)); configsHistory[newCfg] = _irmConfig; modelState.k = _config.kmin; // #AUDIT: This must happen immediately only upon initialisation. _irmConfig = newCfg; emit NewConfig(newCfg, activateConfigAt); } ``` As a result, the timelock no longer protects users from sudden slope changes; the owner can queue an update, instantly force `k = kmin`. **Recommendation:** Defer the `k` reset until the configuration actually activates (or limit it to `_init == true`). When scheduling a pending update, leave `modelState.k` untouched so the current config keeps its consistent slope throughout the timelock, and restore the previous `k` if the update is cancelled. --- ### Low Severity #### Low-1. Duplicated constant instead of reusing `ONE_YEAR` in `DynamicKinkModelFactory`. `generateConfig` hardcodes `int256 s = 365 days;` even though the contract already exposes `int256 public constant ONE_YEAR = 365 days;`, leaving the shared constant unused. ```solidity int256 public constant ONE_YEAR = 365 days; ... int256 s = 365 days; ``` Consider using the `ONE_YEAR` or removing it. #### Low-2. Spec-inconsistent config validation in `verifyConfig`. `verifyConfig` uses generic ranges instead of the tighter relationships described in the spec. It permits `u1` as low as zero (even though the model assumes `ulow ≤ u1`), lets `ucrit` hit a full `1e18` despite the requirement `ucrit < 1`, and repeats constraints already enforced by `UNIVERSAL_LIMIT`, so the extra `type(int96).max` checks never fire. ```solidity require(_config.u1.inClosedInterval(0, _DP), InvalidU1()); require(_config.ucrit.inClosedInterval(_config.ulow, _DP), InvalidUcrit()); require(_config.kmin.inClosedInterval(0, type(int96).max), InvalidKmin()); require(_config.kmax.inClosedInterval(_config.kmin, type(int96).max), InvalidKmax()); ``` Consider mirroring the litepaper bounds (`ulow ≤ u1`, `ucrit < 1`, etc.) and remove redundant checks so validation actually reflects the intended parameter relationships. #### Low-3. Utilization formula in `DynamicKinkModel` diverges from shared `SiloMathLib`. `DynamicKinkModel` reimplements utilization as plain `debt * 1e18 / collateral`, capping to `_DP` only when `debt ≥ collateral` or collateral is zero: ```solidity // DynamicKinkModel.sol // hard rule: utilization in the model should never be above 100%. function _calculateUtiliation(uint256 _collateralAssets, uint256 _debtAssets) internal pure virtual returns (int256) { if (_debtAssets == 0) return 0; if (_collateralAssets == 0 || _debtAssets >= _collateralAssets) return _DP; // forge-lint: disable-next-line(unsafe-typecast) return int256(Math.mulDiv(_debtAssets, uint256(_DP), _collateralAssets, Math.Rounding.Floor)); } ``` But `InterestRateModelV2.sol` relies on `SiloMathLib.calculateUtilization`, which handles guards differently : ```solidity // SiloMathLib.sol function calculateUtilization(uint256 _dp, uint256 _collateralAssets, uint256 _debtAssets) internal pure returns (uint256 utilization) { if (_collateralAssets == 0 || _debtAssets == 0 || _dp == 0) return 0; /* how to prevent overflow on: _debtAssets.mulDiv(_dp, _collateralAssets, Rounding.ACCRUED_INTEREST): 1. max > _debtAssets * _dp / _collateralAssets 2. max / _dp > _debtAssets / _collateralAssets */ if (type(uint256).max / _dp > _debtAssets / _collateralAssets) { utilization = _debtAssets.mulDiv(_dp, _collateralAssets, Rounding.ACCRUED_INTEREST); // cap at 100% if (utilization > _dp) utilization = _dp; } else { // we have overflow utilization = _dp; } } ``` Consider calling `SiloMathLib.calculateUtilization` to keep the kink model in sync with the rest of the system.