Contracts
Every contract in the system - what it does, the functions that matter, and who is allowed to call them.
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.
onlyOwner 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
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
| Function | Who | Does |
|---|---|---|
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 itself | Add/remove from the active registry (O(1) swap-and-pop). |
getDueFundedAccounts(tier, windowStart, cursor, limit) | view | A bounded page of accounts that are due and able to fund one execution. The selection surface for building batches off-chain. |
getDueAccounts(tier, windowStart) | view | The 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) | onlyOwner | Update fee bps; reverts if either exceeds its hard cap. Two arguments: there is no referral share to set. |
setApprovedYieldAdapterImplementation(impl, bool) | onlyOwner | Allowlist 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 / setMinAmountPerExecution | onlyOwner | Protocol configuration setters. |
pause() / unpause() | onlyGuardian | Instant, by design. Pausing blocks only createStrategy, never withdrawals. |
Fees & caps
All fees are configurable but bounded by on-chain hard caps:
| Parameter | Default | Hard cap | Meaning |
|---|---|---|---|
executionFeeBps | 15 (0.15%) | 100 (1%) | Computed on the USDC collected for a batch and deducted in USDC before the swap. |
yieldFeeBps | 1500 (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
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
| Function | Who | Does |
|---|---|---|
initialize(...) | once (initializer) | One-time setup. All strategy parameters become immutable afterward. |
deposit(amount) | onlyOwner | Funds 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) | onlyBatchExecutor | Rejects 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) | onlyBatchExecutor | Forwards 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() | onlyOwner | Full 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) | onlyOwner | Claim 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() | onlyOwner | User-controlled pause of their own strategy. |
isDue(tier, windowStart) | view | True if active, tier matches, and the window is due. |
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
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
| Function | Who | Does |
|---|---|---|
setExecutor(address) | onlyGuardian | Switch 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) | onlyOwner | Rotate the guardian. Deliberately not guardian-gated, so a compromised guardian cannot entrench itself. |
setSwapRouter(address) | onlyOwner | Point at a swap router. Timelocked. |
setPriceOracle(address) | onlyOwner | Set the oracle. Required before any batch can run. Timelocked. |
setWhitelistedAsset(asset, bool) | onlyOwner | Allow/deny an asset. Timelocked. |
rescueTokens(token, to) | onlyOwner | Transfers 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() | onlyGuardian | Instant. Pausing blocks only executeBatch. A stop button you have to wait two days to press is not a stop button. |
nonReentrant 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.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
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
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
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.