Skip to content

CSIP / DCA

Recurring on-chain buys. Pick an amount and an interval; the schedule executes without the user showing up.

Route: /defi/csip · Contract: 0x998D4f9A36d196c762C6A646733E63d9eD1b980D (Arbitrum) · Fee: none, except optional auto-claim

CSIP and DCA are the same product

The UI says CSIP. The code, models, contract and API all say DCA. There is no difference.

What it does

The user deposits a total budget into a vault contract, choosing how many orders to split it into and over what period. A platform executor buys the target token on schedule, and the output is credited to the user's position inside the vault. The user claims it — or opts into auto-claim and it arrives automatically.

The vault is user-owned. The executor can trigger buys; it cannot withdraw.

Contract semantics — read this before touching the code

The field names are actively misleading.

FieldWhat it actually 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, not globally

The CSIP form must send duration = perOrderGap × orders

Sending the per-order gap as the total window makes buys run orders× too fast. This bug shipped once. The fix is in defi-dex/src/component/defi/csip/.

Versions and what each fixed

The contract is UUPS-upgradeable and has been through three implementations.

V1 — the phantom-credit era

V1's executeAutoBuy credited the executor-supplied returnTokenValue instead of the actual swap output. The cron set returnTokenValue = 0.995 × quote as revert protection, so every buy leaked 0.5% of price into surplus that accumulated uncredited in the contract.

Worse, one provider returned an approve() step as its transaction data. The contract executed it (a no-op for the user) and phantom-credited the full expected amount, so accounting could exceed actual holdings.

The V1 source was not in the repo. It was fetched from Arbiscan and is now vendored at bz-backend/contract-deployment/contracts/dca/.

V2 — measured delta + auto-claim

Implementation 0xC804e3adA5d25FF4555f3B9f79fC035357f6bDF1, upgraded 2026-07-03. Storage layout formally validated with upgrades.validateUpgrade; state preserved.

ChangeEffect
Credit the measured balance deltareturnTokenValue becomes a floor only. The slippage buffer stops being a user haircut
enableAutoClaim (payable escrow) / disableAutoClaim (refund) / autoClaimFor (onlyOwnerOrExecutor, pays the user directly)Auto-delivery, funded by escrow
autoClaimFeePerClaim, platformEarnings, withdrawEarningsFee set to 0.00003 ETH ≈ 3× gas
ETH-target cancel no longer revertsV1 bug
.transfer.callGas-stipend safety
Executor role usableV1 bug
frequency = 0 rejectedV1 bug

V2.1 — one-signature creation

Implementation 0x930a2b7F1B123B11d402bEfb6E1100f465082aD9. CreatePositionAutoBuy treats excess msg.value as auto-claim escrow, so create-plus-opt-in is a single signature. The frontend passes fee × orders as extra value. enableAutoClaim remains for existing positions and top-ups.

Proven end-to-end on mainnet 2026-07-03: a probed route executed, credited the exact measured delta (0.000866… ETH), autoClaimFor delivered to the user's wallet, and platformEarnings accrued its first 0.00003 ETH.

The executor cron

bz-backend/services/dcaCron.js (cronBatchDeposit), every minute, gated on CRON != 'false'. It reads investments with status: "success", batches per chain, quotes via swap-sdk, and calls batchExecuteDeposit.

Hardening it has accumulated — each item is a bug that reached production:

GuardProblem it solves
Explicit gasPrice = getGasPrice() × 2ethers' default 1.5-gwei tip inflated the Arbitrum gas reserve by ~77×
Multi-quote fallback (top 3 candidates)Aggregator transaction-data 500s are common; Odos in particular is flaky
Calldata guard — reject tx.to == fromToken or data.length <= 68A provider returning an approve() step, which the contract executed and phantom-credited
returnTokenValue = 0.995 × quoteRevert protection floor. Post-V2 this costs the user nothing
Route probingestimateGas.batchExecuteDeposit([single item]) per candidateRejects intent/solver providers (Relay's token-transfer calldata, Nordstern's async fill that delivers zero in-transaction) which V2 correctly reverts on
Per-position try/catch with lastError / lastAttemptAt on the docOne bad position no longer kills the batch; the UI shows an amber "retrying" state
On-chain self-heal sync each tickMirrors processed, status, depositTime, interval from chain to DB, so another executor's work is not lost
Fills recorded to models/DcaFill.js, unique (txHash, positionId)Auditable per-fill history

processAutoClaims runs after each batch.

API

PurposeEndpoint
Create a position recordPOST /defi/create-position — validates a $1 minimum per order in USD
UpdatePOST /defi/update-position/:dcaId
ListGET /defi/get-position?status=all|active|completed|cancelled
FillsGET /defi/dca-fills
CancelPOST /defi/cancel-dca
ClaimPOST /defi/claim-dca

Claim and cancel transactions are client-side; the backend only flips status. Full detail: DCA API reference.

Frontend behaviour

defi-dex/src/component/defi/csip/ plus swapAI/Dca/ for the chat card.

  • pages/defi/csip.tsx holds the nav and focusPositionId, which auto-opens a newly created position.
  • Position detail polls on-chain analytics every 10 s via DcaManager.getPositionAnalytics.
  • The list polls every 15 s and raises a toast when an order executes.
  • NextRunCountdown shows a live countdown and spinner states.
  • dcaUi.ts holds cadenceLabel and explorerTxUrl helpers.

Claim is clamped to the contract's payable balance

DcaManager.claim(positionId, toToken) clamps to what the contract actually holds, because production accounting can exceed holdings thanks to V1 phantom credits. A partial claim keeps the remainder credited, so the user is not short-changed — they just claim twice.

In BlazAI

The DCA intent renders Dca/Dca (action dca). v1/services/defi.service.js → getDCAData maps the agent's fields to contract semantics:

Agent fieldMeaningMaps to
frequencyinterval multipliervia unitToSeconds
intervalunit (day/week/month)via unitToSeconds
occurrenceorder countcontract frequency

Unresolved semantic mismatch

The agent template says DCA amount is per-cycle, but the app deposits it as the total. A user saying "buy $50 of ETH weekly for 10 weeks" may get a $50 total rather than $500. This is a known open item.

autoInvest no longer hardcodes duration: 10.

Known limits

  • Arbitrum only. One contract, one chain.
  • The agent amount semantic mismatch above.
  • A small orphaned surplus remains in the contract from the V1 era. It can be swept with owner withdrawEth / withdrawToken at any time.
  • Executor gas. Scheduled buys stop when PRIVATE_KEY_EXECUTOR runs dry. Consumption is ~0.00002 ETH per batch on Arbitrum after the gas-price fix.
  • The frontend's swap-sdk pin lags the backend's (quote-display precision only).
  • positionId is per-wallet. Any query that treats it as a global key is wrong.

Recovery precedent

On 2026-07-03, funds stranded by V1 accounting were recovered manually with the owner key: one position's 0.000134 ETH was sent to the user directly; another's 0.0011 WETH was withdrawn, swapped to 1.903 USDC via Uniswap SwapRouter02 (0x68b3…Fc45, fee tier 500), and 1.29397 was sent back into the DCA contract to fund the user's credited remainder so it became claimable again.

A production DB cleanup the same day (user-approved) reclassified 13 dead pending rows with no positionId and 38 stale/dust/malformed success rows to failed, keeping 4 legitimate actives. The cron went from scanning 42+ positions per minute to 4.

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