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, with an on-chain price oracle guarding the minimum output. A timelock owns the administrative contracts. 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
sets aside the execution fee in USDC
asks the price oracle for the independent floor on the net amount
sends one pooled swap through SwapRouter
returns the whole purchased amount to the user accounts
SwapRouter
maps each asset to its swap adapter
UniswapV3Adapter executes the trade
Governance
TimelockController
owns DCAFactory, BatchExecutor, SwapRouter and the oracle
two-day delay on every configuration change
Guardian
instant pause/unpause and executor replacement, nothing else
Three deployed singletons (Factory, BatchExecutor, SwapRouter) plus per-strategy account clones, per-asset adapters, and the governance pair. 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, and whether to enable Aave yield. The strategy owner is not one of those choices: the factory takes it from the caller, so the account always belongs to the wallet that created it. 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.getDueFundedAccounts(tier, windowStart, cursor, limit), a free view call returning a bounded page of accounts that are due and able to fund one execution. It groups them by target asset and computes each account's per-period amount. This selection happens off-chain; the chain only validates.
The view has no authority over execution. Funding can change between selection and the batch landing, so the contracts stay tolerant of an account that turns out to pay nothing. A raw getDueAccounts query remains alongside it as a monitoring surface, precisely because a strategy that is persistently due but unable to pay is deliberately absent from the funded selector and would otherwise be invisible.
3 - On-chain: execute the batch
The executor calls BatchExecutor.executeBatch(accounts, amounts, targetAsset, 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. - 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. The batch totals used downstream are accumulated here from what was actually pulled, so the executor submits no total it could overstate. - Fee: the execution fee is computed on the USDC collected and set aside, so only the net amount continues. Everything downstream sizes itself on that net amount: what is not swapped cannot be bought, quoted against, or distributed.
- Floor: the price oracle returns an independent minimum output for the net amount; 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, with the returned amount re-checked against the same minimum afterwards, because the router is a trust boundary. - Distribute: the entire output is split pro-rata to each account, with the last account absorbing any rounding dust so nothing is stranded. The USDC fee is transferred to the treasury after the swap, so a reverting swap cannot leave the fee already banked.
4 - Receive & withdraw
Each account's receiveOutput forwards the purchased asset to the strategy owner. 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
strategy owner:
deposit
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 (TimelockController, 2-day delay):
all configuration setters
fee config
rescueTokens
setGuardian
guardian (2-of-3 Safe, instant):
pause / unpause
setExecutor
The executor's reach stops at "trigger a buy." Administrative power is split: anything that could redirect funds sits behind the timelock's two-day public delay, while the guardian holds only the two powers a delay would break, neither of which can change routing or touch a balance. 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 |