Architecture
How the contracts fit together, and how funds and permissions move through the system.
Each user gets a private account (a cheap clone) that holds their USDC. A factory creates these accounts and stores protocol settings. A separate executor pools many accounts' buys into one swap, routed through a swap adapter (Uniswap V3 for all four assets today), with an on-chain price oracle guarding the minimum output. Funds only flow through permissioned paths - the design keeps user accounts isolated while letting execution be shared and gas-efficient.
Design principles
Three principles shape every structural choice:
- Isolated custody, pooled execution
- Each user's funds live in their own account contract - never commingled in a shared pool. But execution is batched: many accounts buying the same asset are combined into a single swap. You get isolation for safety and pooling for efficiency.
- Least privilege for automation
- The executor can trigger a scheduled buy and nothing else. It cannot withdraw, cannot change parameters, and cannot pull more than the per-period amount from any account.
- EVM-agnostic, no hardcoded addresses
- No external address is baked into contract logic. Everything - tokens, routers, pools, Aave - is injected at deploy time from per-chain config. Replicating the protocol on another EVM chain requires config, not code changes.
Contract topology
There are three families of contracts: the user layer (factory + per-user accounts + yield), the execution layer (executor + router + swap adapters), and the oracle layer (the price floor). The factory and executor are deployed once; user accounts are minted per strategy as minimal proxies (clones), which makes creating a strategy cheap.
Strategy creation
DCAFactory
creates one UserStrategyAccount clone for each strategy
stores shared fee and configuration values
User custody
UserStrategyAccount
holds the user's USDC
can park idle USDC in AaveV3YieldAdapter when yield is enabled
Scheduled execution
Executor
calls BatchExecutor when a schedule window is due
BatchExecutor
pulls due USDC from UserStrategyAccount clones
asks OracleDispatcher for the independent price floor
sends one pooled swap through SwapRouter
returns purchased assets to the user accounts
SwapRouter
uses UniswapV3Adapter for WETH, wstETH, cbBTC, and cbETH
keeps AerodromeAdapter deployed for venue flexibility, but unused today
Three deployed singletons (Factory, BatchExecutor, SwapRouter) plus per-strategy account clones and per-asset adapters. The oracle layer is detailed on the Price oracle page.
The deposit-to-buy lifecycle
Following the money end-to-end is the clearest way to understand the system.
1 - Create & deposit
A user calls DCAFactory.createStrategy(...) with their choices: target asset, amount per execution, frequency tier, destination wallet, and whether to enable Aave yield. The factory clones a UserStrategyAccount and initializes it. The user then calls deposit(amount) on their account. On the first deposit, the account registers itself in the factory's active registry and (if yield is on) supplies the USDC to Aave.
2 - Off-chain: find who is due
The executor periodically reads DCAFactory.getDueAccounts(tier, windowStart) - a free view call that asks each registered account "are you due in this window?". It groups the due accounts by target asset and computes each account's per-period amount. This selection happens off-chain; the chain only validates.
3 - On-chain: execute the batch
The executor calls BatchExecutor.executeBatch(accounts, amounts, targetAsset, totalAmount, minAmountOut, nonce). The executor is the only address allowed to call this. Inside, in order:
- Structural checks - asset is whitelisted, oracle is configured, nonce unused,
minAmountOut > 0. - Totals check - the contract independently sums
amounts[]and requires it to equaltotalAmount. The executor cannot lie about the total. - Pull - for each account,
authorisedPullmoves exactly that account's per-period USDC. Accounts that cannot pay are skipped, not failed; each account also verifies the batch'stargetAssetmatches its own target. - Floor - the price oracle returns an independent minimum output; the effective minimum is
max(executor's adjusted min, oracle min). The executor can only make slippage stricter. - Swap - one pooled swap through
SwapRouterinto the target asset. - Distribute - the protocol fee is taken, then the asset is split pro-rata to each account; the last account absorbs any rounding dust so nothing is stranded.
4 - Receive & withdraw
Each account's receiveOutput forwards the purchased asset to the user's destination wallet. If that transfer fails, the amount is safely held in pendingWithdrawals for the user to claim. The user can call withdraw() at any time to take their full balance and close the strategy; this works even when the protocol is paused.
Permission flow - who may call what
Every privileged edge is locked down. The list below shows the only permitted callers of each sensitive entry point.
end user:
createStrategy anyone, when not paused
deposit anyone, because funding is open
strategy owner:
withdraw
pause
resume
withdrawPending
executor:
executeBatch BatchExecutor.executor only
BatchExecutor internals:
authorisedPull BatchExecutor only, up to amountPerExecution
receiveOutput BatchExecutor only
SwapRouter.swap BatchExecutor only
SwapRouter:
adapter.swap SwapRouter only
UserStrategy:
yieldAdapter.deposit owning account only
yieldAdapter.withdraw owning account only
owner, then multisig:
all setters
fee config
pause
rescueTokens
The executor's reach stops at "trigger a buy." Admin powers (setters, pause) belong to the owner - the deployer on testnet, a Gnosis Safe multisig on mainnet. The security page explains the limits.
What this architecture optimizes for
The system intentionally separates custody, execution, routing, and pricing. That separation creates a few practical user outcomes:
- Your account holds your funds. The factory creates strategies but does not custody deposits.
- Execution can be pooled without pooling custody. Many users can share one swap while their balances remain isolated.
- The executor has a narrow job. It can trigger a due batch, but each account and the BatchExecutor validate the requested action.
- External dependencies are replaceable by configuration. A future chain, keeper, oracle, router, or yield source should require config or adapters rather than rewriting the core custody model.
The trade-off is that operators must configure those dependencies correctly. The trust and failure assumptions are covered in Trust & failures.
Why clones?
Each UserStrategyAccount is an EIP-1167 minimal proxy pointing at one shared implementation. Deploying a full contract per user would be prohibitively expensive; a clone costs a fraction of the gas. The implementation's constructor calls _disableInitializers() so the logic contract itself can never be initialized or hijacked - only clones are initialized, exactly once.
Frequency tiers
Strategies run on one of four fixed schedules, anchored to absolute time windows rather than each user's signup moment. This lets many users share a window and therefore a batch.
| Tier | Frequency | Anchor (UTC) |
|---|---|---|
| 0 | Daily | 00:00 every day |
| 1 | Weekly | 00:00 every Monday |
| 2 | BiWeekly | 00:00, 14-day epochs anchored to 1970-01-05 |
| 3 | Monthly | 00:00 on the 1st |