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. AaveV3YieldAdapter earns yield on idle USDC. A TimelockController owns the four governed contracts (factory, batch executor, swap router, oracle). The oracle contracts are covered separately on the Price oracle page.

It does not own your strategy clone, the swap adapter, or your yield-adapter clone. Your account stores you as its owner, and the adapters answer only to the contract above them in the chain.

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 cannot brick admin), and their owner is a TimelockController, so every onlyOwner call below is subject to a two-day public delay.

Two kinds of adminonlyOwner means the timelock: queued publicly, executable after two days. It is the role that can change routing, the oracle, the asset allowlist, the treasury and the fees, which is exactly why it is delayed. onlyGuardian means a 2-of-3 Safe acting instantly, deliberately limited to pausing, unpausing, and replacing the executor address, none of which can change routing or move funds. Rotating the guardian is itself owner-gated, so a compromised guardian cannot entrench itself. Neither role can withdraw from a user strategy account.

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, referralCommitment)anyone (when not paused)Clones an account and initializes it. The owner is taken from msg.sender, not from calldata, so a compromised frontend cannot create a strategy controlled by someone else. If yield is enabled, clones the approved implementation the caller named.
registerStrategy(address) / removeStrategy(address)the account itselfAdd/remove from the active registry (O(1) swap-and-pop).
getDueFundedAccounts(tier, windowStart, cursor, limit)viewA bounded page of accounts that are due and able to fund one execution. The selection surface for building batches off-chain.
getDueAccounts(tier, windowStart)viewThe raw due query, kept as a monitoring surface: a strategy that is persistently due but cannot pay is deliberately absent from the funded selector above.
setFees(exec, yield)onlyOwnerUpdate fee bps; reverts if either exceeds its hard cap. Two arguments: there is no referral share to set.
setApprovedYieldAdapterImplementation(impl, bool)onlyOwnerAllowlist a reviewed yield adapter implementation. Each strategy names the venue it wants at creation, and the factory mints a minimal-proxy clone of that implementation for that strategy alone.
setBatchExecutor / setTreasury / setMinExecutions / setMinAmountPerExecutiononlyOwnerProtocol configuration setters.
pause() / unpause()onlyGuardianInstant, by design. Pausing blocks only createStrategy, never withdrawals.
How yield venues are chosenThe owner approves a reviewed adapter implementation, each strategy names the venue it wants at creation, and the factory mints a cheap minimal-proxy clone of that implementation for that strategy alone. Market addresses live in the implementation, so there is no separate market-configuration setter to get wrong. Your venue is fixed for the life of the strategy, and nobody can change it after creation. Idle yield is live on Base mainnet via Aave V3, chosen per strategy at creation.

Fees & caps

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

ParameterDefaultHard capMeaning
executionFeeBps15 (0.15%)100 (1%)Computed on the USDC collected for a batch and deducted in USDC before the swap.
yieldFeeBps1500 (15%)3000 (30%)Cut of idle yield earned, only on positive yield, only at withdrawal.

Example: a batch that collects 1000 USDC pays 1.50 USDC to the treasury and swaps the remaining 998.50 USDC. Every unit of the asset bought with that 998.50 is distributed to the accounts that funded it, so there is no fee cut taken out of the purchased asset.

There is no referrerShareBps. No contract computes, stores, or pays a referral share. Creation carries a blinded referralCommitment that the factory emits and never interprets, which keeps a future off-chain program possible without putting resolvable attribution on chain. A zero commitment is rejected, so the field cannot be quietly skipped.

Launch inputs: minAmountPerExecution = 5 USDC; minimum executions per strategy are Daily 12, Weekly 10, BiWeekly 8, Monthly 6.

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)onlyOwnerFunds the account; enforces the minimum on first deposit; routes to the yield venue if yield is on; self-registers on first deposit.
authorisedPull(amount, nonce)onlyBatchExecutorRejects any request that is not exactly amountPerExecution, then sends what the account actually holds: normally the full amount, or up to 1 base unit less when the yield venue rounds down. Returns 0 + auto-pauses on insufficient balance; returns 0 (no pause) on yield failure.
receiveOutput(asset, amount)onlyBatchExecutorForwards the asset to the strategy owner; falls back to pendingWithdrawals if that transfer fails. There is no separate destination wallet: output always goes to the owner.
withdraw()onlyOwnerFull withdrawal; collects yield fee; marks strategy closed (terminal). No pause can block it: not the protocol's, not your own strategy's. If yield is enabled it does still need Aave to release the position, so it is pause-proof rather than failure-proof.
withdrawPending(asset)onlyOwnerClaim output that fell back to pending. Requires a non-zero pending record, then transfers the account's actual balance of that asset, so a record larger than the real balance cannot lock the tokens.
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 owner, target asset, per-execution amount, frequency, 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 keeps none of your money: whatever it collects goes out in the same transaction.

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 flow

executeBatch(accounts[], amounts[], targetAsset, minAmountOut, nonce)
  1. structural checks   asset whitelisted, oracle set, nonce fresh, minAmountOut > 0
  2. pull                authorisedPull per account; skip the unfundable;
                         each account verifies targetAsset matches its own
                         totals are accumulated here, never taken from the caller
  3. fee                 totalFee   = actualTotal * executionFeeBps / 10000   (USDC)
                         swapAmount = actualTotal - totalFee
  4. floor               effectiveMinOut = max(adjustedExecutorMinOut, oracleMinOut)
                         both derived from swapAmount, not the gross total
  5. swap                one pooled swap of swapAmount via SwapRouter
                         re-verify the returned amount against effectiveMinOut
  6. distribute          split the WHOLE output pro-rata, dust to the last account
                         then transfer the USDC fee to the treasury
                         assert no new USDC or asset left behind

Five arguments, and the caller submits no batch total. Swap sizing, the oracle floor, and the executor's own bound are all derived from what the contract actually pulled, so there is no figure an executor could overstate.

The fee is fixed in step 3 but moved in step 6, after the swap. That ordering is deliberate: the amount is decided before anything is swapped, so it never depends on slippage, while a reverting swap cannot leave the fee already banked. Because the fee never enters the swap, the distribution step has no fee term at all and every unit of the purchased asset belongs to the accounts that paid for it.

Setters & admin functions
FunctionWhoDoes
setExecutor(address)onlyGuardianSwitch the automation address. Instant on purpose: that key is the one most likely to leak, and replacing it cannot move anyone's funds.
setGuardian(address)onlyOwnerRotate the guardian. Deliberately not guardian-gated, so a compromised guardian cannot entrench itself.
setSwapRouter(address)onlyOwnerPoint at a swap router. Timelocked.
setPriceOracle(address)onlyOwnerSet the oracle. Required before any batch can run. Timelocked.
setWhitelistedAsset(asset, bool)onlyOwnerAllow/deny an asset. Timelocked.
rescueTokens(token, to)onlyOwnerTransfers any balance of token sitting directly on BatchExecutor to to. For accidental or stranded tokens; user strategy balances live in separate user accounts. Timelocked.
pause() / unpause()onlyGuardianInstant. Pausing blocks only executeBatch. A stop button you have to wait two days to press is not a stop button.
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 a batch distributes everything it pulled and swapped, keeping nothing of its own. rescueTokens cannot reach into user strategy accounts.
Two separate account checksProvenance and membership are asked as separate questions. isDeployedByFactory means this factory really did create the account, and is permanent. isActiveStrategy means the account is running right now. A batch must satisfy both, so a batch assembled just before you paused or withdrew cannot touch your account when it lands. Your account answers such a batch by doing nothing at all, with no tokens moved and no state of its own changed, rather than by falling through a balance check. The batch nonce is still consumed, so it cannot be replayed later. Only your own deposits are ever at stake, and only you can withdraw them.

SwapRouter

Plain language

A traffic director. It takes a swap request and forwards it to the exchange adapter configured for the asset being bought. 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. 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.

On Base mainnet it is configured for WETH and cbBTC, with direct USDC routes (WETH at fee tier 3000, cbBTC at fee tier 500). Whatever the route, the off-chain executor reads it on chain before quoting, so its quote matches the path the adapter will actually take.

Other venues

The adapter interface exists so a venue can be added without touching anything upstream: a new adapter implements the same ISwapAdapter, and one owner call maps an asset to it. Adding a venue is a configuration change plus one reviewed contract, not a change to the custody or execution model.

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. Cloned by the factory from an owner-approved implementation when the strategy enables yield. deposit, withdraw, and withdrawAll are onlyAccount; balanceOf returns principal plus accrued yield. Uses SafeERC20.

Idle yield is live on Base mainnet via Aave V3, optional per strategy. A strategy can also hold idle USDC without lending it.

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.