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 (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.

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, 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:

  1. Structural checks - asset is whitelisted, oracle is configured, nonce unused, minAmountOut > 0.
  2. Totals check - the contract independently sums amounts[] and requires it to equal totalAmount. The executor cannot lie about the total.
  3. 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.
  4. 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.
  5. Swap - one pooled swap through SwapRouter into the target asset.
  6. 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.
Important propertyAfter distribution the BatchExecutor must hold zero USDC and zero target asset.

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.

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