Organization
- @kilnfi
Engagement Type
Spearbit Web3
Period
-
Repositories
Researchers
Findings
Medium Risk
2 findings
2 fixed
0 acknowledged
Low Risk
12 findings
6 fixed
6 acknowledged
Informational
10 findings
6 fixed
4 acknowledged
Medium Risk2 findings
FeeManager recipient won't be able to receive their cShare fees
Severity
- Severity: Medium
Submitted by
StErMi
Description
The
FeeManageris partially incompatible with theTransferMode.BLOCK_TRANSFERand thecSharesfee type.The
FeeManagerreceives two types of fees:cShareswhen performance/management fees are minted or when theunlockaction is performed forDEPOSIToperationsassetswhen theunlockaction is performed forREDEEMoperations
The
FeeManageracts like a "bucket" that then needs to "distribute" all the fees collected to the fee recipient.The current logic of the
FeeManagerto perform such a task is to executeFeeManager.dispatchERC20which will dispatch an arbitraryassetto all the fee recipients it has been configured with.When the
asset(input parameter) is thecShare, and theConduitis configured withTransferMode.BLOCK_TRANSFERtheFeeManager.dispatchERC20operation will revert.This means that all the
cSharesminted to theFeeManageras fees are locked forever into theFeeManagerRecommendation
Kiln must brainstorm a solution to solve this incompatibility between the
FeeManagerand the Conduit's transfer modeTransferMode.BLOCK_TRANSFER.Note that the same problem arises when the Conduit is configured with the transfer mode
TransferMode.ACCOUNT_LISTand the fee recipient of theFeeManagerhas not been configured intoAccountList.Kiln
Fixed in commit 70c70bd2
cShare fees accrue to the FeeManager as the conduit's own ERC20 (ongoing fees and transactional deposit fees), and
FeeManager.dispatchERC20(cShare)dispatches them viaconduit.transfer(FeeManager -> recipient). Before this fix, the only system-move bypass in the transfer gate exempted the conduit itself but not the FeeManager, so the dispatch was gated by_transferAllowed: it reverted whiletransferEnabled == false, and under AccountList screening when the recipient/FeeManager was blocked/sanctioned or (in STRICT) not allow-listed — stranding the accrued fee cShares until transfers were enabled / the recipient made transfer-eligible. The fix: cShare transfer compliance is now enforced at the publictransfer/transferFromentrypoints (the_updatecounterparty gate is removed), and the FeeManager is exempt as a SENDER only (from == feeManager).FeeManager.dispatchERC20(cShare)performsconduit.transfer(FeeManager -> recipient)withfrom == FeeManager, so it now passes regardless oftransferEnabled/AccountList; screening rests on keeping the fee recipients clean (not blocked/sanctioned; allow-listed in STRICT). All conduit-internal fee accrual/distribution uses the internal_mint/_transferand is unaffected (it never traversed the gate). The exemption is deliberately sender-only: a sanctioned/blocked holder cannottransfer/transferFromcShares TO the FeeManager — or to the conduit — to move them unscreened, closing the symmetricto == feeManager/to == conduitbypass a first-pass_updatecounterparty exemption would have left open.Spearbit
While the fix above resolves the original stranding issue, the sender-only exemption (
from == feeManager) raises a new concern: it bypasses recipient-side compliance screening on fee dispatches, shifting it to an operational assumption about fee recipients. This is covered in the fix-review report. Kiln has since submitted a follow-up fix (PR 459, commit ef8e1d0e) that respects!ready()and screens the destination; it has not been reviewed by Spearbit and should be verified in the upcoming engagement.The Conduit should not allow the transfer of a "finalized" query
Severity
- Severity: Medium
Submitted by
StErMi
Description
The current behavior of the
OwnerRegistry.wrap,OwnerRegistry._updateandConduit.isTransferabledoes not perform any validation on theQuerythat is wrapped or transferred to another account.Without any validation the owner of the query could be able to wrap it and "sell/transfer" the NFT (wrapped Query) to another user that would not be able to retrieve any
cShareorassetgiven that the query is already in the final state (FINALIZEDorREJECTED).Recommendation
Kiln should perform the following refactoring and validations:
OwnerRegistry.wrapshould revert if the query state is in a final stateOwnerRegistry._updateshould pass toconduit.isTransferablethe inputs needed to validate the queryConduit.isTransferableshould revert if the wrapped query (the NFT) is in a final state
Note: we are not suggesting to validate the
EMPTYstate only because right now it's not possible to wrap a query that is in such a state. To wrap it, the query must have been first registered by the Conduit via theregisterOwnerfunction which is only called by theConduitwhen a query has been successfully created on the underlying vehicle.Kiln
Fixed in commit e3b2d69b
Fixed on-chain. We reversed the earlier off-chain-exchange position and now ensure a finalized query never has a live, transferable wrapped NFT.
OwnerRegistry.wraptakes theQueryand revertsNonWrappableStateif the query's vehicle state isEMPTY/SETTLED/REJECTED, so an already-finalized query cannot be wrapped. A newOwnerRegistry.unwrap(Id)— namespace-keyed on the calling conduit (computeHashId(msg.sender, queryId), so a caller can only affect its own queries' NFTs) — burns the wrapped NFT, andConduit._processcalls it when a query reachesSETTLED/REJECTED, after the output is distributed to the holder, so a query that finalizes while wrapped has its NFT burned (OwnerRegistry._updateexempts burns from the transfer gate). Net effect: wrapping a finalized query reverts, and an in-flight wrapped query's NFT is destroyed on finalization, so the spent claim cannot be transferred or sold. We keptIConduit.isTransferable(address,address)unchanged (no query/Id threading) because the protection is achieved by the wrap-revert + burn-on-finalize rather than by gating transfers. This closes the finalized/terminal case; the partially-unlocked case is tracked separately as #10 (non-terminal — would require on-chain query-completion views) and remains acknowledged.Spearbit
The commit e3b2d69b performs the following changes:
- the
OwnerRegistry.wrapfunction reverts if the query's state (fetched from the vehicle) isEMPTYor in a final state (SETTLEDorREJECTED): the caller cannot wrap a non-existing or already finalized query and then transfer it - the
Conduit._processfunction callsOwnerRegistry.unwraponce the progressed query is in a finalized state (SETTLEDorREJECTED). TheOwnerRegistry.unwrapfunction will "silently" (without reverting if the NFT does not exist) burn the NFT. By performing this operation no one will be able to transfer the NFT once the query is finalized.
⚠️ Note: because the
OwnerRegistry.unwrapfunction does burn the NFT and reset the$.queriesand$.tokenToHashIddata structure for the relative query burned, we are also losing the "history" of ownership of the wrapped query and all the data stored in those mappings. TheOwnerRegistry.getOwnerwill returnaddress(0)as the owner even if the query was registered in the past.
Low Risk12 findings
Refactor and fix of the mint/burn/transfer permission logic of the Conduit contract
Severity
- Severity: Low
Submitted by
StErMi
Description
After reviewing the
Conduitbehavior relative to the query creation (deposit/redeem operations), the Conduit's share transfer and the Query NFT transfer (OwnerRegistry) we have identified multiple conflicts between the implementation behavior and the defined specifications.Kiln has provided the following new specifications that the
Conduitshould follow- Scenario
transfer mode == ACCOUNT_LIST
- if
AccountListis inALLOWmode- in create,
msg.senderneeds to be in the list and not in sanction list - in create,
receiverneeds to be in the list and not in sanction list - in transfers,
fromneeds to be in the list and not in sanction list - in transfers,
toneeds to be in the list and not in sanction list - system transfers are allowed (
address(0)<->CONDUIT)
- in create,
- if
AccountListis inBLOCKmode- in create,
msg.sendermust not be in the list or sanction list - in create,
receivermust not be in the list or sanction list - in transfers,
frommust not be in the list and not in sanction list - in transfers,
tomust not be in the list and not in sanction list - system transfers are allowed (
address(0)<->CONDUIT)
- in create,
- Scenario
transfer mode == BLOCK_TRANSFER- in create,
msg.sendermust be equal toreceiver - in transfers, we only allow system transfers (
address(0)<->CONDUIT)
- in create,
- Scenario
transfer mode == ALLOW_TRANSFER: in this scenario everything is allowed
Note: the above scenarios apply to:
- Conduit's shares (direct transfer of Conduit's shares or
createinDEPOSITmode) - Underlying assets (
createinREDEEMmode) OwnerRegistryNFT transfer
We suggest the following changes:
Structure and Natspec
- Rename the
transferModestate variable name toallowMode(or something similar). The value could manage both the permission relative to the creation of the Conduit's query, the transferability of the Query's NFT ownership and the transferability of the Conduit's shares or underlying assets. - Rename the
TransferModestruct toAllowMode(or something similar) - Rename
TransferMode.ALLOW_TRANSFERtoTransferMode.ALLOW_ALL - Update all the natspec relative to the
transferModevariable, theTransferModestruct and everywhere the mint/burn/transfer logic is mentioned
constructorIn the
constructorfunction apply these changes/refactor:When
params.transferMode == ConduitStructs.TransferMode.ACCOUNT_LIST, revert ifaddress(params.accountList) == address(0)Whenparams.transferMode != ConduitStructs.TransferMode.ACCOUNT_LIST, revert ifaddress(params.accountList) != address(0)_validateUserPermissionfunctionThis new functions will be used in both the
create, Conduit's share transfer and NFT transferfunction _isAllowed(ConduitStore.Storage storage $, bool fromCreate, address sender, address receiver) internal returns (bool) { ConduitStructs.TransferMode _transferMode = $.transferMode; // in ALLOW_TRANSFER everything is allowed if( _transferMode == ConduitStructs.TransferMode.ALLOW_TRANSFER ) return true; // in BLOCK_TRANSFER create is allowed when `sender == receiver`, transfer never allowed if( _transferMode == ConduitStructs.TransferMode.BLOCK_TRANSFER ) { if( fromCreate ) { return sender == receiver; } // transfers are never allowed return false; } // in ALLOW_LIST both the `sender` and `receiver` must be verified // note 1: that both the `create` operation and ERC20/ERC721 standards allows // to have `sender == receiver` // note 2: in ALLOW_LIST mode `accountList` is always defined (see constructor requirements) // validate that the sender is allowed if( !_accountList.isAllowed(sender) ) return false; // if the receiver is not the sender, validate that also the receiver is allowed if( sender != receiver ) { if( !_accountList.isAllowed(receiver) ) return false; } // sender was already allowed, receiver was the same as sender or was also allowed return true; }createApply the following changes
- if (!_ready() && msg.sender != $.deployer) { // when the Conduit is not ready, only the deployer can create a Query and the deployer must also be the receiver+ if (!_ready() && (msg.sender != $.deployer || receiver != $.deployer)) { revert ErrorLib.DisabledConduit(); }- if (!_isAllowed($, msg.sender, receiver)) {+ if (!_isAllowed($, true, msg.sender, receiver)) { revert ConduitErrors.NotAllowed(msg.sender, receiver); }Conduit's share transfer
The
_updatefunction is called by theERC20Upgradeablein many flows, including_mintand_burn.Given that we have already validated
msg.senderandreceiverduring thecreateexecution, we can simply override thetransferandtransferFromfunctions and apply the validation directly therefunction transfer(address to, uint256 value) public override returns (bool) { address from = _msgSender(); // Note: we don't need to verify that `msg.sender != $.deployer` because we have enforced that // the deployer is also the owner of the query in the `_create` flow if( _ready() ) { if (!_isAllowed(ConduitStore.getStorage(), false, from, to)) { revert ConduitErrors.NotAllowed(from, to); } } return super.transfer(to, amount); } function transferFrom(address from, address to, uint256 value) public override returns (bool) { if (!_isAllowed(ConduitStore.getStorage(), false, from, to)) { revert ConduitErrors.NotAllowed(from, to); } return super.transferFrom(from, to, amount); }Now the
_updatefunction can be fully removed.We can also remove all those logics that are minting/burning and transferring the shares
- In
_createinstead of performing_transfer+_burn, just perform_burn(msg.sender, _burnAmount) - In
_mintOngoingFeesinstead of performing_mint+_transfer, just perform_mint(address(feeManager), sharesToMint) - In
_distributeAssetinstead of performing_mint+_transfer, just perform_mint(recipient, _mintAmount)
isTransferableThe
isTransferablemust be changed tofunction isTransferable(address from, address to) external view returns (bool) {- return _isAllowed(ConduitStore.getStorage(), from, to);+ return _isAllowed(ConduitStore.getStorage(), false, from, to); }⚠️ Note: if the DEPLOYER it's not a trusted entity, the
isTransferablefunction should add a validation to revert when_ready() == false. Maybe a better solution (to avoid direct impacts on the Conduit gas consumption) is to revert theOwnerRegistry.wrapfunction whenconduit.ready() == false⚠️ Important notes
Assumption on the Fee Manager
We are assuming that the
feeManageris a TRUSTED actor. The permission validation will not be applied to the Conduit's shares (given the changes proposed) when_mintOngoingFeesand_handleUnlockingare executed to mint/distribute the Conduit's shares.If the assumption cannot be made we could apply this solution:
- Update
_mintOngoingFeesto this logic
if (address(feeManager) != address(0) && ($.transferMode != TransferMode.ACCOUNT_LIST || _accountList.isAllowed(address(feeManager))) ) {- Update
_handleUnlockingto this logic
if (address(feeManager) != address(0) && ($.transferMode != TransferMode.ACCOUNT_LIST || _accountList.isAllowed(address(feeManager))) ) {Note that we have left unchanged both the
feeManager.onUpdateand$.queryFeesConfigIdslogics.Async queries and edge cases
When the
Conduitis configured withtransferMode == TransferMode.ACCOUNT_LISTand the query is ASYNC, we could encounter this edge case: thereceiver(the owner of the query returned by_ownerOf(...)) was allowed during thecreate(...)execution but is not allowed anymore when the query is unlocked/recovered.The above suggested implementation would allow the
receiverto finalize the query and receive the shares/assets. If thereceivershould be blocked and the transaction reverted we can suggest the following change in theprocessfunctionif( $.transferMode == TransferMode.ACCOUNT_LIST && !_accountList.isAllowed(_owner) ) { revert ConduitErrors.NotAllowed(_owner, _owner);}⚠️ Note 1: It's important to note that this would now allow the query to be finalized to the
SETTLED/REJECTEDstate. ⚠️ Note 2: It's possible that the now sanctioned user could, for the same query, be able to unlock/recover part of the funds if the query was partially unlocked/recovered.Recommendation
Kiln should refactor and fix the whole mint/burn/transfer permission logic of the
Conduitcontract which is incompatible with the required specification.It's important to note that the code shown above should be seen as a guideline/suggestion and not production-ready code.
Kiln
Fixed by the phase-3 access-control redesign (d88a4887). The entire mint/burn/transfer permission logic was refactored across all three surfaces this finding targets:
- Conduit shares + DEPOSIT create:
create()gates the initiator withcanDeposit(Conduit.sol:322-328) and screens the receiver via_transferAllowedwhenreceiver != msg.sender(Conduit.sol:330-332); ERC20_updatebypasses system moves (mint/burn, conduit-as-counterparty) and gates user↔user transfers, deployer-only pre-enable (Conduit.sol:363-377). The single policy predicate is_transferAllowed = transferEnabled && (accountList == 0 || canTransfer)(Conduit.sol:738-744). - Underlying assets + REDEEM create: gated by
canRedeem(Conduit.sol:324).canRedeem = !sanctioned(AccountList.sol:256-261), so a blocked/de-listed holder can self-exit but the receiver screen clamps them to themselves — the intendedcanCreatesemantics. - OwnerRegistry NFT transfer:
_updateenforcesIConduit.isTransferable(from, to)for wrapped tokens (mints bypass) (OwnerRegistry.sol:323-343) andwrap()is gated byisTransferable(self, self)(OwnerRegistry.sol:227-229).
The original
TransferModeenum (ACCOUNT_LIST ALLOW/BLOCK, BLOCK_TRANSFER, ALLOW_TRANSFER) was intentionally replaced by a dual-list +AllowlistMode {OPEN, REGULAR, STRICT}model with a one-waytransferEnabledlatch and sanctions screening (AccountList.sol:247-281), rather than the finding's sample code — which the finding itself notes is a guideline, not production-ready. Acknowledged as resolved by design.Spearbit
Verified the fixes.
The AccountList should not be able to switch mode when the isListed mapping is not empty
Severity
- Severity: Low
Submitted by
StErMi
Description
The
AccountListcan be configured with two different modes:Mode.ALLOWthat allows only the accounts inisListedto receive or transfercSharesMode.BLOCKthat allows only the accounts not inisListedto receive or transfercShares
The modes are one the opposite of the other and it would create a big problem for the Conduits (given that
AccountListcan be shared between multiple conduits).Let's say that
AccountListhas been configured with theBLOCKmode and it has blockedu1andu2. This means that everyone is allowed excludingu1andu2.If at some point the
AccountListis switched to modeALLOW, everyone is blocked by default and onlyu1andu2(that were in a blacklist) are allowed.This function should be callable only if the
isListedis empty. The problem is that being aisListedis amappingand it can't be simply reset withdelete.It would need a refactor and maybe use a
EnumerableSetof addresses instead of a simple mapping and at that point the best thing would be to have aswapModefunction that takes the new mode and the newaddress[] calldata addrsto add to the list after the mode is swapped instantly.Recommendation
Kiln should consider refactoring
AccountListto allow the swap of themodeonly if theisListedmapping is empty. One possible solution is to useEnumerableSetfor theisListedlist. ThesetAccountListModecould also accept a newaddress[] calldata addrsinput that would be used to initialize the emptyisListedset if needed.Kiln
Fixed by the phase-3 access-control redesign (d88a4887). The single
isListedmapping and the binaryMode.ALLOW/Mode.BLOCKenum described here have been removed entirely.AccountListnow keeps two physically independentEnumerableSet.AddressSetinstances —allowListandblockList— plus a three-valueAllowlistMode {OPEN, REGULAR, STRICT}(AccountList.sol:69-75; IAccountList.sol:29-33). The block-list always denies first and the allow-list is only ever a positive gate (canDeposit:247-253, canTransfer:264-272), sosetAllowlistMode(185-190) only changes which list a given operation consults and can never invert an existing list's meaning. The inversion hazard (blockedu1/u2becoming the only allowed accounts after a mode flip) is therefore structurally impossible. This also adopts theEnumerableSetmigration the finding recommended; with the two lists physically separate there is no need to gate mode switching on an empty list or add aswapMode.Spearbit
Verified the fixes.
Consider improving the validation of the sanctioned configuration logic of the AccountList contract
Severity
- Severity: Low
Submitted by
StErMi
Description
When
sanctionsEnabled == trueit means that the contract wants to validate if the account that interacts with theConduitis sanctioned.This validation is provided by the Oracle
sanctionsOracle. IfsanctionsOracle == address(0)the_isSanctionedfalls back to returnfalse(not sanctioned) only to avoid a revert that would break the liveness of theConduit. The expected "happy path" is that ifsanctionsEnabled == truewe havesanctionsOracle != address(0)sanctionsOracleworks as expected: doesn't revert, return a well-formedboolwhensanctionsOracle.isSanctioned(account)is called
To ensure (as much as possible) that the contract is configured properly and with a working oracle we suggest the following changes:
- In the
constructorrevert whensanctionsEnabled_ == truebutoracle_ == address(0). - In the
toggleSanctionsListfunction revert whenenabled == truebutsanctionsOracle == address(0) - Consider also performing a "liveness" check on the oracle when configured. If the oracle does not work as expected, revert to prevent to configure the contract with a broken one. Below is an example (to be refactored and validated) solution for such a check:
function validateSanctionOracle(address _oracle) private view returns (bool) { address stubAccount = address(0); (bool _success, bytes memory _result) = address(_oracle).staticcall(abi.encodeCall(ISanctionsList.isSanctioned, (stubAccount))); if (!_success || _result.length != 32) { // oracle call has reverted or return value is malformed return false; } uint256 _word; assembly { /// +covered, assembly is not supported _word := mload(add(_result, 0x20)) } // _word > 1: return value malformed, expected boolean type 0 | 1 return _word <= 1; }Recommendation
Kiln should consider improving the sanity checks performed on the "sanctioned" logic configuration of the
AccountListcontract.Kiln
Fixed by previous commits — the phase-3 access-control redesign (d88a4887). All three suggestions are now enforced in
AccountList.sol:initialize()revertsSanctionsOracleRequiredwhen sanctions are enabled without an oracle (line 162).toggleSanctions()revertsSanctionsOracleRequiredwhen enabling without a configured oracle (line 222); additionallysetSanctionsOracle()cannot drop the oracle toaddress(0)while sanctions remain enabled (lines 232-233), closing the symmetric hole.- A code-presence check is performed on the oracle at init and on update via
CheckLib.checkContract(lines 163 and 235), which rejects EOAs / unset addresses (Check.sol:38-43). Beyond a one-time configuration check, the runtime_isSanctioned()fails closed (lines 358-384): a reverting, EOA, or malformed-return oracle is treated as sanctioned rather than silently passing accounts, which is strictly safer than a one-time liveliness probe.
Spearbit
Verified the fixes.
Partially unlockable query can be wrapped and sold
State
- Acknowledged
Severity
- Severity: Low
Submitted by
StErMi
Description
The
STEAMstandard supports scenarios where aQuerycould need multipleunlockorrecovercalls to finalize a query.The initial owner of the query is currently allowed to partially unlock/recover the query, wrap it and sell it as a "full" query to another user.
The receiving user has no way at the moment to understand and validate the state of the wrapped query that they are receiving and evaluate its status.
Recommendation
Kiln should:
- consider reverting the wrap and transfer of a query that has been partially unlocked/recovered
- add storage, logics (when needed) and utility functions to both the
ConduitandVehicleto fetch the state of the query. Users should be able to see how much of the query still needs to be unlocked or recovered.
Kiln
Acknowledged — the partial-unlock (non-terminal) case only. Note the finalized/terminal case is now fixed on-chain (finding #9):
OwnerRegistry.wraptakes theQueryand reverts when it is SETTLED/REJECTED, and the conduit burns the wrapped NFT when a query finalizes, so a finalized query has no transferable NFT. This finding concerns a query that is PARTIALLY unlocked but still in-flight (non-terminal); the finalize-time burn does not apply, and gating it would require on-chain query-completion views that do not exist. For the partial case the wrap/transfer paths intentionally perform no on-chain STEAM completion check:wrapnow reverts only on a terminal/empty state (the #9 fix), andOwnerRegistry._updategates wrapped-NFT transfers only onIConduit.isTransferable(transferEnabled && (accountList == 0 || canTransfer)) — neither consults how much of a still-in-flight query remains to be unlocked/recovered. Buyer protection for a partially-consumed but still-live wrapped claim is therefore handled off-chain on the bespoke exchange contract; adding an on-chain remaining-unlock view to gate it is intentionally out of scope.Spearbit
Acknowledged.
Vehicles that require KYC validation are incompatible with the Conduit
State
- Acknowledged
Severity
- Severity: Low
Submitted by
StErMi
Description
Let's assume that we have a base vehicle that performs the KYC on the
ownerof the query to allow the query to proceed to theUNLOCKINGstate.When the
Conduitis configured with that vehicle as the underlying vehicle, theownerof the query that is created on the vehicle will be theConduititself and not thereceiverpassed to theConduit.createfunction that will invokevehicle.create.This means that the vehicle needs to approve the
Conduititself in theKYCprocess and won't validate the "real" receiver of the funds that the query unlock/recover.Recommendation
Kiln should consider refactoring the BaseVehicle logic and the
Querystructure to track who is the "original" creator of the query that needs to be validated by the underlying vehicle when/where needed.Note 1: the "root owner" of the query could change during the lifecycle of the query given that a query can be "wrapped" and transferred to another user. In that case the underlying vehicle should re-validate the KYC process given that the user has changed.
Note 2: the same problem exists when the
MultiVehicleis configured with vehicles that require a KYC process. In that case theMultiVehicleitself is the owner of the query and not the user that interacts with theMultiVehicle.Note 3: the KYC was just an example of incompatibility between the base vehicle, MultiVehicle and Conduit. There could be other scenarios where these kinds of problem/incompatibility could arise.
Kiln
Acknowledged — by design. The Conduit (and MultiVehicle's SubQueryEngine) is intentionally always the owner/receiver of the query created on the underlying vehicle; this is the foundation of the wrapping architecture, not a bug. The phase-3 access-control redesign (d88a4887) deliberately enforces compliance at the Conduit/AccountList boundary against the real user —
canDeposit/canRedeemon the initiator and the transfer gate on the receiver increate()(Conduit.sol:320-332),isTransferableon wrap and on wrapped-query NFT transfers (OwnerRegistry.sol:227,336), andforceRedeem-based off-boarding (Conduit.sol:219-249) — rather than threading the end-user's identity into the underlying vehicle and theQuerystruct. We are not adopting the suggested STEAM/Query/BaseVehicle refactor (theQuerystruct intentionally remains owner/receiver-only, Query.sol:64-72, with no compliance hook in BaseVehicle). Vehicles that require their own on-chain KYC of the query owner should be integrated directly rather than wrapped by a Conduit/MultiVehicle.Spearbit
Acknowledged.
Users not allowed could be able to receive funds (assets/cShares) when the query is async
State
- Acknowledged
Severity
- Severity: Low
Submitted by
StErMi
Description
Let's assume that the
Conduitis configured with thetransferMode == TransferMode.ACCOUNT_LIST.When the
Conduit._createfunction is executed, the transaction reverts if themsg.senderor thereceiverhas not been both allowed in theAccountList.Let's assume that both
bob(themsg.sender) andalice(thereceiverand so the owner of the query) have been allowed and that the query created by the underlying vehicle is an "async" query that ends in thePROCESSINGstate.Before the query changes the state to
UNLOCKINGorRECOVERING,bob(the owner of the query) is removed from the allowlist of theAccountListor is sanctioned.By not being allowed anymore,
bobshould not be able to receive the "output" of the query (it is not relevant whether it is assets or Conduit's shares). The problem with the current logic is that this behavior is not respected by theConduitonce the query has been created and the assets/cShares are unlocked/recovered.When
Conduit._processis executed and_handleUnlockingor_handleRecoveringis executed, we have two scenarios (depending on the state of the query):- Scenario 1: the conduit distributes the assets (we are unlocking a query created in
REDEEMmode or we are recovering a query created inDEPOSITmode) - Scenario 2: the Conduit mints and distributes the Conduit's shares (we are unlocking a query created in
DEPOSITmode or we are recovering a query created inREDEEMmode) by executing_mint(address(this), _mintAmount);and_transfer(address(this), recipient, _mintAmount);
In the first scenario (distribution of the underlying assets) no validation is performed at all. The query owner, even if it is not allowed anymore, can receive assets. In the second scenario the
Conduitperforms "passive" checks on the receiver but all those logics ignore the validation of the receiver of the shares when the execution comes from a "mint" flow.The
_mintfunction executes_update(from=address(0), to=CONDUIT, value)-> in bothtransferModeconfigurations it will not revert:- with
TransferMode.ACCOUNT_LIST: theaddr2 == CONDUIT-> returntrue-> no revert - with
TransferMode.BLOCK_TRANSFER:from == address(0)is already enough to avoid the revert The_transferfunction executes_update(from=CONDUIT, to=bob, value)-> in bothtransferModeconfigurations it will not revert: - with
TransferMode.ACCOUNT_LIST: theaddr1 = CONDUIT-> returntrue-> no revert - with
TransferMode.BLOCK_TRANSFER: thefrom == CONDUITis already enough to avoid the revert
This means that
bobwill be able to receive thecShareshares from aDEPOSIToperation even if now is not allowed anymore.If
bobwas not allowed during the execution of thecreateoperation, the operation would have reverted in the beginning.Recommendation
If this is the correct behavior, Kiln should carefully document it and let the integrator/manager of the Conduit be aware of it. Otherwise Kiln should refactor the logic to be coherent and revert the transaction, freezing the funds (both assets and Conduit's shares) when the receiver is not allowed anymore to interact with the Conduit. Note that in this case the query won't be able to be finalized and transition to the
SETTLED/REJECTEDstate.This issue has also been covered in the Finding "Refactor and fix of the mint/burn/transfer permission logic of the
Conduitcontract".Kiln
Acknowledged — by design. The phase-3 access-control redesign (d88a4887) intentionally lets in-flight output flow. Gating is enforced at
create()(initiatorcanDeposit/canRedeem+ receiver screen) and on user-to-user ERC20 transfers, butprocess()/unlock/recoverdeliberately do NOT re-screen the receiver of an already-created query: reverting would permanently brick the underlying assets/cShares in a query that can no longer reach SETTLED/REJECTED. Off-boarding of a now-blocked or sanctioned holder is instead handled actively via the newforceRedeem(user, amount, output)path (Conduit.sol:219-249), gated byAccountList.canForceRedeem(CONDUIT_FORCE_REDEEM role), which returns true only for blocked/sanctioned users and ejects the holder's position back to themselves. This is the documented behavior matching your finding's "if this is the correct behavior, document it" branch.Spearbit
Acknowledged. Off-boarding of blocked or sanctioned holders is handled through
forceRedeem.Query.output protection could end up being useless and misleading
State
- Acknowledged
Severity
- Severity: Low
Submitted by
StErMi
Description
The
query.outputvalue is used byBaseVehicleas a security measure to protect the user from slippage losses, unexpected changes in the exchange rate or if the performance, management or operational fees (deposit/redeem) result in an output below the user's expectation.The Conduit is also offering this mechanism but it works differently compared to the one of the Vehicles and could result in confused and useless/misleading behavior.
When the user performs a
DEPOSITaction, the user can only specify asq.outputthe minimum amount ofvSharesthat the conduit should receive whenvehicle.createis executed on the underlying vehicle. This behavior does not protect the user relative to the minimum amount ofcShares(conduit shares) that the user would receive at the very end.When the user performs a
REDEEMaction, the user can only specify asq.outputthe minimum amount of asset that the Conduit should receive whenv.createis executed on the underlying vehicle. Like for theDEPOSITscenario, this does not protect the user relative to the minimum amount of assets that the user would receive at the very end.For the
cSharescase (DEPOSIT) it depends on the exchange rate of theConduitbut also the Conduit's deposit fees that the Conduit'sFee Managerapplies.For the assets case (
REDEEM) it depends on the Conduit's redemption fees that the Conduit'sFee Managerapplies.Recommendation
Kiln should consider removing this protection mechanism that does not fully protect a user's like the one offered by the vehicle or consider extensively documenting it and providing clear guidance on how to use it and where the mechanism could end up not protecting the user as it is expected.
Kiln
Doc clarification in
fce9beeeAcknowledged — by design.
query.outputis forwarded verbatim to the underlying vehicle'screate(Conduit.sol:438), so it is a vehicle-level slippage floor only (min vehicle shares on deposit, min underlying asset from the vehicle on redeem). The Conduit's own fees and share-exchange rate are applied afterward in_handleUnlocking(feeManager.applyFees at line 574; mint/transfer at 644-653), so the floor intentionally does not bound the final conduit-share or final-asset amount. We have applied the documentation clarification:createandcreateRedeemFromConduitSharesnatspec (and theirIConduitdeclarations) now state thequery.output/outputAssetsfloor is enforced at vehicle output only and does not account for Conduit fees or share-exchange rate; users should useestimate()for true expected-output sizing.src/docs/reference/conduit.mdcarries the same note. No logic change.Spearbit
Acknowledged. Kiln documented that the
query.outputfloor is enforced at the vehicle output only (commit fce9beee).Consider reverting the Conduit._create flow if the query state ends in the recover path
State
- Acknowledged
Severity
- Severity: Low
Submitted by
StErMi
Description
The
Conduit._createprocess executed by both thecreateandcreateRedeemFromConduitSharesfunctions could end up with a query in the "recovery" phase.The current implementation does not manage this scenario in a specific way and could end up with a loss on the user side.
Recommendation
Kiln should consider refactoring the
_createand_processlogic to revert the transaction (that comes from a_createflow) if a "synced" query follows theEMPTY -> PROCESSING -> RECOVERINGpath.Kiln
Acknowledged — by design.
Conduit._createdoes not lose user funds on the recover path._process(Conduit.sol:535-538) routes a RECOVERING query into_handleRecovering(Conduit.sol:604-624), which callsvehicle.recover()and distributes the recovered input back to the receiver: a full/partial refund (deposit assets returned; redeem conduit shares re-minted). Per the STEAM spec,create()can never directly yield RECOVERING, so RECOVERING is an async transition handled defensively. Reverting as recommended would discard the refund and would brickforceRedeemoff-boarding for vehicles that fail synchronously, so we keep the current behavior.Spearbit
Acknowledged.
Conduit's Factories additional sanity checks
Severity
- Severity: Low
Submitted by
StErMi
Description
AccountListFactory- Add the
FactoryBase public immutable ACCESS_CONTROL_FACTORYstate variable to theAccountListFactorystorage - Add to the
constructortheFactoryBase accessControlFactoryinput to be saved in theACCESS_CONTROL_FACTORYstate variable - Add to the
_paramsChecksfunction the validationFactoryLib.validateDeployedBy(ACCESS_CONTROL_FACTORY, address(params.accessControl)); - Add to the
_paramsChecksfunction the same validations that have been suggested in Finding 8 "Consider improving the validation of the sanctioned configuration logic of theAccountListcontract"
ConduitFactory- Add the
FactoryBase public immutable ACCESS_CONTROL_FACTORYstate variable to theConduitFactorystorage - Add the
FactoryBase public immutable FEE_MANAGER_FACTORYstate variable to theConduitFactorystorage - Create a
function _paramsChecks(SpawnParams calldata params) internal purefunction to be executed by thespawnflow at the very beginning. - In the new
_paramsCheckscheck add the following validations:FactoryLib.validateDeployedBy(FEE_MANAGER_FACTORY, address(params.feeManager));FactoryLib.validateDeployedBy(ACCESS_CONTROL_FACTORY, address(params.accessControl));
- In the
spawnfunction:accountList,vehicleandownerRegistryare not checked to be deployed by actual trusted factories.
Recommendation
Kiln should implement the sanity checks we have suggested above.
Kiln
Fixed in commit 3c099139 (the AccountListFactory item); the ConduitFactory checks landed earlier in the phase-3 access-control redesign (d88a4887).
ConduitFactory was fully reworked in the redesign: it carries FEE_MANAGER_FACTORY / ACCOUNT_LIST_FACTORY / OWNER_REGISTRY_FACTORY / ACCESS_CONTROL_FACTORY immutables and an internal
_paramsChecks()(run atspawnstart) that callsvalidateDeployedByon feeManager, accountList, ownerRegistry and accessControl. The vehicle is intentionally not validated viavalidateDeployedBy— vehicles are produced by many distinct vehicle-specific factories, so there is no single trusted factory; instead the vehicle'sasset()is validated against the AssetRegistry. The previously-outstanding item — theACCESS_CONTROL_FACTORYimmutable +validateDeployedBy(accessControl)on AccountListFactory, which formerly only didCheckLib.checkContract(accessControl)— has now been added, mirroring the ConduitFactory pattern.Spearbit
Verified the fixes. The
ConduitFactorychecks landed in commit d88a4887 and theAccountListFactorycheck in commit 3c099139.Funds used for the initial deployment of a Conduit could be stuck in the ConduitFactory
State
- Acknowledged
Severity
- Severity: Low
Submitted by
StErMi
Description
When the
Conduitis deployed by theConduitFactory, the factory will follow these steps:- pull
_initialDepositAmountof funds from themsg.sender(account with the roleRoles.CONDUIT_SPAWN) - approve
_initialDepositAmountallowance to theConduit - execute
conduit.create(_depositQuery, address(this));
The
ConduitFactoryis not currently managing the scenarios where the query ends up in the finalREJECTEDstate.If the
vehicleused by the conduit is a "synced" vehicle and the_currentState(after thev.createexecution) isREJECTED, the factory creates aPendingDepositand adds it to$pendingInitialDeposits[address(conduit)]. This logic is wrong because the query is not pending, it has been already finalized as rejected and the recovered funds have been already sent to theConduitFactoryitself (receiver == address(this)whenconduit.createis executed).Calling
finalizeConduitDepositwill early return in_finalizeConduitDepositbecause the query is in theREJECTEDstate. The original funds (or fewer of them depending on the underlying vehicle recovery process) will remain stuck in theConduitFactory.If the
vehicleused by the conduit is an "async" vehicle, the_currentState(after thev.createexecution) isPROCESSINGand the factory creates aPendingDepositand adds it to$pendingInitialDeposits[address(conduit)]. WhenfinalizeConduitDepositis executedState _currentState = _pending.vehicle.state(_pending.depositQuery);will be equal toRECOVERING._finalizeConduitDepositwill callconduit.processthat will recover the funds and send them back to theConduitFactory. The_finalizeConduitDepositwill early return given thatcurrentState != State.SETTLED.Recommendation
In the first scenario where the conduit's vehicle is a "synced" vehicle and the
conduit.createreturns aREJECTEDquery, thespawnfunction should immediately revert.The second scenario is more complicated to handle correctly. The conduit's vehicle could be
REJECTINGthe query for multiple reasons. One possible solution would be to "exhaust" the query (the recovery could be a multi-step recovery process) and send those funds back to the original owner or use those funds to try again with another first query deployment from an ad hoc new function.Kiln
Acknowledged — not actionable for the current vehicle set.
Sub-item 1 (sync vehicle returning REJECTED): cannot occur — the STEAM standard forbids a direct
create()transition to REJECTED/RECOVERING (STEAM.md:367);create()reverts the entire transaction on failure. The state right aftercreate()can only be PROCESSING, UNLOCKING, or SETTLED — never REJECTED — so the "revert on REJECTED" recommendation is moot.Sub-item 2 (async deposit going PROCESSING→RECOVERING and stranding funds): unreachable today — RECOVERING is only reachable from PROCESSING, and every supported vehicle (Aave V3, Morpho Blue, ERC4626, Ethena, Wrapper, MultiVehicle) performs synchronous deposits. The non-settled
PendingDepositrecord is intentionally retained so an async deposit can be finalized later once it settles (ConduitFactory.sol:299-311). We will add a recovery/sweep path only if an async-deposit vehicle is ever introduced.Spearbit
Acknowledged.
Conduit._create() can be front-run by attackers potentially causing denial of service for targeted users
Severity
- Severity: Low
Submitted by
Optimum
Description
Attackers can front-run calls to
Conduit._create()with the same calldata but just setreceiverto be their own controlled address. By doing this they can cause the original user call tocreate()to revert with the error ofConduitErrors.QueryAlreadyExists(queryId)potentially causing denial of service for targeted users. This will require the attackers to deploy the same amount that the victims are depositing, but they can recover it back by creating a redeem query instantly after.Recommendation
Consider recalculating
queryIdby taking the currentqueryIdand hash it together withmsg.sender, note that it will solve the issue both forcreate()andcreateRedeemFromConduitShares().Kiln
Fixed in commit cc626030
Fixed on-chain. We bound the query salt to the caller:
Conduit.createnow takes abytes32 sourceSaltand requiresquery.salt == keccak256(abi.encode(msg.sender, sourceSalt)), revertingInvalidQuerySaltotherwise. Because the accepted salt is cryptographically tied tomsg.sender, an attacker cannot reproduce a victim's salt, so they cannot replay the victim's query to occupy itsqueryId(the front-run reverts for the attacker), and two distinct callers can never collide on aqueryId.createRedeemFromConduitShares(3rd arg renamedsourceSalt) builds its redeem query with the same bound salt;forceRedeemis unaffected (privileged, uses its ownqueriesCount-derived salt). This also removes the prior mitigation requirement that integrators pick unguessable/random salts — even a predictablesourceSaltis collision-proof now.queryIdstaysquery.toId(vehicle)over the bound salt, so conduit↔vehicle id equality used bystate()/process()and the wrapped-query id from finding #9 are all unchanged.Spearbit
Fixed by bounding the query salt to the caller:
Conduit.create()now takes abytes32 sourceSaltand requiresquery.salt == keccak256(abi.encode(msg.sender, sourceSalt)), revertingInvalidQuerySaltotherwise.isTransferable does not check BLOCK_TRANSFER
Severity
- Severity: Low
Submitted by
zigtur
Description
The
isTransferablefunction is used to verify that transfers are enabled fromfromaddress totoaddress.However, this function does not verify that the
$.transferModeis notBLOCK_TRANSFER.This can lead the
isTransferablefunction to indicate that a transfer is allowed while it will revert if executed.Recommendation
Add a check that the transfer mode is not
BLOCK_TRANSFER.Kiln
Fixed by the phase-3 access-control redesign in commit d88a4887. The
TransferModeenum — includingBLOCK_TRANSFER— and the$.transferModestorage field were removed entirely; the store now keeps only atransferEnabledboolean.isTransferable(Conduit.sol:275-277) now delegates to a single predicate_transferAllowed = transferEnabled && (accountList == 0 || accountList.canTransfer(from,to))(Conduit.sol:738-744), which is the identical predicate enforced on the real transfer paths —_update's user↔user gate (Conduit.sol:372) and the create receiver screen (Conduit.sol:330). Because the view and the enforced gate share one code path,isTransferablecan no longer report a transfer as allowed that would later revert. The only remainingBLOCK_TRANSFERmention is a stale doc comment in ITransferGate.sol:18 with no behavioral effect.Spearbit
Fixed. The new
$.transferModeis checked in_transferAllowed.
Informational10 findings
Bulk Informational Issues
Severity
- Severity: Informational
Submitted by
StErMi
Description
- AccountList.sol?lines=92,106: In the
AccountListcontract, theAddressAdded,AddressRemovedandSanctionsOracleSetinputs can be declaredindexed. Consider also renamingaddrtoaccount. - AccountList.sol?lines=182,182: The
StateUnchangederror used throughout the AccountList contract is too generic. State may have been changed for multiple accounts while the error is returned. This is counter-intuitive. Consider having dedicated errors. - OwnerRegistry.sol?lines=158,159: Code is defining
initialize(symbol_, name_)and then calling__ERC721_init(name_, symbol_). The input parameters order is not the same. - OwnerRegistry.sol?lines=209,209: make the input order of the
OwnerRegistry.wrapfunction follow the same order of theregisterOwnerto be consistent and aligned. - OwnerRegistry.sol?lines=328,328: the
_updatefunction of theOwnerRegistrycontract is internally invoked by theERC721contract when the NFT is minted/burned or transferred. To mint the NFT, the user must call thewrapfunction that reverts whenconduit == address(0). This means that when the_updatefunction is called, the scenario where the$.queries[_hashId].conduitvalue is undefined is not possible. Remove such sanity check. - Conduit.sol?lines=90,90: Consider adding a
VehicleCategoryinput when theConduitis deployed and validate that the vehicle used in the__Conduit_initrespects the category. - Conduit.sol?lines=165,165: The
Conduit.createRedeemFromConduitSharesfunction should return theQueryinternally created to prevent the caller from regenerating it in case it needs to use it in the future. Alternatively, this could be handled by emitting a dedicated event. - Conduit.sol?lines=447,447: the
Conduit._mintOngoingFeesis passingId.wrap(0)as theId queryIdto theConduitEvents.Pushedevent even if the query id is known when the_mintOngoingFeesfunction is executed. - ConduitFactory.sol?lines=239,240: Consider documenting that in case the accountList is set with the
Mode.ALLOW, the factory must be allowed before the conduit deployment. It must not be blocklisted inMode.BLOCK. In both modes, it must not be sanctioned. - ConduitFactory.sol?lines=247,252: the
ConduitFactoryshould emit an event to track those Conduit deployments that won't be immediately finalized and need a future execution of thefinalizeConduitDepositfunction. An external tracker/keeper could need to track those events to later on finalize the deployment and enable the conduit.
Recommendation
Kiln should consider implementing the suggestions listed above.
Kiln
Fixed in commit 92e05a50 (the cosmetic/observability items); the remainder is acknowledged by design.
These are tracked Informational nits; the phase-3 access-control redesign (
[d88a4887](https://github.com/railnetorg/hangar/commit/d88a4887)) rewrote AccountList/Conduit and added the OwnerRegistrywraptransfer gate but did not target most of these. We applied the purely cosmetic/observability items: indexed the AccountList event address/oracle params and renamedaddr→account; split the genericStateUnchangedinto dedicated errors (including an unambiguous one for the per-element no-op in_updateList); alignedOwnerRegistry.initializeandwrap/registerOwnerparameter ordering; emitted the realqueryIdin the_create-path feePushedevent; and added a dedicated event inConduitFactory.spawnfor an async initial deposit (not finalized immediately). Acknowledged / not actioned (by design): we keepOwnerRegistry._update'sconduit != address(0)guard as defensive depth; returning the built Query fromcreateRedeemFromConduitSharesand adding VehicleCategory validation on Conduit deployment are enhancements/feature requests rather than bugs (VehicleLib.VehicleCategoryexists but is deliberately not wired into the Conduit); and theMode.ALLOW/Mode.BLOCKdocumentation note is obsolete since the single-mode model was replaced by the OPEN/REGULAR/STRICT dual-list design — the underlying requirement (the factory must satisfycanDeposit: allow-listed in REGULAR/STRICT and never blocked/sanctioned before deployment) is unchanged and is noted in the factory docs.Spearbit
Verified the fixes for the cosmetic and observability items (commit 92e05a50); the remaining items are acknowledged by design.
Natspec issues
Severity
- Severity: Informational
Submitted by
StErMi
Description
- Both the
OwnerRegistryregisterOwnerandwrapare permissionless functions that can be called by anyone (even if they are not "real" conduits) and can generate "invalid" and spammy events and modify the state of the contract. This behavior should be annotated and disclosed to let integrators and monitoring tools be aware of it. - Conduit.sol?lines=587,587:
_distributeAssetreturns both the updated total supply and the vehicle holdings. - OwnerRegistry.sol?lines=64,67: The
@returncomment indicates that it returns theAccountListstorage reference. This is incorrect as it is theOwnerRegistryStoragestorage reference. - Conduit.sol?lines=307,307: the comment "Allow all transfers when conduit is not enabled" is wrong or outdated. Please remove it or rewrite it fully.
- Conduit.sol?lines=297,297: the "BLOCK_TRANSFER prevents all transfers except minting and burning." comment in the
Conduit._updatefunction can be seen as misleading. The_updatefunction will also allow transfers calls where thefromortoaddresses are theConduititself.
Recommendation
Review and correct the cited natspec comments and documentation strings to accurately reflect the implemented behavior. Remove unused error declarations.
Kiln
Fixed in commit 12c41d1b
The two misleading transfer comments were already removed by the phase-3 access-control redesign (
[d88a4887](https://github.com/railnetorg/hangar/commit/d88a4887)): the oldTransferMode/BLOCK_TRANSFERlogic and_isAllowedgate are gone, and the rewritten_update/_transferAllowednatspec describes the behavior accurately, including the mint/burn/conduit-as-counterparty system-move bypass. There were also no unused error declarations to remove (all are reverted at least once). The remaining documentation nits have now been fixed: (A)OwnerRegistry.registerOwnerandwrapgained a@devnote that they are permissionless and may emitOwnerRegistered/QueryWrappedevents for arbitrary, non-real-conduit callers, so integrators and monitoring tools must not treat such events as proof of a genuine conduit; (B)_distributeAssetnow documents both return values (the updated total supply and the updated vehicle holdings); (C)_getOwnerRegistryStoragenatspec was corrected from "AccountList storage reference" to "OwnerRegistry storage reference".Spearbit
Verified the fixes. The documentation has been corrected in commit 12c41d1b.
AccountList address management refactoring
Severity
- Severity: Informational
Submitted by
StErMi
Description
Consider refactoring both the
addAddressesandremoveAddressesfunctions of theAccountListcontract to use the same logic as much as possible. Create an internalupdateAddressesfunction that can be called by both the mentioned functions.function updateAddresses(address[] calldata addrs, bool add) internal { AccountListStorage storage $ = _getAccountListStorage(); uint256 _length = addrs.length; for (uint256 _i; _i < _length; ++_i) { address _addr = addrs[_i]; CheckLib.checkAddress(_addr); if ($.isListed[_addr] == add ) revert StateUnchanged(); $.isListed[_addr] = add; if(add) emit AddressAdded(_addr); else emit AddressRemoved(_addr); } }The same function must also be called in the
initializeflow to replace the existing logic.Recommendation
Kiln should create an internal function
updateAddressesand use it to replace the existing code of theaddAddresses,removeAddressesandinitializefunctions.Kiln
Fixed by the phase-3 access-control redesign in commit d88a4887.
The redesign extracted a single shared internal routine
_updateList(set, addrs, add, isAllow)(AccountList.sol:328-351) that performs the add/remove with per-addressCheckLib.checkAddressand theStateUnchangedno-op guard. It is reused by all four list mutators (addToAllowList/removeFromAllowList/addToBlockList/removeFromBlockList, lines 195/201/207/213) and byinitialize(lines 170-171), exactly as recommended. The oldaddAddresses/removeAddresses/isListedfunctions referenced in the finding no longer exist (the contract now uses two independentEnumerableSets). TheisAllowflag only selects which list-specific event to emit; the add/remove logic itself is single-sourced.Spearbit
Verified the fixes. A single internal
_updateListroutine is now shared by all list mutators and byinitialize.Use forceApprove instead of safeIncreaseAllowance
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
zigtur
Description
The
_createfunction usessafeIncreaseAllowancefrom theSafeERC20library.However, it is not supposed to increase the allowance but actually set it to the value passed.
function _create(Query memory query, address receiver) internal returns (Id queryId, State state) { // ... uint256 _length = query.input.length; for (uint256 _i; _i < _length; ++_i) { Asset memory _asset = query.input[_i]; if (_asset.asset == address(_vehicle)) { uint256 _burnAmount = _convertVehicleSharesToConduitShares( _asset.value, _totalSupply, _vehicleHoldings, _assetDecimals, _vehicle, Math.Rounding.Ceil ); _transfer(msg.sender, address(this), _burnAmount); emit ConduitEvents.Pulled(address(this), _burnAmount, msg.sender, queryId); _burn(address(this), _burnAmount); _totalSupply -= _burnAmount; _vehicleHoldings -= _asset.value; } else { IERC20(_asset.asset).safeTransferFrom(msg.sender, address(this), _asset.value); emit ConduitEvents.Pulled(_asset.asset, _asset.value, msg.sender, queryId); IERC20(_asset.asset).safeIncreaseAllowance(address(_vehicle), _asset.value); } }Recommendation
Consider using
forceApproveinstead ofsafeIncreaseAllowance.Kiln
Acknowledged.
After the phase-3 access-control redesign (d88a4887),
_createresets every input-asset allowance to zero aftervehicle.create()via an unconditionalforceApprove(address(vehicle), 0)loop (Conduit.sol:440-446). This guarantees the conduit→vehicle allowance is always 0 before the nextsafeIncreaseAllowancegrant (Conduit.sol:433), so no stale or accumulated allowance can persist — the concern raised by this finding is fully mitigated.We are intentionally keeping
safeIncreaseAllowancerather than switching toforceApprove(value):query.inputis not deduplicated, and across duplicate input assetssafeIncreaseAllowancecorrectly accumulates the allowance to the total transferred in, whereasforceApprovewould overwrite and under-approve. Combined with the always-zeroed starting allowance,safeIncreaseAllowanceis the more robust choice here, so we are not changing this line.Spearbit
Acknowledged. The allowance is reset to zero after every
vehicle.createcall, which mitigates the concern.Consider aligning the Conduit's code to the other contracts following the existing coding style best practice
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
StErMi
Description
The
Conduitcontract's code is not following the same code style best practices that have been applied widely to other contracts in the codebase.Kiln should consider applying the following suggestions:
Local variable and function input names
- rename all the local variables that could clash with state variables or function names to have the
__prefix. Example:totalSupply->__totalSupplyor_totalSupply->__totalSupply - rename all the function's input names that could clash with state variables or function names to have the
_suffix. Example:totalSupply->totalSupply_
Use the same code/approach already adopted by the
MultiVehicleandBaseVehicleFunction names: adopt the same function names already used by the BaseVehicle. Here are some examples:
_mintOngoingFeesshould be renamed_handleOngoingFees_handleUnlockingshould be renamed_unlock_handleRecoveringshould be renamed_recover- and so on
The same functions (conceptually) have different behaviors
To make a clean example: in the
BaseVehiclethe updated total supply and total assets are directly returned by the_handleOngoingFeesfunction that could modify them (by minting fees). The comparable function in theConduit(called_mintOngoingFees) is not returning those values but returns only the amount of fees that have been minted (if any) to theFeeManager.Recommendation
We understand that the
Conduitis not a "real" Vehicle but we think that it's fair to say that it acts as one. Given the similarity, it would be beneficial for both the readability and maintainability of the code if theConduitwere refactored to align its code and functions' name to the existing one already adopted by the vehicles.⚠️ NOTE: all the above were just examples, the codebase has more. In general this is more a suggestion to review the code and see where it can be optimized and refactored to have a common style/approach.
Kiln
Acknowledged. This is a cosmetic style-alignment observation with no functional or security impact. The three noted divergences still exist in current code: (1) Conduit lifecycle helpers take plain
totalSupply/vehicleHoldingsparams whereas BaseVehicle uses underscore-suffixed locals; (2)_mintOngoingFees/_handleUnlocking/_handleRecoveringnaming differs from BaseVehicle's_handleOngoingFees/_unlock/_recover; (3)_mintOngoingFeesreturns onlysharesToMint. A repo-wide rename/harmonization was out of scope for the phase-3 access-control work and carries regression risk disproportionate to a non-security cleanup; we will fold it into a future dedicated cleanup pass.Spearbit
Acknowledged.
Conduit sanity checks
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
StErMi
Description
Kiln should consider adding the following additional sanity checks to the
Conduitlogicsconstructor+__Conduit_init: add aVehicleCategoryinput to theconstructorof theConduitand enforce that thevehiclewith which theConduitis initialized is indeed respecting theConduit's expected vehicle category. At least for now the protocol is only supporting single-asset vehiclesprocessThe function should revert in the following scenarios:
_owner == address(0): the query does not existvehicle.state(query) == SETTLED / REJECTED: the query has already been finalized and cannot proceed in any other states.
isTransferableConsider reverting with a
UnimplementedorUnsupportedcustom error when$.ownerRegistry == address(0). In this case theConduitdoes not support registering, wrapping and transferring any "wrapped query" as an NFT. This function should never be executable for that kind ofConduit.Another more "soft" approach is to directly return
false.create(optional) Consider validating that the
query.input, thequery.modeand thequery.outputare compatible with the underlying vehicle's expected value. This suggestion has been marked as "optional" given that we are assuming that the vehicle does respect theSTEAMstandard and will revert on its own during thevehicle.create(query)execution if the query is malformed or not compatible with the vehicle itself.createRedeemFromConduitShares(optional) Consider validating that the
query.outputis compatible with the underlying vehicle's expected value and the pre-filledquery.inputandquery.mode. This suggestion has been marked as "optional" given that we are assuming that the vehicle does respect theSTEAMstandard and will revert on its own during thevehicle.create(query)execution if the query is malformed or not compatible with the vehicle itself.Recommendation
Kiln should implement the sanity checks suggested above.
Kiln
Acknowledged. Of the five suggested sanity checks:
- process intentionally early-returns terminal queries (SETTLED/REJECTED) to anyone and no-ops on EMPTY rather than reverting, per the phase-3 access-control redesign (d88a4887) — Conduit.sol:192-204. Reverting would conflict with that design.
- isTransferable is now the single
_transferAllowedpredicate reused for ERC20 cShare transfers and thecreatereceiver screen (Conduit.sol:275-277, 330, 738-744). Making it revert/return false whenownerRegistry == 0would brick ordinary share transfers. - create/createRedeemFromConduitShares validation is already enforced by the underlying vehicle's
create()via STEAM (reverts on invalid input/mode/output, never produces REJECTED). - vehicle-category check:
VehicleLib.isCategoryalready exists and is enforced for MultiVehicle sub-vehicles (VehicleManager.sol:510). Not wiring it into__Conduit_initis intentional; we are tracking it as optional defense-in-depth hardening.
Spearbit
Acknowledged.
Blacklisted Users Can Interact and Emit Events via Query Donations
Severity
- Severity: Informational
Submitted by
Optimum
Description
The
Conduit._isAllowed()function contains a logic flaw that allows restricted users to interact with the contract by setting the receiver to the Conduit address (address(this)).// Allow transfers to the conduit itselfif (addr2 == address(this) || addr1 == address(this)) { return true;}Impact: Blacklisted users can execute state-changing transactions, interact with underlying vehicles, and emit events (e.g., QueryCreated) containing their address, undermining compliance and security filters.
Recommendation
Refactor
_isAllowed()to ensuremsg.senderis always validated unless the sender is the Conduit itself.Kiln
Fixed by the phase-3 access-control redesign (d88a4887).
Conduit._isAllowed()and itsaddr == address(this)short-circuit have been deleted.create()now gates the initiator directly: deposits requireAccountList.canDeposit(msg.sender)and redeems requirecanRedeem(msg.sender)(Conduit.sol:320-328), revertingCreateNotAllowed(msg.sender)independent of thereceiverparameter.canDepositreturnsfalsefor any blocklisted or sanctioned account (AccountList.sol:247-253), so a blocklisted user can no longer create a query — or emitQueryCreated— by settingreceiver = address(this)or any other value. The receiver screen (Conduit.sol:330-332) is additive and only applies whenreceiver != msg.sender. The one remaining self-create path is a blocked (non-sanctioned) holder self-redeeming (canRedeem = !isSanctioned), which is the intended off-boarding behavior and is clamped to the holder's own address by the receiver screen.Spearbit
Fixed by implementing the reviewer's recommendation.
Conduit is prone to donations
Severity
- Severity: Informational
Submitted by
zigtur
Description
The
holdings()function returns the vehicle shares balance of theConduit.As this function is directly getting the balance of the
Conduit, it makes this contract prone to donations which could inflate the conduit shares rate.Recommendation
Either document that donations are expected to the
Conduit, or avoid donations by tracking the amount of shares theConduitgot through queries to the underlying vehicle.Kiln
Fixed in commit
5b535a78holdings()previously returned the raw vehicle-sharebalanceOf(address(this)), so a direct donation of vehicle shares could inflate the conduit-share exchange rate. We added internal vehicle-share accounting — a tracked-holdings field inConduitStore, updated on every legitimate balance change (depositvehicle.create, redeem input, and unlock/recover distribution) — andholdings()/totalAssets()now read that tracked amount instead of the live balance. This removes the donation/rate-inflation surface entirely. Shipped with dedicated tests (a vehicle-share donation does not move the conduit-share rate; tracked holdings equal the real balance under normal create/process/unlock/recover flow, including fee accrual) and updatedIConduit.holdings()documentation.Spearbit
Verified the fixes.
holdings()andtotalAssets()now read internally tracked vehicle shares instead of the live balance, so donations no longer move the exchange rate.Conduit.process is permissionless allowing anyone to process or finalize a query that they do not own
Severity
- Severity: Informational
Submitted by
StErMi
Description
The current implementation of
Conduit.processallows anyone to execute the underlying_processcall that couldresume,unlockorrecovera query, even if themsg.senderis not the owner of the query.This behavior goes against the behavior followed by other contracts in the protocol that follow the
STEAMstandard. While it's true that theConduitis not a Vehicle, we also think that it should follow the same behavior.Recommendation
Kiln should revert the
processoperation if the caller is not the owner of the query. If this feature needs to be supported (used for example by a Keeper system), Kiln can allow the owner of the query to configure allowed delegators that could process the query on his behalf.Kiln
Fixed by the phase-3 access-control redesign (d88a4887).
Conduit.processis no longer permissionless for live queries: it resolves the query owner and, whenmsg.senderis not the owner, requires the newCONDUIT_PROCESSrole — the keeper path you suggested — seeConduit.sol:198-202andRoles.sol:163. The role check revertsMissingRolefor any unauthorized caller, and supports both global and conduit-scoped grants. The only thing still callable by anyone is the early-return for already-terminal queries (SETTLED/REJECTED) atConduit.sol:192-196, which is read-only observability and mutates no state. Covered by the dedicated regression suitetest/conduit/Conduit.ProcessAuthorization.t.sol(non-owner-without-role reverts; owner, global keeper, and conduit-scoped keeper all succeed).Spearbit
Verified the fixes. For live queries,
processis now restricted to the query owner or holders of theCONDUIT_PROCESSrole.Scope and Code Overview
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Scope
The contracts below were reviewed at commit e33758593cc46c198c3358a31334a08b9a52a2a5 and the fix review was reviewed at commit ca898a1c07b591ae4943963c099704522dc0f205.
src├── conduit│ ├── abstracts│ │ ├── ConduitErrors.sol│ │ └── ConduitEvents.sol│ ├── AccountList.sol│ ├── Conduit.sol│ ├── interfaces│ │ ├── IAccountList.sol│ │ ├── IConduit.sol│ │ ├── IOwnerRegistry.sol│ │ └── ISanctionsList.sol│ ├── libs│ │ ├── ConduitStore.sol│ │ └── ConduitStructs.sol│ └── OwnerRegistry.sol└── factories └── conduit ├── AccountListFactory.sol ├── ConduitFactory.sol └── OwnerRegistryFactory.solIn addition to the contract review above, this phase included an end-to-end testing effort over the full stack (vehicles, MultiVehicles, conduits, and keeper flows), exercising the system through stateful invariant campaigns and live-network fork tests.
Codebase Overview
Kiln's phase 3 audit covers the Conduit layer, the user-facing wrapper that turns a raw STEAM vehicle into an ERC-20 product with fees, compliance controls, and transferable positions.
A Conduit wraps exactly one vehicle and issues its own ERC-20 conduit shares; the conduit holds the vehicle's shares, and value flows through the chain
conduit shares ↔ vehicle shares ↔ underlying assets. Users interact through two calls:createvalidates the request, applies access-control checks, pulls the user's assets (or conduit shares for redemptions), snapshots the active fee configuration for the query, and forwards it to the vehicle;processthen drives the query through the STEAM state machine on the owner's behalf — resuming paused queries, unlocking settled ones (minting conduit shares or paying out assets, with fees applied), and recovering failed ones. Synchronous vehicles settle within thecreatetransaction. Management and performance fees accrue through the sharedFeeManager, and each conduit enforces a configurable transfer policy on its shares (list-checked, free, or mint/burn-only).Two optional modules extend a conduit: the AccountList, an allowlist/blocklist with optional sanctions-oracle integration that gates who can create queries, receive shares, and transfer; and the OwnerRegistry, which externalizes query ownership and can wrap an in-flight query into a transferable ERC-721 token. The three conduit factories follow the platform's gated-factory pattern: spawning is role-gated, deploys beacon proxies, and runs an initial burned deposit through the conduit — held as a pending deposit and finalized later when the underlying vehicle settles asynchronously.