Skip to main content

Overview

Smart DCA enables automated, recurring BTC purchases with dynamic position sizing based on market conditions:
  • Standard DCA: Fixed buy amount per execution
  • Smart DCA: Buy amount adjusted by Mayer Multiple (price / 200-day MA)
  • Permissionless execution: Anyone can trigger due orders (keeper earns fee)
  • AVNU integration: Optimal routing computed off-chain

Architecture

User → create_order() → Deposit sell tokens

    Order Storage

[Time passes]

Keeper → execute_order() → Pragma Oracle (spot + TWAP)
          ↓                      ↓
    Mayer Multiple          Adjust amount
          ↓                      ↓
    AVNU Swap → WBTC → User

Mayer Multiple Strategy

The Mayer Multiple measures current BTC price against its 200-day moving average:
Mayer Multiple = Spot Price / 200-day TWAP

Buy Multipliers

Mayer MultipleMarket ConditionBuy MultiplierStrategy
< 0.8Very Cheap1.5xAccumulate aggressively
0.8 - 1.0Below Average1.25xAccumulate moderately
1.0 - 1.5Normal1.0xStandard DCA
1.5 - 2.0Expensive0.75xReduce exposure
> 2.0Overheated0.5xMinimal buys
Users deposit 1.5x the total order amount to cover maximum multiplier scenarios.

Core Functions

create_order

Create a new DCA order.
fn create_order(
    ref self: ContractState,
    sell_token: ContractAddress,
    sell_amount_per: u256,
    frequency: u64,
    total_orders: u32,
    smart: bool,
) -> u64
sell_token
ContractAddress
required
Token to sell for BTC (ETH, USDC, STRK, USDT)
sell_amount_per
u256
required
Base amount per execution (before multiplier)
frequency
u64
required
Seconds between executions (min: 3600 = 1 hour)
total_orders
u32
required
Number of planned executions (1-365)
smart
bool
required
Enable Mayer Multiple adjustment
  1. Validate parameters (frequency ≥ 1 hour, total_orders ≤ 365)
  2. Calculate total deposit:
    • Standard: sell_amount_per * total_orders
    • Smart: sell_amount_per * total_orders * 1.5
  3. Transfer sell tokens from caller to contract
  4. Assign order_id and store order details
  5. Set next_exec to current timestamp (first execution due immediately)
  6. Emit OrderCreated event
Returns: order_id (uint64)

execute_order

Execute a due DCA order. Callable by anyone (keeper).
fn execute_order(
    ref self: ContractState,
    order_id: u64,
    min_btc_out: u256,
    routes: Array<Route>,
)
order_id
u64
required
Order ID to execute
min_btc_out
u256
required
Minimum WBTC output (slippage protection)
routes
Array<Route>
required
AVNU swap routes (computed off-chain)
  1. Verify order is active and due (block.timestamp ≥ next_exec)
  2. Read base amount and smart flag from storage
  3. If smart: query Pragma Oracle for spot price and 200-day TWAP
  4. Calculate Mayer Multiple: (spot * 1e8) / twap
  5. Determine multiplier from Mayer Multiple band
  6. Compute adjusted amount: (base_amount * multiplier) / 100
  7. Deduct keeper fee: (adjusted_amount * keeper_fee_bps) / 10000
  8. Approve AVNU and execute swap
  9. Transfer WBTC to order owner
  10. Pay keeper fee in sell token
  11. Update order state (executed count, spent, btc_received)
  12. Schedule next execution or mark complete
Keepers must compute AVNU routes off-chain using the AVNU API. Invalid routes will cause transaction revert.

cancel_order

Cancel an active order and refund remaining tokens.
fn cancel_order(ref self: ContractState, order_id: u64)
  1. Verify caller is order owner
  2. Verify order is active
  3. Mark order inactive
  4. Calculate refund: deposited - spent
  5. Transfer refund to owner
  6. Emit OrderCancelled event

top_up_order

Add more sell tokens to an existing order.
fn top_up_order(ref self: ContractState, order_id: u64, amount: u256)
Use this to extend an order’s runway if deposited funds run low before all executions complete.

Storage

Order Fields (Flat Maps)

order_owner: Map<u64, ContractAddress>
order_sell_token: Map<u64, ContractAddress>
order_sell_amount: Map<u64, u256>        // Base per-execution amount
order_frequency: Map<u64, u64>           // Seconds between executions
order_total: Map<u64, u32>               // Total planned executions
order_executed: Map<u64, u32>            // Completed count
order_next_exec: Map<u64, u64>           // Next execution timestamp
order_active: Map<u64, bool>
order_smart: Map<u64, bool>              // Use Mayer Multiple
order_deposited: Map<u64, u256>          // Total sell tokens deposited
order_spent: Map<u64, u256>              // Total sell tokens used
order_btc_received: Map<u64, u256>       // Total WBTC received
Starknet storage doesn’t support structs in maps, so order fields are stored in separate flat maps keyed by order_id.

Mayer Multiple Implementation

Query Oracles

fn _get_mayer_multiple(self: @ContractState) -> (u128, u128, u128) {
    let oracle_addr: ContractAddress = addresses::PRAGMA_ORACLE.try_into().unwrap();
    let oracle = IPragmaOracleDispatcher { contract_address: oracle_addr };

    // Get spot price
    let pair_id = addresses::PRAGMA_BTC_USD_PAIR;
    let spot_response = oracle.get_data_median(DataType::SpotEntry(pair_id));
    let spot_price = spot_response.price;

    // Get 200-day TWAP from Summary Stats
    let stats_addr: ContractAddress = addresses::PRAGMA_SUMMARY_STATS.try_into().unwrap();
    let stats = ISummaryStatsDispatcher { contract_address: stats_addr };

    let now = get_block_timestamp();
    let start_time = now - TWAP_WINDOW;  // 200 days in seconds

    let (twap_price, twap_decimals) = stats.calculate_twap(
        DataType::SpotEntry(pair_id),
        AggregationMode::Median,
        now,
        start_time,
    );

    // Normalize to 1e8 scale
    let spot_normalized = normalize_to_scale(spot_price, spot_decimals, 8);
    let twap_normalized = normalize_to_scale(twap_price, twap_decimals, 8);

    // Mayer Multiple = (spot / twap) * 1e8
    let mayer_multiple = (spot_normalized * SCALE) / twap_normalized;

    (spot_normalized, twap_normalized, mayer_multiple)
}

Multiplier Lookup

fn _get_multiplier(self: @ContractState, mm: u128) -> u256 {
    if mm < MM_BAND_1 {        // < 0.8
        MULT_VERY_CHEAP        // 150 (1.5x)
    } else if mm < MM_BAND_2 { // 0.8-1.0
        MULT_CHEAP             // 125 (1.25x)
    } else if mm < MM_BAND_3 { // 1.0-1.5
        MULT_NORMAL            // 100 (1.0x)
    } else if mm < MM_BAND_4 { // 1.5-2.0
        MULT_EXPENSIVE         // 75 (0.75x)
    } else {                   // > 2.0
        MULT_OVERHEATED        // 50 (0.5x)
    }
}

Events

OrderCreated

pub struct OrderCreated {
    pub order_id: u64,         // [key]
    pub owner: ContractAddress, // [key]
    pub sell_token: ContractAddress,
    pub sell_amount_per: u256,
    pub frequency: u64,
    pub total_orders: u32,
    pub smart: bool,
    pub total_deposited: u256,
}

OrderExecuted

pub struct OrderExecuted {
    pub order_id: u64,         // [key]
    pub keeper: ContractAddress, // [key]
    pub sell_amount: u256,
    pub btc_received: u256,
    pub keeper_fee: u256,
    pub mayer_multiple: u128,
    pub execution_number: u32,
}

OrderCompleted

pub struct OrderCompleted {
    pub order_id: u64,         // [key]
    pub total_spent: u256,
    pub total_btc: u256,
}

View Functions

fn get_order(self: @ContractState, order_id: u64) -> (
    ContractAddress, // owner
    ContractAddress, // sell_token
    u256,            // sell_amount_per
    u64,             // frequency
    u32,             // total
    u32,             // executed
    u64,             // next_exec
    bool,            // active
    bool,            // smart
    u256,            // deposited
    u256,            // spent
    u256,            // btc_received
)

fn get_mayer_multiple(self: @ContractState) -> (u128, u128, u128)  // (spot, twap, mm)
fn get_next_order_id(self: @ContractState) -> u64
fn get_keeper_fee_bps(self: @ContractState) -> u16

Configuration

Constructor

fn constructor(
    ref self: ContractState,
    owner: ContractAddress,
    keeper_fee_bps: u16,  // Default: 10 (0.1%)
)

Parameters

fn set_keeper_fee(ref self: ContractState, new_fee_bps: u16)  // Max: 100 (1%)
fn transfer_ownership(ref self: ContractState, new_owner: ContractAddress)
fn upgrade(ref self: ContractState, new_class_hash: ClassHash)

Keeper Economics

Fee Model

  • Keeper earns keeper_fee_bps of adjusted sell amount (not base amount)
  • Default: 10 bps (0.1%)
  • Fee paid in sell token, not WBTC
  • Example: 100 USDC order → keeper earns 0.1 USDC

Execution Criteria

assert(self.order_active.read(order_id), 'Order not active');
assert(now >= next_exec, 'Not yet due');
assert(executed < total, 'Order complete');
Keepers can monitor OrderCreated events and schedule executions based on next_exec timestamps. No mempool competition — first keeper to execute wins the fee.

Integration Example

use btcvault::dca::{ISmartDcaDispatcher, ISmartDcaDispatcherTrait};
use btcvault::interfaces::Route;

// User creates Smart DCA order
let dca = ISmartDcaDispatcher { contract_address: dca_addr };
let usdc = IERC20Dispatcher { contract_address: USDC };

// Approve 1.5x total (for max multiplier)
let total_deposit = 100_000_000 * 30 * 15 / 10;  // 100 USDC * 30 orders * 1.5
usdc.approve(dca_addr, total_deposit);

let order_id = dca.create_order(
    sell_token: USDC,
    sell_amount_per: 100_000_000,  // 100 USDC (6 decimals)
    frequency: 86400,               // Daily
    total_orders: 30,               // 30 days
    smart: true,                    // Enable Mayer Multiple
);

// Keeper executes order when due
let (_, _, _, _, _, _, next_exec, active, _, _, _, _) = dca.get_order(order_id);
assert(block.timestamp >= next_exec, 'Not due');
assert(active, 'Not active');

let routes = compute_avnu_routes(USDC, WBTC, 100_000_000);  // Off-chain
dca.execute_order(
    order_id: order_id,
    min_btc_out: 50_000,  // 0.0005 WBTC min (slippage)
    routes: routes,
);

AVNU Route Computation

import axios from 'axios';

const response = await axios.get('https://starknet.api.avnu.fi/swap/v1/quotes', {
  params: {
    sellTokenAddress: USDC_ADDRESS,
    buyTokenAddress: WBTC_ADDRESS,
    sellAmount: '100000000',  // 100 USDC
  },
});

const routes = response.data.routes.map((r: any) => ({
  token_from: r.sellTokenAddress,
  token_to: r.buyTokenAddress,
  exchange_address: r.exchangeAddress,
  percent: r.percent,
  additional_swap_params: r.additionalSwapParams,
}));

Security Considerations

Smart DCA relies on Pragma Oracle for spot price and Summary Stats for TWAP. If oracles fail, execution reverts. Standard DCA orders continue unaffected.
Keeper must provide min_btc_out to protect against sandwich attacks. Too high = revert, too low = user loses value.
First keeper to execute a due order wins the fee. No mempool competition (Starknet uses sequencer ordering).
Funds are locked in the contract until execution or cancellation. No emergency withdraw by owner (non-custodial).

See Also

Build docs developers (and LLMs) love