AutoFinance logo

AutoFinance ExitDestinations Contract Security Review Report

June 2026

Overview

This report covers the security review for the AutoFinance. The changes included a new ExitDestinations contract which introduces more security measures. Our security assessment was a full review of the code, spanning a total of 1 week. During our review, we not identify any major severity vulnerabilities. We did identify a few minor severity vulnerabilities. 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 af ter completion of our audit

Scope

The analyzed resources are located on:

https://github.com/Tokemak/v2-core/tree/ae903f4e51a92cf0e2de9b3fde447f8d3bb771b4

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

https://github.com/Tokemak/v2-core/pull/1076

Summary

Total number of findings
6

Weaknesses

This section contains the list of discovered weaknesses.

TOKE10-1 | AMOUNTSIN MAY CONTAIN ZERO AMOUNTS, CAUSING FLASHREBALANCE() TO REVERT AND BRICKING STAGE 3

Severity:

Medium

Status:

Fixed

Path:

src/security/operations/ExitDestinations.sol#L703-L704, src/security/operations/ExitDestinations.sol#L739-L750

Description:

Stage 3 of the emergency recovery process restores Autopool accounting after a destination's underlying asset has been burned. It does this by performing a flash rebalance that moves the recovered constituent tokens into healthy destinations.

The _rebalanceEmergencyPulledOut function asks the destination vault which tokens and amounts are owed to the Autopool, then passes those returned arrays directly into the rebalance operation as tokensIn and amountsIn, without any modification or validation.

(uint256 shares, address[] memory underlyingTokens, uint256[] memory amounts) =
    IDestinationVaultV2(destination).emergencyPullAvailable(address(autopool));
 
if (shares == 0) revert NothingToRebalance(address(autopool), address(destination));
...
autopool.flashRebalance(
    IAutopilotFlashBorrower(address(this)),
    IStrategy.RebalanceParams({
        destinationsIn: targets,
        tokensIn: underlyingTokens,
        amountsIn: amounts,            // <- may contain zero entries
        destinationsOut: destinationsOut,
        tokensOut: tokensOut,
        amountsOut: amountsOut
    }),
    abi.encode(rebalanceArgs)
);

emergencyPullAvailable returns underlyingTokens = $.underlyingTokens from the destination vault and a parallel amounts array in which an element could be 0 from itself calculation (CoreFacet.sol:659–664):

uint256 toTransfer = ($.emergencyBurnTokenBalance[token] * holderAllowedToBurn / emergencySharesBurned)
    - $.emergencyBurnTokenBalanceRetrieved[autopool][token];
 
if (toTransfer > 0) {
    amounts[i] = toTransfer;
}

The aggregate shares (sharesAvailableToBurn) can be > 0 even when an individual amounts[i] == 0 (the other constituents still owe value), so the stage 3 execution still passes the requirements shares > 0. However, when executing flashRebalance in Autopool, AutopoolRebalance.validateRebalanceParams will reject any zero token amount via ensureNoZeros (AutopoolRebalance.sol:173)

function ensureNoZeros(
    address destination,
    address token,
    uint256 amount
) private pure {
    AutopilotErrors.verifyNotZero(destination, "destination");
    AutopilotErrors.verifyNotZero(token, "token");
    AutopilotErrors.verifyNotZero(amount, "amount");
}

Remediation:

Consider filtering out zero amount constituents inside the amounts array before calling flashRebalance(), or allowing the sender to pass the specific tokens of destination vault to be used.

TOKE10-2 | SOLVER REBALANCE OF THE REPORTING QUEUE HEAD CAN RESET THE STREAMING FEE FORFEITURE CLOCK

Severity:

Low

Status:

Fixed

Path:

src/vault/libs/AutopoolRebalance.sol#L53

Description:

The streaming fee forfeiture logic prorates streaming fees when profit is observed after a stale reporting gap. This relies on lastFeeEvaluationTimestamp only being refreshed after a non-stale fee evaluation.

uint256 reportingGap = block.timestamp - $.lastFeeEvaluationTimestamp;
if (reportingGap > MAX_REPORT_GAP_FOR_STREAMING_FEES) {
    uint256 chargeableProfit =
        profit.mulDiv(MAX_REPORT_GAP_FOR_STREAMING_FEES, reportingGap, Math.Rounding.Down);
    emit StreamingFeeForfeited(profit - chargeableProfit, reportingGap);
    profit = chargeableProfit;
}
 
...
 
if (!AutopoolDebt.isDebtStale($)) {
    $.lastFeeEvaluationTimestamp = block.timestamp;
}

However, isDebtStale() only checks the current head of the debt reporting queue.

function oldestDebtReporting(
    AutopoolState storage $
) public view returns (uint256) {
    return $.destinationInfo[$.debtReportQueue.peekHead()].lastReport;
}

A solver-controlled rebalance can refresh the current queue head before fee handling runs. The touched destination is re-snapshotted during processRebalance(), while queue updates happen only after onValueChange().

updatedAssets = processRebalance($, params, data, hooks);
 
address(feeHandler)
    .functionDelegateCall(abi.encodeCall(IAutopoolFeeHandler.onValueChange, (updatedAssets, hooks, false)));
 
...
 
AutopoolDestinations.manageQueuesForDestination($, rebalanceParams.destinationsOut[i], false);

The relevant call flow is:

AutopoolETH.flashRebalance()
    -> AutopoolRebalance.flashRebalance()
        -> processRebalance()
            -> AutopoolDebt.flashRebalance()
                -> _recalculateDestInfo()
                    -> touchedDestination.lastReport = block.timestamp
        -> feeHandler.onValueChange()
            -> AutopoolFees.collectFees()
                -> AutopoolDebt.isDebtStale()
                    -> debtReportQueue.peekHead().lastReport
                -> lastFeeEvaluationTimestamp = block.timestamp
        -> manageQueuesForDestination()
            -> touched destination is moved in the queue

As a result, if the current queue head is rebalanced after the pool has become stale, that head remains at the front of the queue during fee handling while its lastReport has already been refreshed. isDebtStale() then returns false and lastFeeEvaluationTimestamp is updated, even if other destinations remain stale and contain unreported gains.

When a later debt report surfaces those non-head gains, the reporting gap is measured from the rebalance rather than from the last full non-stale reporting point. This can cause streaming fees to be charged on profit that would otherwise have been partially forfeited.

Remediation:

Move debt reporting queue updates for touched destinations before rebalance fee handling.

Alternatively, only update lastFeeEvaluationTimestamp during full debt reporting.

If rebalance-time fee evaluation remains supported, make the staleness check use the oldest lastReport across all active debt-reporting destinations instead of only the current queue head.

TOKE10-3 | STAGE4 USES DIRECT-CALL SWAPPER SEMANTICS WITH DELEGATECALL-BASED ASYNC SWAPPERS

Severity:

Low

Status:

Fixed

Path:

src/security/operations/ExitDestinations.sol:#L430

Description:

Stage4 emergency rebalancing converts received tokensOut by approving a registered async swapper and then calling the supplied payload with a normal external call. This flow assumes the swapper will pull the approved tokens from ExitDestinations.

address swapOp = stage4Args.swapOps[i].asyncSwapper;
 
systemRegistry.asyncSwapperRegistry().verifyIsRegistered(swapOp);
 
IERC20(tokenOut).safeApprove(swapOp, 0);
IERC20(tokenOut).safeApprove(swapOp, swapAmount);
 
swapOp.functionCall(stage4Args.swapOps[i].payload);

The production async swappers use a different custody model. BaseAsyncSwapper does not pull tokens from msg.sender. it assumes the current execution context already holds the sell token, approves the aggregator from address(this), and measures output at address(this).

address approveTo = _getApproveTo();
SafeERC20.safeApprove(sellToken, approveTo, swapParams.sellAmount);
 
uint256 buyTokenBalanceBefore = buyToken.balanceOf(address(this));
 
(bool success,) = AGGREGATOR.call(swapParams.data);

The deployment path registers BaseAsyncSwapper instances in the async swapper registry.

BaseAsyncSwapper zeroExSwapper = new BaseAsyncSwapper(values.ext.zeroExProxy, false);
asyncSwapperRegistry.register(address(zeroExSwapper));

When Stage4 calls this swapper directly, address(this) inside the swapper is the swapper contract, not ExitDestinations. The approval from ExitDestinations to the swapper is not consumed by the aggregator call, so the swap can revert or fail the expected output check.

The existing Stage4 tests use direct-pull helper swappers, which match Stage4's current approve + call flow but do not match the registered async swapper model.

contract SdaiDaiSlippageSwap {
    function swap(address sdai, address dai, uint256 sdaiIn, uint256 daiOut) external {
        IERC20(sdai).safeTransferFrom(msg.sender, address(this), sdaiIn);
        IERC4626(sdai).redeem(sdaiIn, address(this), address(this));
        IERC20(dai).safeTransfer(msg.sender, daiOut);
    }
}

This affects the Stage4 swap-based conversion path when operators use the registered async swappers. Funds are not permanently locked, since tokens held by ExitDestinations can still be recovered manually through recover().

Remediation:

Use a Stage4-specific swapper interface that explicitly pulls sell tokens from the caller.

Do not accept existing async swapper registry entries for Stage4 unless they are marked as direct-call-compatible.

Alternatively, execute the existing async swappers through a delegatecall-based path with appropriate custody and reentrancy controls.

TOKE10-5 | NESTED AUTOPOOL VALUATION USES GLOBAL SUPPLY FOR DEPOSIT-SIDE PRICING

Severity:

Low

Status:

Fixed

Path:

src/vault/destinations/v2/facets/protocols/tokemak/AutopilotValuationFacet.sol#L49

Description:

When an Autopool is used as the underlying asset of an Autopilot destination, the valuation facet prices the child Autopool using deposit-purpose assets but the no-argument totalSupply(), which represents global supply.

uint256 totalSupply = autopool.totalSupply(); // Implied Global
uint256 taDeposit = autopool.totalAssets(IAutopool.TotalAssetPurpose.Deposit);
 
uint256 maxVal = autopool.convertToAssets(1e18, taDeposit, totalSupply, Math.Rounding.Down);

Deposit-purpose supply is intended to exclude still-locked profit shares so new deposits do not receive value earned before entry.

function totalSupply(
    IAutopool.TotalSupplyPurpose purpose
) public view returns (uint256 shares) {
    shares = totalSupply();
    if (purpose == IAutopool.TotalSupplyPurpose.Deposit) {
        uint256 locked = AutopoolFees.lockedProfitShares();

During a child Autopool's locked-profit period, this can understate the destination's deposit-side value. If a parent Autopool holds that destination, new parent deposits may receive more shares than they would under deposit-purpose child supply.

Remediation:

  • Use totalSupply(TotalSupplyPurpose.Deposit) when pairing with totalAssets(TotalAssetPurpose.Deposit).
  • Use purpose-matched supply values in range pricing for deposit and withdrawal paths.

TOKE10-4 | FIRST POST-UPGRADE PROFIT COLLECTION CAN FORFEIT STREAMING FEES

Severity:

Low

Status:

Fixed

Path:

src/vault/libs/AutopoolFees.sol#L70

Description:

lastFeeEvaluationTimestamp is used to prorate streaming-fee profit when the reporting gap is larger than MAX_REPORT_GAP_FOR_STREAMING_FEES. For existing Autopools upgraded to this implementation, this newly added storage value can remain zero. The fee initializer seeds the periodic fee timestamp and fee mark timestamp, but does not seed lastFeeEvaluationTimestamp.

function initializeFeeAndProfitSettings() external {
    AutopoolState storage $ = AutopoolStorage.load();
    uint256 timestamp = block.timestamp;
 
    $.feeSettings.lastPeriodicFeeTake = timestamp;
    $.feeSettings.navPerShareLastFeeMark = FEE_DIVISOR;
    $.feeSettings.navPerShareLastFeeMarkTimestamp = timestamp;
 
    emit LastPeriodicFeeTakeSet(timestamp);
 
    setProfitUnlockPeriod(86_400);
}

During the first post-upgrade fee evaluation that observes profit, collectFees() measures the reporting gap from zero. This causes only a small prorated portion of the profit to remain chargeable.

uint256 reportingGap = block.timestamp - $.lastFeeEvaluationTimestamp;
if (reportingGap > MAX_REPORT_GAP_FOR_STREAMING_FEES) {
    uint256 chargeableProfit =
        profit.mulDiv(MAX_REPORT_GAP_FOR_STREAMING_FEES, reportingGap, Math.Rounding.Down);
    emit StreamingFeeForfeited(profit - chargeableProfit, reportingGap);
    profit = chargeableProfit;
}

The fee mark is then updated to the current NAV, and lastFeeEvaluationTimestamp is only stamped afterward when debt is not stale.

if (!AutopoolDebt.isDebtStale($)) {
    $.lastFeeEvaluationTimestamp = block.timestamp;
}

As a result, an upgraded pool with active streaming fees and a configured fee sink can miss most of the streaming fee for the first profitable fee evaluation after the upgrade. Once the fee mark has been rebased, that skipped fee is not collected later.

Remediation:

Seed lastFeeEvaluationTimestamp during fee and profit initialization for new Autopools.

Add an upgrade migration or reinitializer that sets lastFeeEvaluationTimestamp for existing Autopools.

Handle a zero lastFeeEvaluationTimestamp in fee collection using an existing conservative fee timestamp instead of Unix zero.

TOKE10-6 | PRO-RATA-ONLY MODE DOES NOT AUTOMATICALLY DISABLE DEPOSITS

Severity:

Informational

Status:

Acknowledged

Path:

src/vault/AutopoolETH.sol#L1172

Description:

proRataOnly is applied to standard redemption paths, but it is not used as a deposit gate. When redeemBlockBps is enabled, a large reported value drop sets this mode on the Autopool.

uint256 redeemBlockBps = $.redeemBlockBps;
if (redeemBlockBps > 0 && !$.proRataOnly && newTotal < startingTotal) {
    uint256 decrease = startingTotal - newTotal;
    if (decrease * FEE_DIVISOR > redeemBlockBps * startingTotal) {
        emit ProRataOnlyTriggered(startingTotal, newTotal, redeemBlockBps);
        _setProRataOnly($, true);
    }
}

Standard redemptions explicitly enforce proRataOnly.

function _redeemProtected(
    uint256 shares,
    address receiver,
    address owner,
    IAutopool.TotalAssetPurpose purpose
) private whenNotPaused onUserInteraction(purpose) returns (uint256 assets) {
    if (AutopoolStorage.load().proRataOnly) {
        revert ProRataOnlyEnforced();
    }
    assets = _redeemOps(shares, receiver, owner, purpose);
}

Deposits are controlled separately through pause, shutdown, stale-debt checks, and the deposit high-watermark gate. maxMint() does not check proRataOnly.

bool isDebtStale = $.isDebtStale();
 
if (paused || $.shutdown || isDebtStale) {
    return 0;
}
 
uint256 toleranceBps = $.highWatermarkToleranceBps;
if (toleranceBps > 0) {
    uint256 high = $.highWatermarkDeposits;
    uint256 currentNavPerSharePadded = ($.assetBreakdown.totalIdle + $.assetBreakdown.totalDebtMax) * 1e24 / ts;
    if (currentNavPerSharePadded * AutopoolFees.FEE_DIVISOR < high * (AutopoolFees.FEE_DIVISOR - toleranceBps))
    {
        return 0;
    }
}
 
return type(uint112).max - ts;

Therefore, enabling pro-rata-only redemptions should not be assumed to also prevent new deposits. If deposits are intended to be unavailable during the same value-drop state, the deposit-side gate must be configured or enforced separately.

Remediation:

Apply the proRataOnly state to deposit and mint limits if deposits should be disabled during this mode.

Add an explicit proRataOnly check to the direct deposit and mint paths if entry should be blocked.

Table of contents