Appearance
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.
| Field | What it actually 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, 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.
| Change | Effect |
|---|---|
| Credit the measured balance delta | returnTokenValue 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, withdrawEarnings | Fee set to 0.00003 ETH ≈ 3× gas |
| ETH-target cancel no longer reverts | V1 bug |
.transfer → .call | Gas-stipend safety |
| Executor role usable | V1 bug |
frequency = 0 rejected | V1 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:
| Guard | Problem it solves |
|---|---|
Explicit gasPrice = getGasPrice() × 2 | ethers' 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 <= 68 | A provider returning an approve() step, which the contract executed and phantom-credited |
returnTokenValue = 0.995 × quote | Revert protection floor. Post-V2 this costs the user nothing |
Route probing — estimateGas.batchExecuteDeposit([single item]) per candidate | Rejects 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 doc | One bad position no longer kills the batch; the UI shows an amber "retrying" state |
| On-chain self-heal sync each tick | Mirrors 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
| Purpose | Endpoint |
|---|---|
| Create a position record | POST /defi/create-position — validates a $1 minimum per order in USD |
| Update | POST /defi/update-position/:dcaId |
| List | GET /defi/get-position?status=all|active|completed|cancelled |
| Fills | GET /defi/dca-fills |
| Cancel | POST /defi/cancel-dca |
| Claim | POST /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.tsxholds the nav andfocusPositionId, 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.
NextRunCountdownshows a live countdown and spinner states.dcaUi.tsholdscadenceLabelandexplorerTxUrlhelpers.
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 field | Meaning | Maps to |
|---|---|---|
frequency | interval multiplier | via unitToSeconds |
interval | unit (day/week/month) | via unitToSeconds |
occurrence | order count | contract 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
amountsemantic mismatch above. - A small orphaned surplus remains in the contract from the V1 era. It can be swept with owner
withdrawEth/withdrawTokenat any time. - Executor gas. Scheduled buys stop when
PRIVATE_KEY_EXECUTORruns dry. Consumption is ~0.00002 ETH per batch on Arbitrum after the gas-price fix. - The frontend's
swap-sdkpin lags the backend's (quote-display precision only). positionIdis 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.