Fees & Modules
A Conduit is deliberately thin: it wraps one Vehicle with an ERC20 share token and
delegates everything else to optional modules chosen at deployment. All four
addresses below are spawn parameters, and all four accept 0x0000... — a Conduit
with no modules at all is a valid, working Conduit.
| Module | spawn parameter | Purpose |
|---|---|---|
| FeeManager | feeManager | Management, performance, deposit and redeem fees |
| AccountList | accountList | Who may deposit, hold and transfer shares |
| OwnerRegistry | ownerRegistry | Delegates query ownership, for ERC721 positions |
| Interceptions | initialInterceptions | Routes additional rewards to third parties |
None of them can be swapped after deployment — only their own internal configuration changes. Decide before you spawn.
FeeManager
Handles all four fee types:
- Management fee — annualized, prorated by time elapsed.
- Performance fee — charged on gains above a high water mark.
- Deposit fee — deducted from the shares received.
- Redeem fee — deducted from the assets received.
Fees are captured at query creation and applied at settlement. That matters for
async vehicles: a deposit created before a fee change settles under the old
schedule, so a manager cannot retroactively tax queries already in flight. The feesConfigId(query) view tells you which captured configuration a given query is
bound to.
Management and performance fees accrue by minting shares to the FeeManager during process(), which is why a Conduit’s totalSupply() grows slightly even with no
new deposits.
To deploy one, use the Fee Manager step of the Multi-Vehicle guide — the same FeeManagerFactory serves Conduits.
AccountList
The compliance layer: an allow-list, a block-list, and optional sanctions screening. It is a separate contract, so one AccountList can be shared across several Conduits.
Precedence is: sanctions > block-list > allow-list/mode. A blocked but non-sanctioned holder may always redeem to retrieve their own funds — being blocked stops you entering, not exiting.
Allowlist modes
setAllowlistMode() picks one of three:
| Mode | Deposit (create) | Transfer |
|---|---|---|
OPEN | Allow-list ignored; block-list only | Open to non-blocked parties |
REGULAR | Gated by the allow-list | Open to non-blocked parties |
STRICT | Gated by the allow-list | Both sides must be allow-listed |
Redeem is gated by sanctions only in every mode, so any non-sanctioned holder can
always exit. Switching mode is always safe — it only changes which operations
consult the lists, and reverts ModeUnchanged on a no-op.
Lists and sanctions
addToAllowList / removeFromAllowList and addToBlockList / removeFromBlockList take address arrays and revert AddressAlreadyListed / AddressNotListed; address(0) is rejected.
Sanctions screening calls an external oracle implementing ISanctionsList (for
example Chainalysis). It fails closed: an oracle revert, an EOA at the oracle
address, or a malformed return is all treated as sanctioned. Enabling it requires a
configured oracle (SanctionsOracleRequired otherwise), and the oracle may only be
set back to address(0) while screening is off.
All of this is gated by the ACCOUNT_LIST_MANAGER role on the AccountList’s own
access control. Inspect state with the isBlocked, isAllowListed, isSanctioned, mode, sanctionsEnabled and oracle views.
Ejecting a non-compliant holder
forceRedeem(user, amount, output) burns a blocked or sanctioned holder’s shares
and returns the underlying to that holder — there is no third-party custody.
It only works on an already-non-compliant holder.
canForceRedeem(user, caller)requires both that the target is blocked or sanctioned and that the caller holdsCONDUIT_FORCE_REDEEM. A clean holder is never force-redeemable — the call revertsForceRedeemNotAllowed. With no AccountList configured at all it revertsForceRedeemUnavailable, and with a zero amount,NothingToRedeem.
Size the expected output with estimate() rather than a raw output floor, since estimate() accounts for conduit fees and the current share rate.
Transfer policy
Share transferability is a single boolean, transferEnabled, set at initialization.
It is a one-way latch.
enableTransfers()(gated byCONDUIT_SET_TRANSFER_ENABLED) turns it on and there is no way to turn it back off — disabling it would brick every ERC20 integration downstream. Calling it twice revertsStateUnchanged. Treat enabling transfers as permanent.
transferEnabled | Behavior |
|---|---|
false | Mint and burn only — no user-to-user transfers, no wrapped-query NFT transfers, no third-party-receiver creates. Fee dispatch, self-redeem and force-redeem still work. |
true | Transfers permitted, still subject to the AccountList |
The two policies compose: transferEnabled && (accountList == 0 || accountList.canTransfer(from, to)). isTransferable(from, to) evaluates exactly that, so check it for a specific pair
before relying on a move.
OwnerRegistry
Delegates query ownership to an external registry, which lets a pending query be wrapped into a transferable ERC721. This is the advanced path for tokenized positions — a secondary market in pending redemptions rather than in shares.
Most Conduits pass 0x0000... here.
Interceptions — routing additional rewards
Protocol incentives and airdrops that land on the Conduit can be split toward
integrators. An interception maps an asset to a list of recipients with basis-point
shares; asset: address(0) is a wildcard matching every asset.
// Route 5% of all rewards to an integrator, on every chain.
Interceptor.Recipient[] memory recipients = new Interceptor.Recipient[](1);
recipients[0] = Interceptor.Recipient({
target: integratorAddress,
shareBps: 500, // 5%
chainId: 0 // all chains
});
Interceptor.Interception[] memory interceptions = new Interceptor.Interception[](1);
interceptions[0] = Interceptor.Interception({
asset: address(0), // wildcard: all assets
recipients: recipients
});
conduit.setInterceptions(interceptions); // requires CONDUIT_SET_INTERCEPTIONS Unlike the module addresses, interceptions can be reconfigured after deployment, so passing an empty array at spawn costs you nothing.
Enabling a Conduit
A freshly spawned Conduit is not enabled — only its deployer (the factory) may create queries or move shares until it is. Enabling is part of the seed-deposit flow and is done by the factory, never by hand:
spawn()pulls the registry-configured initial deposit, creates the seed deposit query (owner and receiver both the Conduit), and processes it.- Sync vehicle — the deposit settles in the same transaction.
spawnburns the seed shares to the burn address, checksinitialExpectedSupply, and callsenable(). Done. - Async vehicle —
spawnrecords aPendingDepositand emitsPendingConduitDeposit. The Conduit stays disabled until the seed query reachesSETTLED.
For the async case, call ConduitFactory.finalizeConduitDeposit(conduit) once the
seed query settles. It drives the query via process() and, on SETTLED, burns the
seed shares, verifies initialExpectedSupply (reverting ExpectedSupplyNotReached otherwise), enables the Conduit, and clears the pending record.
It is safe to call early. Before the deposit settles,
finalizeConduitDepositis a no-op that leaves the pending record intact — re-call it after each state advance. It revertsNoPendingDepositonly when there is no pending deposit at all.ready()tells you whether the Conduit is already open.
The Deploy a Conduit guide walks this
end to end with a live check on ready().