Appearance
DCA API
CSIP positions. All under /api/defi. Controller: bz-backend/controllers/defi/DcaController.js.
These routes are unauthenticated
The walletAddress in the body or query is trusted. That is acceptable here because funds only move via user-signed transactions — claim and cancel are client-side, and the backend only flips status. Unlike the Autopilot routes, a forged request cannot redirect value.
Product page: CSIP / DCA. Contract: DCA contract.
POST /defi/create-position
Creates the off-chain record. The on-chain position is created separately by the user's own CreatePositionAutoBuy transaction.
Body
| Field | Notes |
|---|---|
fromToken | Address of the token being spent |
toToken | Address of the token being bought |
amount | Total budget, not per-order |
frequency | Order count |
interval | The total window in seconds — stored as duration |
walletAddress | Owner |
chainId | An object — the controller reads chainId?.chainId |
isAutoBuy | Boolean |
timeFrame | Display cadence |
chainId is nested
createDcaRecord reads chainId?.chainId, so the body carries an object ({ chainId: 42161, … }), not a bare number. Sending a number silently resolves to undefined and both token lookups fail.
interval is the total window, and frequency is the count
The frontend must send interval = perOrderGap × orders. Sending the gap makes buys run orders× too fast — a bug that shipped once. The contract computes spacing as interval / frequency. See semantics.
Validation
Token resolution. Both tokens are looked up by (chainId, address). A miss returns 400 with "{From|To} token not found for the given chain".
Minimum order size. If frequency > 0 and fromToken.coingeckoId exists:
js
perOrderUsd = (amount / frequency) × price
if (perOrderUsd > 0 && perOrderUsd < MIN_ORDER_USD) → 400Message: Each order is only ~$X. Minimum is ~$Y per order so it can be executed.
Why the minimum exists, and why it is skipped on price outages
A per-order value below the minimum will not get a swap route from any aggregator, so the position would be created and then never execute — the worst possible outcome. The check is deliberately skipped when the price cannot be resolved, so a CoinGecko outage does not block creation.
Response
201 with the created DcaInvestment document.
The stored shape renames two fields:
js
{ fromToken: ObjectId, toToken: ObjectId, walletAddress,
processed: 0, isAutoBuy, frequency,
duration: interval, // ← `interval` becomes `duration`
chainId: chainId?.chainId, // ← unwrapped
amount, timeFrame }POST /defi/update-position/:dcaId
Applies req.body to the document with updateOne. No field whitelist.
No field validation
The whole body is merged. A typo creates a stray field; a wrong frequency or duration desynchronises the record from the chain — though the cron's on-chain self-heal sync will overwrite processed, status, depositTime and interval on the next tick.
Returns 201 with { success: true }.
GET /defi/get-position
| Query | Notes |
|---|---|
walletAddress | Required |
status | all | active | completed | cancelled |
GET /defi/dca-fills
| Query | Notes |
|---|---|
walletAddress | Required |
positionId | Optional; coerced to Number. Empty string means "all" |
Returns up to 200 fills, newest first (sort({ _id: -1 })), with fromToken and toToken populated.
Model: models/DcaFill.js, unique on (txHash, positionId).
positionId is unique per wallet, not globally
userPosition[user][positionId] in the contract. A query on positionId alone spans wallets.
POST /defi/cancel-dca
Flips the record's status. The on-chain cancellation is a client-side transaction (removePosition) that the frontend sends separately.
POST /defi/claim-dca
Same pattern — status only. The claim itself is DcaManager.claim(positionId, toToken) from the frontend.
Claim is clamped to the contract's payable balance
Production accounting can exceed actual holdings because of V1-era phantom credits. The frontend clamps the claim amount, so a partial claim keeps the remainder credited and the user claims twice rather than losing anything.
What the API does not do
| Action | Where it happens |
|---|---|
| Create the on-chain position | User's CreatePositionAutoBuy transaction |
| Execute a scheduled buy | services/dcaCron.js, every minute |
| Credit output | The contract, from the measured balance delta |
| Claim | User's transaction, or autoClaimFor from the executor |
| Cancel on-chain | User's removePosition transaction |
| Enable/disable auto-claim | User's transaction (or excess msg.value at creation) |
The backend is a record keeper and a scheduler, not a custodian.
The executor cron
Not an endpoint, but the thing that makes the product work. cronBatchDeposit in services/dcaCron.js, every minute, gated on CRON != 'false'.
Per tick:
- Read investments with
status: 'success'. - On-chain self-heal sync — mirror
processed,status,depositTime,intervalfrom chain into Mongo (handles other executors). - Filter to due positions, group per chain.
getBestExecutableRoutewith the vault asdstWalletAddressand anestimateGasprobe.batchExecuteDeposit([...])withgasOverrides.- Record fills.
processAutoClaims().
Per-position try/catch writes lastError and lastAttemptAt (cleared on success, rendered as an amber "retrying" state).
Detail: execution engine.
Frontend polling
| Surface | Interval |
|---|---|
Position detail — on-chain analytics via DcaManager.getPositionAnalytics | 10 s |
| Position list — with executed-order toasts | 15 s |
In BlazAI
The DCA intent renders swapAI/Dca/Dca (action dca). v1/services/defi.service.js → getDCAData maps agent fields to contract semantics via unitToSeconds:
| Agent field | Meaning |
|---|---|
frequency | Interval multiplier |
interval | Unit (day / week / month) |
occurrence | Order count → contract frequency |
Known semantic mismatch
The agent template says DCA amount is per-cycle; the app deposits it as the total. A user asking for "$50 of ETH weekly for 10 weeks" may get a $50 total rather than $500. Unresolved.
Failure modes
| Symptom | Cause |
|---|---|
400 token not found for the given chain | chainId sent as a number instead of an object, or the token is not seeded |
400 Each order is only ~$X | Below the per-order minimum |
| Position created but never executes | CRON=false, executor out of gas, or no route available |
| Amber "retrying" state | lastError set — check the cron logs |
| Buys running too fast | interval sent as the per-order gap rather than the total window |
OutputBelowMinimum revert | The route was an intent/solver provider — the probe should have filtered it |
| Claim short of the accounting balance | V1-era phantom credits; the clamp is working as intended |