Organization
- @kilnfi
Engagement Type
Spearbit Web3
Period
-
Repositories
Researchers
Findings
Medium Risk
1 findings
0 fixed
1 acknowledged
Low Risk
3 findings
0 fixed
3 acknowledged
Informational
3 findings
0 fixed
3 acknowledged
Medium Risk1 finding
SectorAccountingEngine._dispatchRedeem should not early return/revert when the vehicle is not authorized anymore
State
- Acknowledged
Severity
- Severity: Medium
Submitted by
StErMi
Description
The new implementation of the
SectorAccountingEngine._dispatchRedeemwill revert or early return (depending on thestrictparameter) if the vehicle from which theSAEis redeeming has been unauthorized.This change creates two problems:
- The new behavior is going against one important assumption and invariant previously held by the protocol: users should always be able to redeem from a sub-vehicle even if such sub-vehicle has been unauthorized.
- The
QueueStrategyEngine.unallocatefunction is not aligned to the new behavior of theSectorAccountingEngine._dispatchRedeemfunction creating a side effect issue that would prevent the protocol/users to be able to redeem assets whenSectorAccountingEngine.requestWithdrawableis executed.
The
QueueStrategyEnginecould select a vehicle that is not authorized but will skip (or revert) when the_dispatchRedeemloop iteration is executed. The result is that the total shares unallocated will be lower than what they could have been if other vehicles would have been selected by theQueueStrategyEngine.Let's assume that we have this queue in the
QueueStrategyEnginevehicle_1not authorizedvehicle_2authorized
We want to deallocate 100 assets and both vehicles can sustain that request from an allocation POV.
The
QueueStrategyEnginewill consume the 100 assets requested by usingvehicle_1, but the_dispatchRedeemwill silently fail (when invoked byrequestWithdrawable) because thevehicle_1is not authorized.Recommendation
Kiln should explain why the
SectorAccountingEnginedispatch redeem behavior and logic have been changed and refactor the code to correctly handle the issue described above.Spearbit
Acknowledged. This issue will be checked in the next audit.
Low Risk3 findings
Fee Recipient should not be automatically whitelisted by Conduit during share transfer
State
- Acknowledged
Severity
- Severity: Low
Submitted by
StErMi
Description
The
_screenUserMovefunction is executed by theConduitcontract whentransferandtransferFromare executed. Those functions are executed whenConduit's shares are moved from an account to another and theConduitneeds to validate if the action can be allowed depending on theConduitandAccountListconfiguration/rules.function _screenUserMove(address from, address to) internal view { ConduitStore.Storage storage $ = ConduitStore.getStorage(); // FeeManager fee dispatch bypasses the gate: accrued cShare fees must be dispatchable regardless of // transferEnabled/AccountList. Exempt as a SENDER only; screening then rests on keeping fee recipients clean. if (from == address($.feeManager)) { return; } if (!_ready()) { // Pre-enable: only the deployer may move shares. if (msg.sender != $.deployer) { revert ConduitErrors.TransferNotAllowed(from, to); } } else if (!_transferAllowed($, from, to)) { revert ConduitErrors.TransferNotAllowed(from, to); } }The current logic is making a "special" case when the
fromaddress is the$.feeManagerand is basically whitelisting both the$.feeManagerbut also the fee recipient (see howFeeManager.dispatchERC20works).For a "normal" user the
_screenUserMovefunction would have reverted if- the
Conduitwas not ready - the
$.transferEnabledis equal tofalse - the
_accountList.canTransfer(from, to)returns false
The
AccountList.canTransferreturns false whenfromortois sanctionedfromortois in the blacklist$.modeisAllowlistMode.STRICTandfromandtoare not both in the whitelist
Once the
touser has received the shares, the same permissions are not applied when it will create aREDEEMrequest (to receive the corresponding assets) if thereceiverof the assets is the same asmsg.sender. In that case the Conduit is much less restrictive becauseAccountList.canRedeemis executed, which reverts only if the user is sanctioned.The current behavior could allow, for example, a blocked or not whitelisted (when the mode is strict) Fee Receiver to receive and redeem shares even if the account is indeed in a blacklist or not in a whitelist.
Kiln should apply the same rules applied to the "normal" users also to the Fee Recipients of the Fee Manager.
Recommendation
Kiln should
- Not allow the Fee Manager to transfer Conduit's shares when the
Conduitis not ready - Skip the
$.transferEnabledif thefromis the Fee Manager but apply anyway theAccountList.canTransferchecks (when configured) - The Fee Manager and the Fee Recipients should be properly configured inside the Access Manager to be allowed (or not) to perform the transfer and receive them.
Spearbit
Acknowledged. This issue will be checked in the next audit. Kiln's follow-up fix in PR 459 (commit ef8e1d0e, respecting
!ready()and screening the destination) appears to target this finding and should be verified.The RequestWithdrawableShortfall event won't be triggered when selected vehicle is unauthorized and _manualShortfall = 0
State
- Acknowledged
Severity
- Severity: Low
Submitted by
StErMi
Description
As already explained in the "SectorAccountingEngine._dispatchRedeem should not early return/revert when the vehicle is not authorized anymore" Finding, it's possible that during the
requestWithdrawableexecution, theQueueStrategyEnginecontract selects a vehicle that will be silently skipped by the_dispatchRedeemexecution (when strict=false).When
_manualShortfall == 0inrequestWithdrawableit means that theQueueStrategyEnginewas able to fulfill the whole withdrawal request with the vehicles available in the queue system.But if the
_dispatchRedeemsilently fails because the vehicle is not authorized anymore, the premise held by_manualShortfallwon't be true anymore. TherequestWithdrawablehas not been able to redeem the full requestedamountwhile theRequestWithdrawableShortfallhas not been triggered to signal it.Recommendation
Kiln should consider refactoring the logic to trigger
RequestWithdrawableShortfallif the underlying_dispatchRedeemwas unable to request the redeem for the whole expectedamount.Spearbit
Acknowledged. This issue will be checked in the next audit.
QueueStrategyEngine can still over-deposit and over-redeem because of duplicates in the queues
State
- Acknowledged
Severity
- Severity: Low
Submitted by
StErMi
Description
Both the
allocateandunallocatefunctions inQueueStrategyEnginehave a new logic that must account for previous iterations "commitment" that have been fulfilled by the same vehicle toward a deposit or redeem.The logic is not using these commitment values against the upper bound deposit target and the lower bound redeem target.
unallocateuint256 _vehicleSharesHoldings = _accountingEngine.vehicleSettledShares(_currentQueueEntry.vehicle); // 3. we check if the vehicle is not already under the target (with threshold tolerance), otherwise we skip it uint256 _effectiveTarget = _currentQueueEntry.target.upperBound(); if (_vehicleSharesHoldings <= _effectiveTarget) { continue; }The
_vehicleSharesHoldingsvariable holds the amount of shares of a vehicle that the MV owns. Let's say that the queue is configured like this (for the same vehicle)- remainingAssets = 100
- 1 share = 1 asset (to make things easy for the example)
- entry 0:
target = 80,threshold = 10 - entry 1:
target = 70,threshold = 10 _accountingEngine.maxRedeemablereturns a value like ~INF (we don't care)
_vehicleSharesHoldingsis equal to100 sharesFirst iteration
_vehicleSharesHoldings = 100_effectiveTarget = 80 + 10 = 90100 <= 90 → FALSE → OK_deltaSharesToTarget = 100 - 80 = 20_deltaSharesToTarget = 20 - 0 (_priorShares) = 20_sharesToUnallocate → 20The first iteration has "committed" to deallocate 20 shares (20 assets).
So theoretically the
_dispatchRedeemwill deallocate, after the first loop, 20 shares and the vehicle would return_vehicleSharesHoldings = 80.Now let's see the second iteration of
unallocate_vehicleSharesHoldings = 100_effectiveTarget = 70 + 10 = 80100 <= 80 → FALSE → OK_deltaSharesToTarget = 100 - 70 = 30_deltaSharesToTarget = 30 - 20 (_priorShares) = 10_sharesToUnallocate → 10The second iteration has unallocated 10 more shares, but in reality if we had "simulated" correctly the deallocations of the shares from the first iteration the check
if (_vehicleSharesHoldings <= _effectiveTarget) {would have returnedTRUEbecause the_vehicleSharesHoldingsis not100but100 - _priorShares = 80.The result is that the
unallocatehas 10 unallocated more shares than the_currentQueueEntry.targetshould have permitted because we are not including_effectiveTargetin theif (_vehicleSharesHoldings <= _effectiveTarget)checkallocateThe same problem that I described for the
unallocatefunction also happens for theallocateone.uint256 _vehicleSharesHoldings = 0; if (!_effectiveTarget.isUnlimited()) { _vehicleSharesHoldings = _accountingEngine.vehicleEstimatedShares(_currentQueueEntry.vehicle); // 6. Skip if at target (within threshold tolerance) if (_effectiveTarget.isAtTarget(_vehicleSharesHoldings)) { continue; } }Let's say that we are in this scenario:
share:assetratio is1_vehicleSharesHoldings = 70maxDepositable = INF(we don't care)_config.cap = INF(we can allocate as much as we want from theVehicleManagerPOV)- entry 0:
target = 80,threshold = 0 - entry 1:
target = 100,threshold = 20
First iteration
_priorAssets = 10_effectiveTarget = _currentQueueEntry.target_vehicleSharesHoldings = 70_effectiveTarget.isAtTarget(70) = FALSE holdings >= t.value - t.threshold -> 70 >= 80-0 -> 70 >= 80 -> FALSE_deltaSharesToTarget = _effectiveTarget.remainingCapacity(70) -> 80-70 = 10 shares_deltaAssetsToTarget = 10 assets - 0 (_priorAssets) = 10 assetsSo "theoretically" after the first iteration of the
_dispatchDepositwe have allocated10 assetsand the new_vehicleSharesHoldings = 80Now let's see the second iteration
_priorAssets = 0_effectiveTarget = _currentQueueEntry.target_vehicleSharesHoldings = 70_effectiveTarget.isAtTarget(70) = FALSE holdings >= t.value - t.threshold -> 70 >= 100-20 -> 70 >= 80 -> FALSE_deltaSharesToTarget = _effectiveTarget.remainingCapacity(70) -> 100-70 = 30 shares_deltaAssetsToTarget = 30 assets - 10 (_priorAssets) = 20 assetsThe second iteration has allocated 20 assets.
This would not have happened if
_effectiveTarget.isAtTarget(_vehicleSharesHoldings))would have included the prior committed asset in the check by evaluating_effectiveTarget.isAtTarget(_vehicleSharesHoldings + _priorAssets_CONVERTED_IN_SHARES)).Recommendation
Kiln must account for
_priorAssetsand_priorSharesinside the sanity checks performed against the targets used by theallocateandunallocatefunctions to avoid overdepositing or overredeeming compared to what's expected by the configured target.Spearbit
Acknowledged. This issue will be checked in the next audit.
Informational3 findings
Bulk Informational
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
StErMi
Description
- AccountList.sol?lines=369,371: the scenario where
address(_oracle) == address(0)should be impossible inAccountList._isSanctionedafter the validation ofif (!$.sanctionsEnabled) {.toggleSanctions,setSanctionsOracleandinitializereverts when$.sanctionsEnabled && $.sanctionsOracle == address(0). Consider removing it after proper review. - OwnerRegistry.sol?lines=315,322: if the Conduit's query has been wrapped and finalized, the Conduit will execute
OwnerRegistry.unwrapthat will burn the NFT and reset the records relative to the query stored in the mappings$.queriesand$.tokenToHashId. Kiln should document that theOwnerRegistry.getOwnerfunction will returnaddress(0)even if the query had an owner and the owner had changed from the original one stored in the query records. - VehicleManager.sol?lines=283,283: in
retrieveQueryRedeemQueueAssetsuse$.assetinstead of fetching it from the$.accountingEngine - SectorAccountingEngine.sol?lines=626,626: consider inlining the code of
_syncMoveVehicleActivationin themovefunction given that it's only used there - All the new factories that use the
IQueryRegistryin theirSpawnParamsstruct are missing the natspec documentation for theregistryattribute - Consider renaming the
IQueryRegistry registryattribute in theSpawnParamsstruct of all the factories toqueryRegistry. We already have theAssetRegistryand using just the variable nameregistrycan be confusing. - Consider validating the
params.registryin the_paramsChecksfunction of all the factories to be consistent with the existing behavior - QueryRedeemQueue.sol?lines=359,359: the
QueryRedeemQueue.redeemfunction is using theonlyMultiVehicleOrManagermodifier even if it's only called by theMultiVehicleFacet. Replace the modifier withonlyMultiVehicle - QueryRedeemQueue.sol?lines=395,395: The
QueryRedeemQueue.retrievefunction is using theonlyMultiVehicleOrManagereven if the function is called only by theVehicleManager. Replace it with a new modifier that allows only theVMto be themsg.sender.
Recommendation
Kiln should consider implementing the above suggestions.
Spearbit
Acknowledged. This issue will be checked in the next audit.
Consider refactoring the dispatchState check in both the _dispatchDeposit and _dispatchRedeem functions
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
StErMi
Description
At the very end of both the
_dispatchDepositand_dispatchRedeemflows we have this sanity check// 7. Create and dispatch the deposit query if (_effectiveAmount > 0) { depositQuery = _createQuery(Mode.DEPOSIT, $.asset, vehicle, _effectiveAmount, minOutput, data); SubQuery memory _subQuery = $.subQueryEngine.createSubQuery(vehicle, depositQuery, settledDestination, rejectedDestination); dispatchState = $.subQueryEngine.progressQuery(_subQuery, depositQuery); emit Dispatched(vehicle, settledDestination, rejectedDestination, depositQuery, dispatchState, operationId); } // 8. Strict callers (operator `dispatch()`) cannot tolerate a silent no-op: either the // block above was skipped (`_effectiveAmount == 0`) or `progressQuery` itself // produced an EMPTY transition. Either way the strict caller's intent (slippage // protection via `minOutput`, vehicle-specific `data`) would be discarded. The // non-strict path leaves staged assets parked in the vehicle sector for the asset // manager to recover.>>> if (strict && dispatchState == State.EMPTY) {>>> revert EmptyStrictDispatch(vehicle, Mode.DEPOSIT, amount, _sectorBalance);>>> }The natspec mentions "
progressQueryitself produced an EMPTY transition" but this scenario is impossible. The$.subQueryEngine.createSubQueryexecution has only two options:- The internal checks revert
- the
vehicle.createis executed. If it's executed (without reverting) the query state cannot beEMPTYbecause otherwise the vehicle would have reverted or theQueryRegistrywould have reverted (EMPTY→EMPTY is not a valid transition).
Given the above statement, the only reason for which we can revert with
EmptyStrictDispatchis becausestrict == true && _effectiveAmount == 0.dispatchStateis indeed equal toState.EMPTYbut only because we have (in this scenario) triggered theif (_effectiveAmount > 0) {branch.It's incorrect and misleading to say that
dispatchStatecould be empty because we have entered that branch and the subquery engine has generated an "empty" query/state. That should not be possible.Recommendation
Kiln should consider the following:
- update the natspec in both the dispatch functions to correct the wrong statement
- consider refactoring the logic of the sanity check to be explicit and make the code more clear and easier to understand and read
if (_effectiveAmount > 0) { // current logic} else if ( strict ) { // _effectiveAmount == 0 && strict == true revert EmptyStrictDispatch(vehicle, Mode.DEPOSIT, amount, _sectorBalance);} else { // _effectiveAmount == 0 && strict == false return (depositQuery, State.EMPTY);}Spearbit
Acknowledged. This issue will be checked in the next audit.
Review summary
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Code Overview
This report is a fix-review pass over the changes introduced in PR #459 (commit
ca898a1). The review examined the contracts to verify that the fixes applied in this commit are correct and complete.The review surfaced findings that remain open. These have not been resolved in this phase and are documented here so the team can analyze and remediate them, and so they can be carried forward as watch-points in future security reviews.