Contracts

Every contract in the system - what it does, the functions that matter, and who is allowed to call them.

The cast of contracts

DCAFactory creates accounts and holds settings. UserStrategyAccount is your personal vault. BatchExecutor runs the scheduled buys. SwapRouter picks the exchange. The Uniswap adapter does the actual trades (an Aerodrome adapter is deployed but currently unused). AaveV3YieldAdapter earns yield on idle USDC. The oracle contracts are covered separately on the Price oracle page.

Solidity 0.8.24, Foundry. Fee math uses a basis-point denominator of 10000 everywhere. DCAFactory, BatchExecutor, and SwapRouter use OpenZeppelin Ownable2Step (ownership transfers in two steps, so a mistyped address can't brick admin). Line references below point into contracts/src/.

DCAFactory

Plain language

The control center. It mints each user's personal strategy account and is the single source of truth for protocol-wide settings - fees, minimum amounts, the treasury address, and the list of approved yield providers.

Role: deploys UserStrategyAccount clones, maintains the active-strategy registry, and stores fee/config. Read by the BatchExecutor (fees, treasury, isStrategy) and by each account (minimums, registry calls). Inherits Ownable2Step + Pausable. Holds no tokens, so it needs no reentrancy guard.

Key functions

FunctionWhoDoes
createStrategy(StrategyParams)anyone (when not paused)Clones an account, optionally auto-deploys an Aave adapter, initializes it.
registerStrategy(address) / removeStrategy(address)the account itselfAdd/remove from the active registry (O(1) swap-and-pop).
getDueAccounts(tier, windowStart)viewStaticcalls each account's isDue to build batches off-chain.
setFees(exec, yield, referrer)onlyOwnerUpdate fee bps; reverts if any exceeds its hard cap.
setApprovedYieldAdapter(adapter, bool)onlyOwnerAllowlist a yield adapter implementation.
setBatchExecutor / setTreasury / setAaveConfig / setMinExecutions / setMinAmountPerExecutiononlyOwnerProtocol configuration setters.
pause() / unpause()onlyOwnerPausing blocks only createStrategy - never withdrawals.

Fees & caps

All fees are configurable but bounded by on-chain hard caps:

ParameterDefaultHard capMeaning
executionFeeBps15 (0.15%)100 (1%)Cut of each swap's output.
yieldFeeBps1500 (15%)3000 (30%)Cut of Aave yield earned (only on positive yield).
referrerShareBps3000 (30%)5000 (50%)How much of the protocol's execution fee goes to the referrer, if the strategy has one.

Example: if a batch buys 1000 USDC worth of an asset, the 0.15% execution fee is 1.50 USDC worth of that asset. With a 30% referrer share, the referrer receives 0.45 and the treasury receives 1.05. The user still pays the same 1.50 total fee; the referrer share only changes who receives that fee.

Other defaults: minAmountPerExecution = 5 USDC; minimum executions per strategy - Daily 10, Weekly 8, BiWeekly 6, Monthly 3.

UserStrategyAccount

Plain language

Your personal vault. It holds your deposited USDC, optionally parks it in Aave for yield, hands out exactly the per-period amount when a buy is due, and forwards the purchased crypto to your wallet. Only you can withdraw.

Role: deployed as a clone by the factory, one per strategy. Talks to the factory (registry, fees), the yield adapter (idle yield), and the BatchExecutor (pull + receive). Inherits Initializable + ReentrancyGuard; uses SafeERC20. Deliberately not Ownable - the owner is stored as a plain address - so it stays a lean clone.

Key functions & access control
FunctionWhoDoes
initialize(...)once (initializer)One-time setup. All strategy parameters become immutable afterward.
deposit(amount)anyoneFunds the account; enforces minimum on first deposit; routes to Aave if yield on; self-registers on first deposit.
authorisedPull(amount, nonce)onlyBatchExecutorSends exactly amountPerExecution; returns 0 + auto-pauses on insufficient balance; returns 0 (no pause) on yield failure.
receiveOutput(asset, amount)onlyBatchExecutorForwards asset to the destination wallet; falls back to pendingWithdrawals if that transfer fails.
withdraw()onlyOwnerFull withdrawal; collects yield fee; marks strategy closed (terminal). Always available, even when paused.
withdrawPending(asset)onlyOwnerClaim output that fell back to pending.
pause() / resume()onlyOwnerUser-controlled pause of their own strategy.
isDue(tier, windowStart)viewTrue if active, tier matches, and the window is due.
ImmutabilityAfter initialize, the target asset, per-execution amount, frequency, destination wallet, yield flag, and yield adapter can never change. Nobody - not the owner, not the executor, not admin - can rewrite your strategy. You control your balance; never the parameters.

The yield fee on withdrawal is yieldEarned * yieldFeeBps / 10000, where yieldEarned = received - deposited and only when positive. The closed flag makes withdrawal terminal: a closed strategy can never redeposit and sneak back into the active registry.

BatchExecutor

Plain language

The engine. On schedule it collects everyone's USDC for a given asset, makes one big pooled purchase, splits the result fairly back to each user, and takes the protocol fee. It holds no money at rest.

Role: the provider-agnostic executor calls executeBatch. It pulls USDC via each account's authorisedPull, enforces the oracle floor, routes one swap through the SwapRouter, then distributes the asset and pays fees. Inherits Ownable2Step + Pausable + ReentrancyGuard; uses SafeERC20.

The executeBatch six-step flow

executeBatch(accounts[], amounts[], targetAsset, totalAmount, minAmountOut, nonce)
  1. structural checks   asset whitelisted, oracle set, nonce fresh, minAmountOut > 0
  2. totals check        sum(amounts) == totalAmount         (executor cannot lie)
  3. pull                authorisedPull per account; skip the unfundable;
                         each account verifies targetAsset matches its own
  4. floor               effectiveMinOut = max(adjustedExecutorMinOut, oracleMinOut)
  5. swap                one pooled swap via SwapRouter
  6. distribute          take fee, split pro-rata, assign dust to the last account
                         assert zero USDC & zero asset left
Setters & admin functions
FunctionWhoDoes
setExecutor(address)onlyOwnerSwitch the automation provider.
setSwapRouter(address)onlyOwnerPoint at a swap router.
setPriceOracle(address)onlyOwnerSet the oracle/dispatcher. Required before any batch can run.
setWhitelistedAsset(asset, bool)onlyOwnerAllow/deny an asset.
rescueTokens(token, to)onlyOwnerTransfers any balance of token sitting directly on BatchExecutor to to. This is for accidental or stranded tokens; user strategy balances live in separate user accounts.
pause() / unpause()onlyOwnerPausing blocks only executeBatch.
Key protectionsnonReentrant on executeBatch; nonce replay guard; asset allowlist; minAmountOut == 0 rejected and oracle must be set; only pulls from registered factory.isStrategy accounts; reverts the whole batch on per-account asset mismatch; asserts zero balance after every batch. rescueTokens cannot reach into user strategy accounts.

SwapRouter

Plain language

A traffic director. It takes a swap request and forwards it to the right exchange adapter for the asset being bought - currently the Uniswap V3 adapter for all four assets (an Aerodrome adapter is wired in too, but unused). It takes no fee and holds no funds.

Role: sits between the BatchExecutor and the swap adapters. Inherits Ownable2Step; uses SafeERC20. swap(asset, amountIn, minAmountOut) is callable only by the BatchExecutor; it looks up the per-asset adapter, pulls USDC, approves and calls the adapter, and returns the asset. Config setters (setAssetAdapter, configureUniswapRoute, configureAerodromeRoute, setBatchExecutor) are onlyOwner. Reverts NoAdapterForAsset if an asset has no adapter mapped.

Swap adapters

Plain language

The adapters are the only parts that talk to a specific exchange. Each implements the same ISwapAdapter interface, so adding a new venue or chain is a matter of writing one adapter - nothing upstream changes.

UniswapV3Adapter

Buys on Uniswap V3 - both single-hop trades, such as USDC to WETH, and two-hop routed trades, such as USDC to WETH to wstETH, via a per-asset RouteConfig. On Base mainnet it handles all four assets: WETH and cbBTC direct, wstETH and cbETH two-hop via WETH (fee 500). Both setRoute and swap are restricted to the immutable swapRouter address. Slippage is enforced via minAmountOut (no price limit set on the pool). Reverts AssetNotConfigured if no route exists for the asset.

AerodromeAdapter

The same ISwapAdapter job for Aerodrome, kept for venue flexibility. Single-hop only, using Aerodrome's Route[] ABI with a stable/volatile flag and pool factory per asset. Same caller restriction and slippage handling. It is deployed but currently unused - see the routing note.

Routing noteOn Base mainnet, cbETH routes through Uniswap V3 two-hop trading from USDC to WETH to cbETH (fee 500/500), not Aerodrome: the direct Aerodrome USDC/cbETH pool is too thin (it quotes well off fair value), whereas the Uniswap cbETH/WETH 0.05% pool is deep (~99.9% of fair value). The Aerodrome adapter is deployed but not active for the current routes. The executor reads the live on-chain route so its QuoterV2 quote matches the on-chain swap path exactly.

AaveV3YieldAdapter

Plain language

Parks a user's idle USDC in Aave V3 to earn interest, and pulls it back when a buy is due or the user withdraws. One adapter instance per account.

Role: implements IYieldAdapter; bound to exactly one owning UserStrategyAccount via an immutable account address. Auto-deployed by the factory when yield is enabled with no custom adapter. deposit, withdraw, and withdrawAll are onlyAccount; balanceOf returns principal + accrued yield. Uses SafeERC20.

The shortfall guard requests min(amount, aTokenBalance) so Aave never reverts on a 1-wei aToken rounding shortfall, and reverts WithdrawalShortfall only if the received amount is more than 1 wei short.

Interfaces

Thin interface contracts keep the system decoupled and EVM-agnostic: ISwapAdapter, IYieldAdapter, IPriceOracle, IChainlinkAggregator, IUniswapV3Router, IAerodromeRouter, IAaveV3Pool. Swapping a venue or yield provider means writing a new adapter against the existing interface - no change to the core.