Architecture

How the contracts fit together, and how funds and permissions move through the system.

The short version

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.

User-facing versionThis section explains the architecture behind the flow. If you want the product journey first - including what transactions the app asks you to sign and what happens when something fails - start with User flows.

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:

  1. Structural checks: asset is whitelisted, oracle is configured, nonce unused, minAmountOut > 0.
  2. Pull: for each account, authorisedPull moves exactly that account's per-period USDC. Accounts that cannot pay are skipped, not failed; each account also verifies the batch's targetAsset matches 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.
  3. 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.
  4. 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.
  5. Swap: one pooled swap through SwapRouter into the target asset, with the returned amount re-checked against the same minimum afterwards, because the router is a trust boundary.
  6. 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.
Important propertyThe BatchExecutor distributes everything it pulls and swaps, keeping none of the funds it moves. Because the fee is taken in USDC, the distribution step has no fee term at all: every unit of the purchased asset belongs to the accounts that paid for it.

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.

TierFrequencyAnchor (UTC)
0Daily00:00 every day
1Weekly00:00 every Monday
2BiWeekly00:00, 14-day epochs anchored to 1970-01-05
3Monthly00:00 on the 1st