Skip to content

DCA contract

The CSIP vault. A user deposits a total budget; the platform executor buys on schedule; the vault credits the output to the user's position.

Contract name: DCAInvestmentV2 · Proxy: 0x998D4f9A36d196c762C6A646733E63d9eD1b980D (Arbitrum)
Pattern: UUPS · Source: bz-backend/contract-deployment/contracts/dca/

VersionImplementationNotes
V1(fetched from Arbiscan)Phantom-credit era. Source was not in the repo until 2026-07-03
V20xC804e3adA5d25FF4555f3B9f79fC035357f6bDF1Measured delta + auto-claim
V2.10x930a2b7F1B123B11d402bEfb6E1100f465082aD9One-signature creation. Current

Owner is the executor wallet 0x5935745431D1385ae1a9CE2644fb5f5d1f140459 (PRIVATE_KEY_EXECUTOR).

Semantics

The field names are actively misleading and this is the single most important thing to internalise:

FieldWhat it means
intervalThe total window in seconds — not the gap between orders
frequencyThe order count — not a rate
Order spacinginterval / frequency
Next duedepositTime + (interval × processed / frequency)
positionIdUnique per wallet (userPosition[user][positionId]), not globally

duration = perOrderGap × orders

The frontend must send the total window. Sending the per-order gap makes buys run orders× too fast. This shipped once and had to be fixed in defi-dex/src/component/defi/csip/.

Storage layout

solidity
// ───── V1 storage (do not touch, slots 0–7) ─────
address public executor;
uint256 public feeAutoBuy;          // 0
uint256 public feeDepositOnly;
address public wEthAddress;
mapping(address => mapping(uint256 => Investment)) public userPosition;
mapping(address => mapping(uint256 => uint256)) public userWithdrawablePerPosition;
mapping(address => mapping(uint256 => uint256)) public userClaimedAmountPerPosition;
mapping(address => uint256) public higestPosition;   // typo is in the source

// ───── V2 storage (append only) ─────
mapping(address => mapping(uint256 => uint256)) public autoClaimPrepaid;
uint256 public autoClaimFeePerClaim;                 // ≈ 0.00003 ETH
uint256 public platformEarnings;

Slots 0–7 replicate V1 exactly. Never reorder.

The upgrade was formally validated with upgrades.validateUpgrade(V1 → V2) and state was preserved on mainnet. Any V3 must append only.

The measured-delta fix

This is the heart of V2:

solidity
function executeAutoBuy(WithdrawParams memory p, Investment memory position) private {
    uint256 balanceBefore = _selfBalance(position.toToken);
    executeMetaTransactionSwap(p, position);
    uint256 received = _selfBalance(position.toToken) - balanceBefore;

    if (received < p.returnTokenValue)
        revert OutputBelowMinimum(received, p.returnTokenValue);

    updateUserPosition(p.user, p.positionId, received, position.interestRate);
}

returnTokenValue is now purely a minimum-output floor. The user is credited received — what actually arrived.

What V1 did instead

V1 credited returnTokenValue directly. Since the cron set returnTokenValue = 0.995 × quote as revert protection, every buy leaked 0.5% of price into surplus that accumulated uncredited in the contract. Combined with providers returning an approve() step (which the contract executed as a no-op and then phantom-credited in full), accounting could exceed actual holdings.

The consequences persist:

Claim is clamped to the contract's payable balance

DcaManager.claim(positionId, toToken) on the frontend clamps to what the contract actually holds, because production accounting can still exceed holdings from the V1 era. A partial claim keeps the remainder credited, so the user claims twice rather than losing anything.

Position creation

Two entry points:

FunctionPurpose
CreatePositionAutoBuy(...) payableRecurring buys. V2.1: excess msg.value above the budget is treated as auto-claim escrow, making create-plus-opt-in a single signature
CreatePositionDepositOnly(...) payableDeposit without the buy schedule

savePosition and updateUserPosition maintain the position record and the withdrawable balance.

Execution

FunctionCallerPurpose
batchExecuteDeposit(...)onlyOwnerOrExecutorThe cron's entry point. Processes many positions per transaction
executeAutoBuy(...)internalOne buy, with the measured-delta credit
executeDepositOnly(...)internalDeposit-only variant
executeMetaTransactionSwap(...)internalPerforms the actual router call

This contract has its own executeMetaTransactionSwap

It is not BlazpayRelayer's. The DCA vault calls the aggregator router itself, because output must land in the vault so the balance delta can be measured. Automation does not route through the relayer at all — see system overview.

Auto-claim

The V2 feature set:

FunctionAccessPurpose
enableAutoClaim(positionId) payableuserEscrow gas for future claims
disableAutoClaim(positionId)userRefund the unused escrow
autoClaimFor(user, positionId, …)onlyOwnerOrExecutorDeliver a claim directly to the user, deducting autoClaimFeePerClaim from their escrow into platformEarnings
setAutoClaimFee(uint256)onlyOwnerTune the fee
withdrawEarnings()onlyOwnerSweep platformEarnings

_refundPrepaid and _refundPrepaidTo handle escrow return on cancel and disable.

The fee is set to 0.00003 ETH, roughly 3× the executor's real gas cost on Arbitrum. The margin is the platform's revenue on this feature.

autoClaimFor pays the position owner, not the caller — which is what keeps it non-custodial despite being executor-triggered.

Claims and withdrawal

FunctionAccess
withdrawWithPositionId(positionId, amount)position owner
withdrawExactAmount(amount)position owner
removePosition(positionId)position owner
getPositionsAndAmount(...)view
getInvestments(...)view

withdrawWithPositionId checks higestPosition[msg.sender] >= positionId and reverts PositionNotFound otherwise — this is where the per-wallet positionId semantics are enforced. When the withdrawn amount equals the full withdrawable balance, the position status flips.

Owner operations

FunctionPurpose
setExecutor(address)Rotate the executor key
setFee(...)feeAutoBuy is currently 0
withdrawEth(uint256), withdrawToken(address, uint256)Sweep — used for the V1-era orphaned surplus
pause() / unpause()
_authorizeUpgradeonlyOwner

Errors

solidity
error OutputBelowMinimum(uint256 actual, uint256 minimum);
error NotAuthorized(address caller);
error NothingToClaim(address user, uint256 positionId);
error PrepaidExhausted(address user, uint256 positionId);
error PositionNotFound(uint256 positionId);

OutputBelowMinimum is the one you will see most. It usually means the route was an intent/solver provider that delivered nothing in-transaction — which is what route probing prevents.

Access control

solidity
modifier onlyOwnerOrExecutor() {
    if (msg.sender != owner() && msg.sender != executor)
        revert NotAuthorized(msg.sender);
    _;
}

V1 had executor in storage but nothing used it — everything was onlyOwner. V2 made the role functional. Today owner and executor happen to be the same address, but the separation exists so the executor key can be rotated without touching upgrade authority.

V2's V1 bug fixes

Beyond the measured delta:

BugFix
ETH-target cancel revertedCorrected payout path
.transfer gas stipend broke smart-contract walletsReplaced with .call via _payout
executor role existed but was unusableonlyOwnerOrExecutor
frequency = 0 acceptedRejected

Contract ABIs

Kept in sync in two places, both refreshed after each upgrade:

bz-backend/utils/abi/dcaAbi.json
defi-dex/src/constants/abis/dca.json

Known limits

  • Arbitrum only. One deployment.
  • A small orphaned surplus remains from the V1 era. Sweepable with withdrawEth / withdrawToken at any time.
  • higestPosition is misspelled in storage and cannot be renamed.
  • Owner and executor are the same EOA, so a compromised executor also has upgrade authority. Separating them is the obvious hardening.
  • feeAutoBuy is 0. CSIP itself is free; only auto-claim charges.
  • No per-position pause. Only a global pause() and per-position cancel.

Internal + developer documentation. Usage figures are point-in-time snapshots — re-verify before publishing them.