Lets a user automate recurring buys (dollar-cost averaging) on Solana without handing custody to a centralized service. Funds stay in a program-controlled vault the user can exit at any time. A permissionless off-chain keeper triggers each scheduled swap by CPIing into Jupiter v6.
- State: one
PositionPDA per active DCA strategy. Seeds:[b"position", owner, user_nonce]. Theuser_noncelets a single owner run multiple concurrent DCA positions. The Position is the authority over its input vault (an ATA holding the deposited input token) and output vault (an ATA accumulating the output token). - Instructions:
create_dcaopens a position, deposits the full input budget, and sets the schedule and the catastrophic-loss floor.execute_swapis permissionless. It validatesnow >= last + interval, snapshots input/output vault balances, CPIs Jupiter'srouteinstruction, then assertsinput_delta == amount_per_cycleandoutput_delta >= min_out_per_cycle.claim_outputis owner-only. It drains the output vault to the owner's ATA.close_dcais owner-only. It returns remaining input and output, closes the vaults, closes the position, and returns rent.
v1 has no on-chain notion of fair market price. Protection works in two independent layers, and it is important not to confuse them:
- Per-cycle fill quality is handled by the keeper, which sets Jupiter's own slippage tolerance tightly when it builds each route. Jupiter reverts any fill that misses that tolerance. This absorbs ordinary quote-vs-execution drift.
- Catastrophic-loss protection is handled by
min_out_per_cycle, an immutable floor stored on the Position and set once at creation. The program enforces it against the actual vault balance delta, computed from before/after snapshots, never from a value Jupiter or the keeper reports.
min_out_per_cycle is a disaster backstop, not a price tracker. It
should be set loosely, well below expected market output, so that
normal price movement across the DCA schedule never trips it. Setting it
tightly would cause cycles to fail on routine volatility, which defeats
the purpose of DCA (running through price movement). The cost of a loose
floor is covered under "Security considerations" below.
The key property: Jupiter's slippage check is parameterised by the
untrusted keeper, so it cannot be the security boundary. min_out_per_cycle
is the boundary the keeper cannot influence.
What the program defends against:
- PDA-owned vaults. Input and output vaults are authority-controlled by the Position PDA. Only the program can move funds out of them.
- Owner-only instructions.
claim_outputandclose_dcarequire the owner's signature and verify it against the Position. - Input bound.
execute_swapassertsinput_delta == amount_per_cycle, so a keeper cannot push more than one cycle's budget through a route, regardless of how the Jupiter instruction is constructed. - Measured, not trusted. The output floor is checked against the real balance delta on the vault, not a return value from the swap.
- Atomic failure. A failed Jupiter CPI or a sub-floor fill reverts the entire transaction. The cycle counter is not decremented and no funds move, so the keeper can safely retry next block.
Out of scope in v1:
- No on-chain price reference. Because there is no oracle, a keeper
that is both malicious and the route builder can still produce a fill
anywhere above
min_out_per_cycle. The catastrophic floor caps the worst case; it does not guarantee a fair per-cycle price. An oracle-based guard (see roadmap) narrows this window by giving the program its own price reference, bounded by oracle confidence and staleness. It reduces the gap; it does not eliminate it.
anchor buildTests run against surfpool, a drop-in
replacement for solana-test-validator that JIT-clones any account or
program from mainnet on first reference. This lets the test suite CPI
into the real Jupiter v6 program with real DEX state, rather than
mocking it.
In one terminal:
surfpool start --rpc-url <mainnet-rpc-url>In another:
anchor run testCoverage includes the happy path plus the adversarial cases: early-crank rejection, last-cycle behavior, sub-floor fill rejection, double-claim, and unauthorized close.
solana config set --url devnet
anchor deployDevnet program ID: BJtVmmKQq1fcgbyWtU1PBbyftNdKXpz4gnCaMFpVyuBz
(view on Solana Explorer).
A keeper is any off-chain process that periodically:
- Lists active positions (e.g. via an indexer or
getProgramAccounts). - For each position whose
last_execution_ts + interval_seconds <= nowandcycles_remaining > 0:- Calls Jupiter's
/buildendpoint for the position's(input_mint, output_mint, amount_per_cycle)withuserPublicKey = position, setting a tight slippage tolerance. - Sends
execute_swapwith the returned instruction data asjupiter_data.
- Calls Jupiter's
v1 ships the program and the test suite; a production keeper is left as
an integration exercise. The flow above is implemented end to end in
tests/fixtures/jupiter.ts and is a fine reference.
- Pyth/Switchboard price guard for a tighter, market-aware per-execution floor.
- Keeper fee (bps cut of output) to incentivise cranking.
- Pause / resume.
- Top-up (
deposit_more).