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 Multiple Market Condition Buy Multiplier Strategy < 0.8 Very Cheap 1.5x Accumulate aggressively 0.8 - 1.0 Below Average 1.25x Accumulate moderately 1.0 - 1.5 Normal 1.0x Standard DCA 1.5 - 2.0 Expensive 0.75x Reduce exposure > 2.0 Overheated 0.5x Minimal 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
Token to sell for BTC (ETH, USDC, STRK, USDT)
Base amount per execution (before multiplier)
Seconds between executions (min: 3600 = 1 hour)
Number of planned executions (1-365)
Enable Mayer Multiple adjustment
Validate parameters (frequency ≥ 1 hour, total_orders ≤ 365)
Calculate total deposit:
Standard: sell_amount_per * total_orders
Smart: sell_amount_per * total_orders * 1.5
Transfer sell tokens from caller to contract
Assign order_id and store order details
Set next_exec to current timestamp (first execution due immediately)
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>,
)
Minimum WBTC output (slippage protection)
AVNU swap routes (computed off-chain)
Verify order is active and due (block.timestamp ≥ next_exec)
Read base amount and smart flag from storage
If smart: query Pragma Oracle for spot price and 200-day TWAP
Calculate Mayer Multiple: (spot * 1e8) / twap
Determine multiplier from Mayer Multiple band
Compute adjusted amount: (base_amount * multiplier) / 100
Deduct keeper fee: (adjusted_amount * keeper_fee_bps) / 10000
Approve AVNU and execute swap
Transfer WBTC to order owner
Pay keeper fee in sell token
Update order state (executed count, spent, btc_received)
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)
Verify caller is order owner
Verify order is active
Mark order inactive
Calculate refund: deposited - spent
Transfer refund to owner
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
AVNU API (TypeScript)
AVNU API (Python)
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