Appearance
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/
| Version | Implementation | Notes |
|---|---|---|
| V1 | (fetched from Arbiscan) | Phantom-credit era. Source was not in the repo until 2026-07-03 |
| V2 | 0xC804e3adA5d25FF4555f3B9f79fC035357f6bDF1 | Measured delta + auto-claim |
| V2.1 | 0x930a2b7F1B123B11d402bEfb6E1100f465082aD9 | One-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:
| Field | What it means |
|---|---|
interval | The total window in seconds — not the gap between orders |
frequency | The order count — not a rate |
| Order spacing | interval / frequency |
| Next due | depositTime + (interval × processed / frequency) |
positionId | Unique 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:
| Function | Purpose |
|---|---|
CreatePositionAutoBuy(...) payable | Recurring buys. V2.1: excess msg.value above the budget is treated as auto-claim escrow, making create-plus-opt-in a single signature |
CreatePositionDepositOnly(...) payable | Deposit without the buy schedule |
savePosition and updateUserPosition maintain the position record and the withdrawable balance.
Execution
| Function | Caller | Purpose |
|---|---|---|
batchExecuteDeposit(...) | onlyOwnerOrExecutor | The cron's entry point. Processes many positions per transaction |
executeAutoBuy(...) | internal | One buy, with the measured-delta credit |
executeDepositOnly(...) | internal | Deposit-only variant |
executeMetaTransactionSwap(...) | internal | Performs 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:
| Function | Access | Purpose |
|---|---|---|
enableAutoClaim(positionId) payable | user | Escrow gas for future claims |
disableAutoClaim(positionId) | user | Refund the unused escrow |
autoClaimFor(user, positionId, …) | onlyOwnerOrExecutor | Deliver a claim directly to the user, deducting autoClaimFeePerClaim from their escrow into platformEarnings |
setAutoClaimFee(uint256) | onlyOwner | Tune the fee |
withdrawEarnings() | onlyOwner | Sweep 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
| Function | Access |
|---|---|
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
| Function | Purpose |
|---|---|
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() | |
_authorizeUpgrade | onlyOwner |
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:
| Bug | Fix |
|---|---|
| ETH-target cancel reverted | Corrected payout path |
.transfer gas stipend broke smart-contract wallets | Replaced with .call via _payout |
executor role existed but was unusable | onlyOwnerOrExecutor |
frequency = 0 accepted | Rejected |
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.jsonKnown limits
- Arbitrum only. One deployment.
- A small orphaned surplus remains from the V1 era. Sweepable with
withdrawEth/withdrawTokenat any time. higestPositionis 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.
feeAutoBuyis 0. CSIP itself is free; only auto-claim charges.- No per-position pause. Only a global
pause()and per-position cancel.
Related
- Product behaviour and the cron: CSIP / DCA
- Route selection: execution engine
- Upgrade procedure: contracts & upgrades