Skip to content

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

FieldNotes
fromTokenAddress of the token being spent
toTokenAddress of the token being bought
amountTotal budget, not per-order
frequencyOrder count
intervalThe total window in seconds — stored as duration
walletAddressOwner
chainIdAn object — the controller reads chainId?.chainId
isAutoBuyBoolean
timeFrameDisplay 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) → 400

Message: 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

QueryNotes
walletAddressRequired
statusall | active | completed | cancelled

GET /defi/dca-fills

QueryNotes
walletAddressRequired
positionIdOptional; 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

ActionWhere it happens
Create the on-chain positionUser's CreatePositionAutoBuy transaction
Execute a scheduled buyservices/dcaCron.js, every minute
Credit outputThe contract, from the measured balance delta
ClaimUser's transaction, or autoClaimFor from the executor
Cancel on-chainUser's removePosition transaction
Enable/disable auto-claimUser'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:

  1. Read investments with status: 'success'.
  2. On-chain self-heal sync — mirror processed, status, depositTime, interval from chain into Mongo (handles other executors).
  3. Filter to due positions, group per chain.
  4. getBestExecutableRoute with the vault as dstWalletAddress and an estimateGas probe.
  5. batchExecuteDeposit([...]) with gasOverrides.
  6. Record fills.
  7. processAutoClaims().

Per-position try/catch writes lastError and lastAttemptAt (cleared on success, rendered as an amber "retrying" state).

Detail: execution engine.

Frontend polling

SurfaceInterval
Position detail — on-chain analytics via DcaManager.getPositionAnalytics10 s
Position list — with executed-order toasts15 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 fieldMeaning
frequencyInterval multiplier
intervalUnit (day / week / month)
occurrenceOrder 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

SymptomCause
400 token not found for the given chainchainId sent as a number instead of an object, or the token is not seeded
400 Each order is only ~$XBelow the per-order minimum
Position created but never executesCRON=false, executor out of gas, or no route available
Amber "retrying" statelastError set — check the cron logs
Buys running too fastinterval sent as the per-order gap rather than the total window
OutputBelowMinimum revertThe route was an intent/solver provider — the probe should have filtered it
Claim short of the accounting balanceV1-era phantom credits; the clamp is working as intended

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