Documentation

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.

Modulespawn parameterPurpose
FeeManagerfeeManagerManagement, performance, deposit and redeem fees
AccountListaccountListWho may deposit, hold and transfer shares
OwnerRegistryownerRegistryDelegates query ownership, for ERC721 positions
InterceptionsinitialInterceptionsRoutes 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:

ModeDeposit (create)Transfer
OPENAllow-list ignored; block-list onlyOpen to non-blocked parties
REGULARGated by the allow-listOpen to non-blocked parties
STRICTGated by the allow-listBoth 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 holds CONDUIT_FORCE_REDEEM. A clean holder is never force-redeemable — the call reverts ForceRedeemNotAllowed. With no AccountList configured at all it reverts ForceRedeemUnavailable, 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 by CONDUIT_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 reverts StateUnchanged. Treat enabling transfers as permanent.

transferEnabledBehavior
falseMint 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.
trueTransfers 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:

  1. spawn() pulls the registry-configured initial deposit, creates the seed deposit query (owner and receiver both the Conduit), and processes it.
  2. Sync vehicle — the deposit settles in the same transaction. spawn burns the seed shares to the burn address, checks initialExpectedSupply, and calls enable(). Done.
  3. Async vehicle — spawn records a PendingDeposit and emits PendingConduitDeposit. The Conduit stays disabled until the seed query reaches SETTLED.

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, finalizeConduitDeposit is a no-op that leaves the pending record intact — re-call it after each state advance. It reverts NoPendingDeposit only 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().