Cybro logo

Cybro V3, V4 & Automation Security Review Report

March 2026

Overview

This report covers the security review for Cybro. This audit covered the Solidity smart contracts of the Cybro V3, V4 and automation features. Cybro allows liquidity providers to easily modify their position and enable automation. Our security assessment was a full review of the code, spanning a total of 3 weeks. During our review, we did not identify any major security vulnerabilities. We did identify several Medium severity vulnerabilities and other minor issues and optimizations. All reported issues were fixed or acknowledged by the development team and subsequently verified by us.  We can confidently say that the overall security and code quality have increased after completion of our audit. 

Scope

The analyzed resources are located on:

https://github.com/cybro-io/contracts-v2/tree/c90b637c102c5f3ef4844510e2f920c4d1502655

The issues described in this report were fixed in the following commit:

https://github.com/cybro-io/contracts-v2/tree/7b9c62cad23670f28f0b7c0f87ed7c125a442ecd

Summary

Total number of findings
8

Weaknesses

This section contains the list of discovered weaknesses.

CYBRO1-3 | AUTOMANAGER SINGLE-TOKEN AUTO CLAIM/CLOSE EXECUTES SWAPS WITHOUT MINIMUM OUTPUT PROTECTION

Severity:

Medium

Status:

Fixed

Path:

src/BaseAutoManagerV3.sol, src/AutoManagerV4.sol

Description:

In the autoClaimFees and autoClose flows, when transferType is TOKEN0 or TOKEN1, the contract performs an internal conversion swap but does not enforce a minimum acceptable output. This differs from the manual LP manager single-token claim/withdraw paths, which include minAmountOut checks.

As a result, the automated transaction can still succeed even if execution price is significantly worse at the time of inclusion (for example, after short-lived price skewing). The user receives less output token than expected, with no on-chain guard in the auto path to reject the trade.

function _swapWithPriceLimit(bool zeroForOne, uint256 amount, PoolInfo memory poolInfo, uint160 sqrtPriceLimitX96)
    internal
    returns (int256 amount0, int256 amount1)
{
    if (amount == 0) return (0, 0);
    if (sqrtPriceLimitX96 == 0) {
        sqrtPriceLimitX96 = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1;
    }
    (amount0, amount1) = IUniswapV3Pool(poolInfo.pool)
        .swap(
            address(this),
            zeroForOne,
            int256(amount),
            sqrtPriceLimitX96,
            zeroForOne
                ? abi.encode(poolInfo.token0, poolInfo.token1, poolInfo.fee)
                : abi.encode(poolInfo.token1, poolInfo.token0, poolInfo.fee)
        );
}
function _swapWithPriceLimit(bool zeroForOne, uint256 amount, PoolKey memory key, uint160 sqrtPriceLimitX96)
    internal
    returns (int256 amount0, int256 amount1)
{
    if (amount == 0) return (0, 0);
    if (sqrtPriceLimitX96 == 0) {
        sqrtPriceLimitX96 = zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1;
    }
 
    bytes memory data = abi.encode(CALLBACK_ACTION_SWAP, abi.encode(key, zeroForOne, amount, sqrtPriceLimitX96));
    bytes memory result = poolManager.unlock(data);
    BalanceDelta delta = abi.decode(result, (BalanceDelta));
 
    return (delta.amount0(), delta.amount1());
}

Remediation:

Add a user-defined minimum output (or equivalent slippage bound) to auto requests and revert if realized output is below that threshold in single-token auto claim/close paths.

CYBRO1-4 | PARTIAL SWAP FILL IN _CHARGEFEESWAPTRANSFER CAN LEAVE UNCONSUMED INPUT UNSETTLED

Severity:

Medium

Status:

Fixed

Path:

src/BaseLPManagerV3.sol, src/BaseLPManagerV4.sol

Description:

When _chargeFeeSwapTransfer executes a single-token conversion (TransferInfoInToken.TOKEN0 or TOKEN1), it calls _swap and then unconditionally sets the opposite-side amount to zero. The issue is that _swap returns only the output amount and does not return how much input was actually consumed.

Under partial-fill conditions (for example, when executable liquidity is exhausted before the requested input is fully spent), the swap callback settles only the consumed portion. The unconsumed input remains in the manager contract. Because the input-side amount is zeroed unconditionally, that residual is excluded from settlement and is not transferred to the recipient in that execution path.

function _chargeFeeSwapTransfer(
    uint256 amount0In,
    uint256 amount1In,
    uint256 positionId,
    TransferInfoInToken transferInfoInToken,
    IProtocolFeeCollector.FeeType feeType,
    address recipient
) internal returns (uint256 amount0, uint256 amount1) {
    PoolInfo memory poolInfo = _getPoolInfoById(positionId);
    amount0 = _collectProtocolFee(poolInfo.token0, amount0In, feeType);
    amount1 = _collectProtocolFee(poolInfo.token1, amount1In, feeType);
    if (transferInfoInToken != TransferInfoInToken.BOTH) {
        if (transferInfoInToken == TransferInfoInToken.TOKEN0) {
            amount0 += _swap(false, amount1, poolInfo);
            amount1 = 0;
        } else {
            amount1 += _swap(true, amount0, poolInfo);
            amount0 = 0;
        }
    }
    if (amount0 > 0) IERC20Metadata(poolInfo.token0).safeTransfer(recipient, amount0);
    if (amount1 > 0) IERC20Metadata(poolInfo.token1).safeTransfer(recipient, amount1);
}
function _chargeFeeSwapTransfer(
    uint256 amount0In,
    uint256 amount1In,
    uint256 positionId,
    TransferInfoInToken transferInfoInToken,
    IProtocolFeeCollector.FeeType feeType,
    address recipient
) internal returns (uint256 amount0, uint256 amount1) {
    PoolKey memory key = _getPoolKey(positionId);
    amount0 = _collectProtocolFee(key.currency0, amount0In, feeType);
    amount1 = _collectProtocolFee(key.currency1, amount1In, feeType);
    if (transferInfoInToken != TransferInfoInToken.BOTH) {
        if (transferInfoInToken == TransferInfoInToken.TOKEN0) {
            amount0 += _swap(false, amount1, key);
            amount1 = 0;
        } else {
            amount1 += _swap(true, amount0, key);
            amount0 = 0;
        }
    }
 
    _transfer(key.currency0, amount0, recipient);
    _transfer(key.currency1, amount1, recipient);
}

Remediation:

Update _chargeFeeSwapTransfer to settle using actual swap deltas (input consumed and output received).

Instead of zeroing the input side, decrement it by the consumed amount and transfer any unconsumed residual to the recipient in the same flow. Apply this change consistently across both V3 and V4 implementations.

CYBRO1-6 | AUTOREBALANCE CAN FAIL FOR POOLS WITH UNEVEN TICK SPACINGS

Severity:

Medium

Status:

Fixed

Path:

src/AutoManagerV4.sol:autoRebalance#L206-L233

Description:

The autoRebalance function allows the AutoManager to rebalance the position of a Cybro user automatically.

The function takes the difference in the position's upper and lower tick to get the width of the position. This width will always be a multiple of the pool's tick spacing. It then divides this width by 2 and calculates the new lower and upper tick by subtracting and adding half of this width to the current tick respectively:

(, int24 currentTick,,) = poolManager.getSlot0(_toId(ctx.poolKey));
int24 newLower;
int24 newUpper;
{
    // Calculate new range centered on current tick with same width as before
    int24 widthTicks = ctx.tickUpper - ctx.tickLower;
    newLower = currentTick - widthTicks / 2;
    newLower -= newLower % ctx.poolKey.tickSpacing;
    newUpper = currentTick + widthTicks / 2;
    newUpper -= newUpper % ctx.poolKey.tickSpacing;
}

However, this is incompatible with pools with an uneven tick spacing due to rounding.

For example, a tick spacing of 3 ticks would result in a lot of failed rebalancing. Consider the user having a position on ticks 0-3 and the current tick is at tick 4, triggering a rebalance. It would calculate:

  • The tick width as 3.
  • The new lower would be 4 - (3 // 2) = 4 - 1 = 3 and modulo'd to 3 - (3 % 3) = 3 - 0 = 3
  • The new upper would be 4 + (3 // 2) = 4 + 1 = 5 and modulo'd to 5 - (5 % 3) = 5 - 2 = 3 Because of the rounding in the halving of the tick width, it becomes possible for the new lower and the new upper to become equal. This is not allowed for a Uniswap LP position and it would cause a revert when trying to create such a position.

If the original position was larger than 1 tick spacing, then the rounding would cause the position size to shrink.

function autoRebalance(AutoRebalanceRequest calldata request, bytes memory signature)
    external
    onlyRole(AUTO_MANAGER_ROLE)
{
    // Verify rebalance trigger condition is met (price outside bounds)
    require(needsRebalance(request), NotNeededAutoAction());
 
    // Validate signature from position owner and get owner address
    address owner = _validateSignatureFromOwner(
        _hashTypedDataV4(keccak256(abi.encode(AUTO_REBALANCE_REQUEST_TYPEHASH, request))),
        signature,
        request.positionId
    );
    PositionContext memory ctx = _getPositionContext(request.positionId);
    _checkPriceManipulation(ctx.poolKey);
    (, int24 currentTick,,) = poolManager.getSlot0(_toId(ctx.poolKey));
    int24 newLower;
    int24 newUpper;
    {
        // Calculate new range centered on current tick with same width as before
        int24 widthTicks = ctx.tickUpper - ctx.tickLower;
        newLower = currentTick - widthTicks / 2;
        newLower -= newLower % ctx.poolKey.tickSpacing;
        newUpper = currentTick + widthTicks / 2;
        newUpper -= newUpper % ctx.poolKey.tickSpacing;
    }
    _moveRange(request.positionId, newLower, newUpper, owner, 0, owner);
}

Remediation:

The half of the tick width should be rounded up for the calculation of the new upper tick.

For example:

newUpper = currentTick + widthTicks / 2 + (widthTicks % 2 == 1 ? 1 : 0);
CYBRO1-7 | MALFORMED EIP-712 TYPE STRINGS PREVENT STANDARD WALLET SIGNATURES FOR AUTO-MANAGER REQUESTS

Severity:

Medium

Status:

Fixed

Path:

src/AutoManagerV4.sol, src/BaseAutoManagerV3.sol

Description:

BaseAutoManagerV3.sol and AutoManagerV4.sol define the EIP-712 type hashes for AutoClaimRequest, AutoRebalanceRequest, and AutoCloseRequest using type strings that include extra whitespace after some commas. This makes the stored TYPEHASH values differ from the encodeType output defined by EIP-712.

bytes32 public constant AUTO_CLAIM_REQUEST_TYPEHASH = keccak256(
    "AutoClaimRequest(uint256 positionId,uint256 initialTimestamp,uint256 claimInterval,uint256 claimMinAmountUsd,address recipient,uint8 transferType, uint256 nonce)"
);
bytes32 public constant AUTO_REBALANCE_REQUEST_TYPEHASH =
    keccak256("AutoRebalanceRequest(uint256 positionId,uint160 triggerLower,uint160 triggerUpper, uint256 nonce)");
bytes32 public constant AUTO_CLOSE_REQUEST_TYPEHASH = keccak256(
    "AutoCloseRequest(uint256 positionId,uint160 triggerPrice,bool belowOrAbove,address recipient,uint8 transferType, uint256 nonce)"
);

As a result, the contracts verify signatures against a non-standard struct hash, while standard EIP-712 signers such as common wallets and libraries derive the struct hash from the properly formatted type string. In practice, signatures produced through standard signTypedData flows will not match the on-chain digest and will be rejected.

Remediation:

Update the affected EIP-712 type hash definitions so that the type strings exactly match the format required by EIP-712, without extra whitespace in the field list.

CYBRO1-1 | MISSING CHAINLINK STALE-PRICE VALIDATION IN ORACLEDATA

Severity:

Informational

Status:

Fixed

Path:

src/libraries/OracleData.sol#L18

Description:

After calling latestRoundData(), the library only checked updatedAt != 0, which is insufficient to guarantee price freshness and round completeness. As a result, stale prices (hours/days old) or incomplete round data could be treated as valid.

In addition, when integrating stricter validation (e.g., answeredInRound >= roundId and staleness checks), care must be taken to ensure that a revert inside the oracle validation path does not unintentionally cause upstream logic (such as _checkPriceManipulation) to bypass price manipulation checks via a try/catch fail-open pattern. If a newly added require triggers a revert and the caller catches it with an early return, the price manipulation validation may be skipped entirely, effectively reintroducing a fail-open condition.

Therefore, oracle validation failures should be handled in a fail-closed manner, ensuring that manipulation checks cannot be bypassed when oracle calls revert.

function getPrice(IChainlinkOracle oracle) public view returns (uint256) {
    (, int256 price,, uint256 updatedAt,) = oracle.latestRoundData();
    require(updatedAt != 0, RoundNotComplete());
    require(price > 0, ChainlinkPriceReportingZero());
 
    return uint256(price);
}

Remediation:

Consider adding explicit round completeness and time-based freshness.

require(answeredInRound >= roundId, StalePrice()); // Round completeness check
require(block.timestamp - updatedAt <= MAX_STALENESS, StalePrice()); // Time-based freshness check
CYBRO1-2 | MISSING EVENTS ON SIGNIFICANT STATE CHANGES

Severity:

Informational

Status:

Fixed

Path:

src/AutoManagerV4.sol:setOracle#L153-L155, src/BaseAutoManagerV3.sol:setOracle#L151-L153, src/Oracle.sol:setPrimaryOracle, setOracles#L127-L129, L136-L143

Description:

The functions in various contracts that set the one or multiple oracles do not emit event, even though these are significant state changes in the configuration.

This makes off-chain tracking cumbersome and can hurt off-chain services.

function setOracle(IOracle _oracle) external onlyRole(DEFAULT_ADMIN_ROLE) {
    oracle = _oracle;
}

Remediation:

We recommend to emit the appropriate events on significant configuration state changes to notify any off-chain trackers such as chain explorers.

CYBRO1-5 | DIRECTION BASED DELTA SETTLEMENT IN UNLOCKCALLBACK CAN REVERT ON DELTA-RETURNING HOOK POOLS

Severity:

Informational

Status:

Acknowledged

Path:

src/BaseLPManagerV4.sol

Description:

The internal swap settlement path in unlockCallback derives debt and amountOut from zeroForOne under canonical delta assumptions. In Uniswap v4, that assumption does not always hold for pools where delta-returning hooks are enabled, because hooks can modify swap input/output and hook delta is applied to the final caller delta.

When the returned delta shape (sign/magnitude relationship) is atypical, signed-to-unsigned conversion may revert, or one-sided settlement may leave an outstanding balance that causes unlock to revert with CurrencyNotSettled.

function unlockCallback(bytes calldata data) external override onlyPoolManager returns (bytes memory) {
    (uint8 action, bytes memory params) = abi.decode(data, (uint8, bytes));
    if (action == CALLBACK_ACTION_SWAP) {
        // Decode swap parameters
        (PoolKey memory _key, bool zeroForOne, uint256 amount, uint160 sqrtPriceLimitX96) =
            abi.decode(params, (PoolKey, bool, uint256, uint160));
 
        UniswapPoolKey memory key = _cast(_key);
 
        BalanceDelta delta = poolManager.swap(
            key,
            IPoolManager.SwapParams({
                zeroForOne: zeroForOne, amountSpecified: -int256(amount), sqrtPriceLimitX96: sqrtPriceLimitX96
            }),
            new bytes(0)
        );
 
        (Currency currencyIn, Currency currencyOut) =
            zeroForOne ? (key.currency0, key.currency1) : (key.currency1, key.currency0);
 
        // Calculate debt (input amount) and output amount from delta
        uint128 debt = zeroForOne ? uint128(-delta.amount0()) : uint128(-delta.amount1());
        uint128 amountOut = zeroForOne ? uint128(delta.amount1()) : uint128(delta.amount0());
 
        // Settle debt with pool manager
        if (debt > 0) {
            poolManager.sync(currencyIn);
            if (currencyIn.isAddressZero()) {
                poolManager.settle{value: debt}();
            } else {
                currencyIn.transfer(address(poolManager), debt);
                poolManager.settle();
            }
        }
 
        // Take output tokens from pool manager
        if (amountOut > 0) {
            poolManager.take(currencyOut, address(this), amountOut);
        }
 
        return abi.encode(delta);
    }
    return "";
}

Remediation:

Settle using actual returned amount0/amount1 per token leg (negative: pay/settle, positive: take), and validate delta shape before unsigned conversions so unsupported cases fail predictably.

CYBRO1-8 | PRICE MANIPULATION CHECK LIMITS THE TICK PRICE TO 128-BIT

Severity:

Informational

Status:

Acknowledged

Path:

src/AutoManagerV4.sol:_checkPriceManipulation#L340-L350

Description:

The function _checkPriceManipulation is used during an auto rebalance to check the pool's current SqrtPriceX96 in slot0 against price manipulation.

It does by taking the square of the price and checking the deviation against a trusted oracle price. However, by taking the square of the price, the function will revert with an overflow if the value is greater than or equal to 128-bits, resulting in 256-bits or more.

The SqrtPriceX96 is a uint160 and a 64X96 precision number, which means that the 64 most significant bits are used for the whole number, and the 96 least significant bits are for the decimals. This also means that the SqrtPriceX96 is always 96+ bits by default.

By limiting support for the SqrtPriceX96 to only 128-bits, it means that there are only 32 of the most significant bits left for the whole number, instead of 64 bits. In others words, the square root price ratio between token A and token B is limited to 4.29 billion (32-bit), instead of a much higher value with 64 bits.

While tokens with decently high USD value might not encounter these price ratios, it is still definitely possible if a highly valued token with a low amount of decimals (e.g. WBTC) is paired with a token with 18 decimals and a low value.

In such a case, the auto rebalance price manipulation check would cause a revert.

function _checkPriceManipulation(PoolKey memory poolKey) internal view {
    uint256 trustedSqrtPrice;
    try oracle.getSqrtPriceX96(poolKey.currency0, poolKey.currency1) returns (uint160 price) {
        trustedSqrtPrice = uint256(price);
    } catch {
        return;
    }
    uint256 deviation =
        FullMath.mulDiv(uint256(getCurrentSqrtPriceX96(poolKey)) ** 2, PRECISION, trustedSqrtPrice ** 2);
    require((deviation > PRECISION - MAX_DEVIATION) && (deviation < PRECISION + MAX_DEVIATION),
        PriceManipulation());
}

Remediation:

Separately handle the case where either of the SqrtPriceX96 values are greater than type(uint128).max to ensure no overflow occurs.