Skip to content

Instantly share code, notes, and snippets.

@al-maisan
Last active February 19, 2026 08:40
Show Gist options
  • Select an option

  • Save al-maisan/30320008982aec0a95d49949176b3b48 to your computer and use it in GitHub Desktop.

Select an option

Save al-maisan/30320008982aec0a95d49949176b3b48 to your computer and use it in GitHub Desktop.
DCA as a service - cost estimates

DCA-as-a-Service: On-Chain Solution Breakdown

1. Terminology

Term Definition
PDA (Program Derived Address) A deterministic account address derived from a program ID and a set of seeds (e.g., ["campaign", user_wallet, campaign_id]). PDAs have no private key -- only the owning program can sign for them. Used to create program-controlled accounts and act as on-chain authorities.
ATA (Associated Token Account) The canonical SPL token account for a given wallet + mint pair, derived deterministically via the Associated Token Program. Every wallet has exactly one ATA per token mint. Address is derived from [wallet, token_program, mint].
SPL Token Solana's standard token program (equivalent to ERC-20 on Ethereum). Defines how fungible tokens are created, transferred, and managed. Each token balance is stored in a separate token account.
Token-2022 Solana's newer token program with extensions (transfer fees, confidential transfers, transfer hooks, etc.). Not fully backwards-compatible with the original SPL Token program.
Delegate An authority granted permission to transfer tokens from a token account on behalf of the owner, up to a specified allowance. Set via Approve / ApproveChecked. Only one delegate per token account at a time.
Allowance (Delegated Amount) The maximum number of tokens a delegate is authorized to transfer. Decremented on each transfer. Must be re-approved when exhausted.
Mint The on-chain account defining a token type (supply, decimals, authorities). Each token (USDC, WSOL, etc.) has a unique mint address.
Rent-Exempt Solana accounts must maintain a minimum SOL balance to avoid garbage collection. Accounts funded above this threshold are rent-exempt and persist indefinitely. Standard practice for all program accounts.
CPI (Cross-Program Invocation) A call from one Solana program to another within a single transaction. Used here for the program to invoke Jupiter's swap and the SPL Token program's transfer.
Anchor The most widely used Solana program framework. Provides account validation macros, serialization, and IDL generation. Assumed as the development framework for this project.
Jupiter Solana's leading DEX aggregator. Routes swaps across multiple liquidity venues (Raydium, Orca, etc.) to find optimal pricing. Exposes both an off-chain quote API and an on-chain swap program for CPI.
TukTuk A Solana-native task scheduling protocol. Registers crank tasks that fire on a schedule (e.g., daily). Used here to trigger execute_step at each campaign's cadence.
Cranker An off-chain agent (bot/backend) that submits transactions to trigger on-chain actions. In permissionless designs, anyone can act as a cranker. The cranker pays transaction fees.
Signer The wallet or keypair that authorizes a transaction. Solana transactions require at least one signer. Different instructions may require different signers (user for create, cranker for execute).
Bump Seed A single byte appended to PDA seeds to ensure the derived address falls off the Ed25519 curve (i.e., has no private key). Stored in the account for efficient re-derivation.
Lamports The smallest unit of SOL (1 SOL = 1,000,000,000 lamports). Also used generically for the smallest unit of any SPL token when discussing amounts.
Slot A ~400ms window in which a Solana validator produces a block. Transactions in the same slot are ordered by the leader. Relevant for race conditions (F-14).
IDL (Interface Definition Language) An Anchor-generated JSON schema describing a program's instructions, accounts, and types. Used by frontends and SDKs to build transactions.
MEV (Maximal Extractable Value) Profit extracted by reordering, inserting, or censoring transactions within a block. On Solana, manifests primarily as sandwich attacks on swaps.
Jito A Solana MEV infrastructure provider. Jito bundles allow transactions to be submitted atomically with tips to validators, providing some MEV protection.
DCA (Dollar-Cost Averaging) An investment strategy of splitting a total purchase into periodic equal-sized buys, reducing exposure to short-term price volatility.

2. Program Overview

A Solana program that manages non-custodial dollar-cost-averaging campaigns. Users delegate a capped SPL token allowance to a program-derived PDA. An automated cranker (TukTuk or permissionless) calls execute_step on schedule, which pulls funds via the delegate, swaps through Jupiter, and deposits output tokens into the user's ATA.


3. Account Structures

2.1 Campaign (PDA)

Seeds: ["campaign", user_wallet, campaign_id]

Field Type Description
bump u8 PDA bump seed
owner Pubkey User wallet (signer for create/cancel/pause/resume)
campaign_id u64 Unique per-user campaign index
input_mint Pubkey Token being spent (USDC, WSOL)
output_mint Pubkey Token being bought
funding_token_account Pubkey User-owned token account with delegate set to campaign PDA
destination_ata Pubkey User-owned ATA for output_mint
per_step_amount u64 Lamports/smallest-unit to spend per step
cadence_secs i64 Interval between steps (e.g., 86400 for daily)
steps_total u32 Total number of steps in campaign
steps_executed u32 Steps successfully completed
start_ts i64 Campaign start timestamp
next_due_ts i64 Next eligible execution time
status enum Active, Paused(reason), Cancelled, Completed, Error(code)
pause_reason enum None, DelegateOverwritten, AllowanceExhausted, InsufficientFunds, MaxRetriesExceeded, UserPaused
min_output_amount u64 Slippage floor per step (0 = no enforcement)
max_retries u8 Max retry attempts per step before auto-pause
retry_count u8 Current step retry counter (reset on success)
created_at i64 Creation timestamp
last_executed_at i64 Timestamp of last successful step
catch_up bool Whether missed steps should be executed back-to-back

2.2 Campaign Step Log (PDA, optional)

Seeds: ["step", campaign_pda, step_index]

Field Type Description
bump u8 PDA bump seed
campaign Pubkey Parent campaign
step_index u32 Step number (0-based)
input_amount u64 Amount spent
output_amount u64 Amount received
executed_at i64 Execution timestamp
route_label String (32) Jupiter route identifier/hash

This account enforces idempotency -- if the PDA for a given step_index already exists, the step cannot execute again.


4. Instructions

3.1 create_campaign

Signer: user wallet

Accounts:

  • user (signer, mut) -- pays for account creation
  • campaign (PDA, init)
  • funding_token_account (user-owned, token account for input_mint)
  • destination_ata (user-owned, ATA for output_mint -- create if missing)
  • input_mint
  • output_mint
  • system_program
  • token_program
  • associated_token_program

Args:

  • campaign_id: u64
  • per_step_amount: u64
  • cadence_secs: i64
  • steps_total: u32
  • min_output_amount: u64
  • max_retries: u8
  • catch_up: bool

Validations:

  • per_step_amount > 0
  • steps_total > 0
  • cadence_secs >= 60 (minimum 1 minute to prevent abuse)
  • funding_token_account.mint == input_mint
  • funding_token_account.owner == user
  • destination_ata.mint == output_mint
  • destination_ata.owner == user
  • Campaign PDA does not already exist

Effects:

  • Initializes campaign account with status Active
  • Sets next_due_ts = Clock::get().unix_timestamp (first step eligible immediately) or start_ts if provided
  • Emits CampaignCreated event

Note: This instruction does NOT set up the delegate. The user must call ApproveChecked as a separate SPL Token instruction (or bundle in same tx) to set delegate = campaign PDA with allowance = per_step_amount * steps_total.


3.2 execute_step

Signer: cranker (permissionless -- anyone can call this)

Accounts:

  • cranker (signer) -- pays tx fees
  • campaign (PDA, mut)
  • step_log (PDA, init) -- idempotency guard
  • funding_token_account (mut) -- user's token account, delegate = campaign PDA
  • campaign_pda_authority (campaign PDA used as delegate authority)
  • destination_ata (mut) -- user's ATA for output_mint
  • jupiter_program + Jupiter route accounts (variable)
  • token_program
  • system_program
  • clock (sysvar)

Args:

  • jupiter_route_data: Vec<u8> -- serialized Jupiter swap instruction data
  • min_out_amount: u64 -- from Jupiter quote, must be >= campaign's min_output_amount

Validations (ordered):

  1. campaign.status == Active
  2. Clock::get().unix_timestamp >= campaign.next_due_ts
  3. campaign.steps_executed < campaign.steps_total
  4. Step log PDA does not exist (idempotency)
  5. funding_token_account.delegate == campaign_pda_authority
  6. funding_token_account.delegated_amount >= campaign.per_step_amount
  7. funding_token_account.amount >= campaign.per_step_amount (actual balance)
  8. min_out_amount >= campaign.min_output_amount (slippage guard)
  9. destination_ata.owner == campaign.owner (output goes to user)
  10. destination_ata.mint == campaign.output_mint

Effects on success:

  • Transfers per_step_amount from funding_token_account via delegate authority
  • Executes Jupiter swap CPI
  • Deposits output into destination_ata
  • Creates step_log account with execution details
  • Increments steps_executed
  • Resets retry_count = 0
  • Sets last_executed_at = now
  • Updates next_due_ts:
    • If catch_up == true and steps are behind: next_due_ts = now (immediately eligible for next)
    • Otherwise: next_due_ts = next_due_ts + cadence_secs
  • If steps_executed == steps_total: set status to Completed
  • Emits StepExecuted event

Effects on failure (swap CPI fails):

  • Increments retry_count
  • If retry_count >= max_retries: set status to Paused(MaxRetriesExceeded)
  • Emits StepFailed event
  • Entire tx reverts (no partial state change) -- retry_count increment must happen in a separate monitoring tx or be tracked off-chain

Important design decision on retry_count: Since a failed CPI reverts the whole tx, retry_count cannot be incremented on-chain during a failed swap. Two options:

  • Option A (recommended): Track retry_count off-chain in the backend. The on-chain max_retries field is informational. Backend calls pause_campaign with reason MaxRetriesExceeded after exhausting retries.
  • Option B: Split into two instructions: prepare_step (locks funds, increments attempt counter) and finalize_step (completes swap). More complex but fully on-chain.

3.3 cancel_campaign

Signer: campaign owner

Accounts:

  • owner (signer)
  • campaign (PDA, mut)

Validations:

  • owner == campaign.owner
  • campaign.status != Cancelled and campaign.status != Completed

Effects:

  • Sets status to Cancelled
  • Emits CampaignCancelled event

Note: Does NOT revoke the delegate allowance. Frontend should bundle a Revoke instruction in the same tx, or prompt the user to revoke separately.


3.4 pause_campaign

Signer: campaign owner OR cranker (with reason constraint)

Accounts:

  • authority (signer)
  • campaign (PDA, mut)
  • funding_token_account (read-only, for validation)

Args:

  • reason: PauseReason

Validations:

  • If authority == campaign.owner: any reason allowed (including UserPaused)
  • If authority != campaign.owner: reason must be one of DelegateOverwritten, AllowanceExhausted, InsufficientFunds, MaxRetriesExceeded AND the corresponding condition must be verifiable on-chain:
    • DelegateOverwritten: funding_token_account.delegate != campaign_pda
    • AllowanceExhausted: funding_token_account.delegated_amount < campaign.per_step_amount
    • InsufficientFunds: funding_token_account.amount < campaign.per_step_amount
    • MaxRetriesExceeded: caller attests (backend authority check or separate PDA)
  • campaign.status == Active

Effects:

  • Sets status to Paused(reason)
  • Emits CampaignPaused event

3.5 resume_campaign

Signer: campaign owner

Accounts:

  • owner (signer)
  • campaign (PDA, mut)
  • funding_token_account (read-only, for validation)

Validations:

  • owner == campaign.owner
  • campaign.status == Paused(_)
  • Verify the pause condition is resolved:
    • funding_token_account.delegate == campaign_pda
    • funding_token_account.delegated_amount >= remaining_allowance_needed
    • funding_token_account.amount >= campaign.per_step_amount

Effects:

  • Sets status to Active
  • Resets retry_count = 0
  • Recalculates next_due_ts:
    • If catch_up == true: next_due_ts = now
    • If catch_up == false: next_due_ts = now + cadence_secs (skip missed steps)
  • Emits CampaignResumed event

3.6 close_campaign

Signer: campaign owner

Accounts:

  • owner (signer, mut) -- receives rent
  • campaign (PDA, mut, close)

Validations:

  • owner == campaign.owner
  • campaign.status == Cancelled or campaign.status == Completed

Effects:

  • Closes campaign account, returns rent to owner
  • Emits CampaignClosed event

5. Events

Event Fields Emitted by
CampaignCreated campaign_pda, owner, input_mint, output_mint, per_step_amount, steps_total, cadence_secs create_campaign
StepExecuted campaign_pda, step_index, input_amount, output_amount, route_label, timestamp execute_step
StepFailed campaign_pda, step_index, error_code, timestamp execute_step (off-chain emit via backend log)
CampaignPaused campaign_pda, reason, timestamp pause_campaign
CampaignResumed campaign_pda, timestamp resume_campaign
CampaignCancelled campaign_pda, timestamp cancel_campaign
CampaignCompleted campaign_pda, steps_executed, timestamp execute_step (when final step completes)
CampaignClosed campaign_pda, owner, timestamp close_campaign

6. Use Cases

UC-1: Create Campaign

Actor: User via frontend

Flow:

  1. User selects input token, output token, total amount, duration (days)
  2. Frontend derives: per_step_amount = total / days, steps_total = days, cadence_secs = 86400
  3. Frontend creates a dedicated funding token account for this campaign (avoids delegate conflicts with other campaigns or user activity)
  4. Frontend builds tx with:
    • create_campaign instruction
    • ApproveChecked on funding_token_account (delegate = campaign PDA, amount = total)
    • Create destination ATA if it doesn't exist
  5. User signs and submits

Ticket scope:

  • Program: create_campaign instruction handler + validation
  • Frontend: campaign creation form, tx builder, ATA creation logic

UC-2: Execute Step (Automated)

Actor: TukTuk scheduler / permissionless cranker / backend

Flow:

  1. Cranker queries all Active campaigns where next_due_ts <= now
  2. For each due campaign: a. Fetch Jupiter quote for per_step_amount of input_mint -> output_mint b. Build execute_step tx with Jupiter route data c. Submit tx
  3. On success: step_log created, campaign state updated
  4. On failure: retry up to max_retries (tracked off-chain), then call pause_campaign

Ticket scope:

  • Program: execute_step instruction handler, step_log PDA init, Jupiter CPI, delegate transfer
  • Backend: campaign scanner, Jupiter quote fetcher, tx builder, retry logic
  • TukTuk: task registration per campaign

UC-3: Cancel Campaign

Actor: User via frontend

Flow:

  1. User clicks "Cancel" on active or paused campaign
  2. Frontend builds tx with:
    • cancel_campaign instruction
    • Revoke on funding_token_account (removes delegate)
  3. User signs and submits
  4. Frontend updates UI to show cancelled state

Ticket scope:

  • Program: cancel_campaign instruction handler
  • Frontend: cancel button, tx builder with revoke bundling

UC-4: Pause Campaign (Automated)

Actor: Backend/cranker detects an issue

Flow:

  1. Before executing a step, cranker reads funding_token_account state
  2. Detects one of: delegate overwritten, allowance exhausted, insufficient funds
  3. Calls pause_campaign with appropriate reason
  4. Frontend polls campaign state and shows pause reason + remediation action

Ticket scope:

  • Program: pause_campaign instruction handler with on-chain condition verification
  • Backend: pre-execution health checks, pause tx builder
  • Frontend: pause state display, remediation prompts

UC-5: Pause Campaign (User-Initiated)

Actor: User via frontend

Flow:

  1. User clicks "Pause" on active campaign
  2. Frontend builds pause_campaign tx with reason UserPaused
  3. User signs and submits

Ticket scope:

  • Program: accept UserPaused reason when signer is owner
  • Frontend: pause button

UC-6: Resume Campaign

Actor: User via frontend

Flow:

  1. User resolves the issue (re-approves delegate, tops up funds)
  2. User clicks "Resume"
  3. Frontend validates on-chain that conditions are met
  4. Frontend builds resume_campaign tx
  5. User signs and submits
  6. Campaign becomes Active, cranker picks it up on next scan

Ticket scope:

  • Program: resume_campaign instruction handler with condition checks
  • Frontend: resume flow with pre-checks, re-approval tx if needed

UC-7: Campaign Completes

Actor: System (automatic on final step)

Flow:

  1. execute_step runs for the last step (steps_executed + 1 == steps_total)
  2. Program sets status to Completed
  3. Emits CampaignCompleted event
  4. Frontend shows completion stats

Ticket scope:

  • Program: completion transition in execute_step
  • Frontend: completion UI, summary stats

UC-8: Close Campaign (Reclaim Rent)

Actor: User via frontend

Flow:

  1. Campaign is in Cancelled or Completed state
  2. User clicks "Close" to reclaim rent
  3. Frontend builds close_campaign tx
  4. User signs, campaign account is closed, SOL returned

Ticket scope:

  • Program: close_campaign instruction handler
  • Frontend: close button on terminal campaigns

UC-9: View Campaign Progress & Metrics

Actor: User via frontend

Flow:

  1. Frontend reads campaign account state for status, progress, next_due_ts
  2. Backend indexes StepExecuted events into a database
  3. Frontend queries backend API for:
    • Average buy price (sum of input / sum of output across steps)
    • Total amount acquired
    • Current streak (consecutive successful steps)
    • Best/worst execution (min/max output_amount)
    • Route distribution (most used Jupiter routes)
  4. Renders dashboard

Ticket scope:

  • Backend: event indexer, metrics API endpoints
  • Frontend: dashboard UI, charts, stats display

UC-10: Catch-Up Missed Steps

Actor: System (cranker/backend)

Flow:

  1. Campaign has catch_up = true and multiple steps are overdue (e.g., cranker was down for 3 days)
  2. Cranker detects next_due_ts is far in the past
  3. Executes steps back-to-back:
    • Step N executes, next_due_ts stays at now (catch-up mode)
    • Step N+1 executes immediately
    • Continues until caught up or a step fails
  4. Once caught up, next_due_ts resumes normal cadence

Ticket scope:

  • Program: catch-up logic in next_due_ts calculation
  • Backend: batch execution loop for overdue campaigns

7. Failure Cases

F-1: Delegate Overwritten

Trigger: User (or another dapp) calls Approve on the funding token account, replacing the campaign PDA as delegate.

Detection: funding_token_account.delegate != campaign_pda

Impact: execute_step will fail validation at step 5.

Recovery:

  1. Backend detects and calls pause_campaign(DelegateOverwritten)
  2. User re-approves delegate via frontend (or creates a new dedicated funding account)
  3. User calls resume_campaign

Prevention: Use a dedicated funding token account per campaign (not the user's main USDC account).


F-2: Allowance Exhausted

Trigger: Delegate allowance decremented to below per_step_amount. Can happen if user initially approved less than per_step_amount * steps_total, or if allowance was partially consumed by something else.

Detection: funding_token_account.delegated_amount < campaign.per_step_amount

Impact: execute_step will fail validation at step 6.

Recovery:

  1. Backend detects and calls pause_campaign(AllowanceExhausted)
  2. User calls ApproveChecked with additional allowance
  3. User calls resume_campaign

F-3: Insufficient Funds

Trigger: User moved tokens out of the funding account, or funding account balance is lower than per_step_amount.

Detection: funding_token_account.amount < campaign.per_step_amount

Impact: execute_step will fail validation at step 7.

Recovery:

  1. Backend detects and calls pause_campaign(InsufficientFunds)
  2. User transfers tokens back into funding account
  3. User calls resume_campaign

F-4: Jupiter Swap Fails (Slippage / Liquidity)

Trigger: Jupiter CPI reverts due to slippage exceeding tolerance, insufficient liquidity, or stale route data.

Detection: Transaction reverts entirely.

Impact: Step is not executed. No on-chain state change.

Recovery:

  1. Backend increments off-chain retry counter
  2. Re-fetches Jupiter quote with updated pricing
  3. Resubmits execute_step with new route data
  4. If retries exhausted: calls pause_campaign(MaxRetriesExceeded)

F-5: Cranker Downtime

Trigger: TukTuk task fails to fire, backend is down, or no permissionless cranker is available.

Detection: next_due_ts is in the past and no step_log exists for the expected step_index.

Impact: Steps are missed. Campaign falls behind schedule.

Recovery:

  • If catch_up = true: steps execute back-to-back when cranker comes back online
  • If catch_up = false: missed steps are skipped, next_due_ts advances to next future slot on resume

Mitigation: Allow permissionless cranking so anyone can trigger due steps, not just the primary backend.


F-6: Destination ATA Closed

Trigger: User closes their ATA for the output mint.

Detection: destination_ata account does not exist or has 0 lamports.

Impact: Jupiter swap CPI will fail when trying to deposit output tokens.

Recovery:

  1. Backend detects missing ATA before submitting tx
  2. Backend (or cranker) recreates ATA as part of the execute_step tx (payer = cranker, owner = user)
  3. Alternatively: pause campaign until user recreates ATA

F-7: Output Mint Frozen / Transfer Restricted

Trigger: Output token mint authority freezes the token or adds transfer restrictions.

Detection: Jupiter swap CPI fails with token program error.

Impact: Steps cannot complete. Funds are not pulled (tx reverts).

Recovery:

  1. Retries will all fail
  2. Campaign paused with MaxRetriesExceeded
  3. User cancels campaign
  4. User's input funds remain untouched in funding account

F-8: Campaign PDA Account Rent Depletion

Trigger: Should not happen if account is rent-exempt at creation, but worth noting.

Detection: Account balance below rent-exempt minimum.

Impact: Account could be garbage collected by the runtime.

Recovery: Ensure all accounts are initialized as rent-exempt (standard Anchor behavior).


F-9: Concurrent Step Execution Attempts

Trigger: Multiple crankers attempt to execute the same step simultaneously.

Detection: Step log PDA init will fail for the second tx (account already exists).

Impact: Only one tx succeeds. Others revert cleanly.

Recovery: No action needed -- idempotency enforced by step_log PDA uniqueness.


F-10: Front-Running / Sandwich Attack on Swap

Trigger: MEV bot observes the execute_step tx in the mempool and sandwiches the Jupiter swap.

Detection: Output amount is near or at min_output_amount consistently.

Impact: User gets worse execution price.

Mitigation:

  • Set min_output_amount appropriately (tight slippage)
  • Use Jupiter's anti-MEV features (e.g., Jito bundles, priority fees)
  • Consider using Jupiter DCA program directly if MEV becomes a persistent issue

F-11: Input Mint Depegs or Loses Value

Trigger: USDC depegs or WSOL price drops significantly.

Detection: Off-chain price monitoring.

Impact: DCA continues executing at unfavorable rates.

Recovery: User manually pauses or cancels the campaign. This is a user-level risk, not a program-level failure.


F-12: Funding Token Account Closed

Trigger: User closes the dedicated funding token account entirely (not just drains it -- the account ceases to exist).

Detection: Account does not exist when cranker attempts execute_step.

Impact: Cannot transfer via delegate -- tx fails. Different from F-3 (insufficient funds) because the account itself is gone.

Recovery:

  1. Backend detects and calls pause_campaign(InsufficientFunds) (or a dedicated FundingAccountClosed reason)
  2. User must create a new funding token account, fund it, and re-approve delegate
  3. Problem: campaign stores funding_token_account pubkey. If the user creates a new account, the pubkey differs. Either:
    • Add an update_funding_account instruction (owner-only), OR
    • Require the user to recreate the account at the same address (only possible if it was a PDA or deterministic keypair -- unlikely for a standard keypair account)

Design implication: Consider deriving the funding token account as a PDA or ATA so the address is deterministic and can be recreated.


F-13: Malicious Cranker Route Manipulation

Trigger: A cranker passes Jupiter route data that routes through a low-liquidity pool they control, extracting value from the swap.

Detection: Output amount is consistently at or near min_output_amount despite normal market conditions.

Impact: User gets worse execution than market rate. Different from F-10 (sandwich) -- this is the cranker itself being adversarial.

Mitigation:

  • min_output_amount on the campaign enforces a floor
  • Backend should set min_out_amount in the instruction args based on a fresh Jupiter quote with tight slippage
  • On-chain: program should verify min_out_amount >= campaign.min_output_amount
  • Consider: allow the user to set a trusted cranker whitelist (at the cost of permissionless cranking)

F-14: Race Between Cancel and Execute

Trigger: User submits cancel_campaign tx. In the same slot, cranker's execute_step tx lands first.

Detection: User sees one more step executed than expected after cancellation.

Impact: One additional step worth of funds spent after user intended to cancel.

Mitigation:

  • Inherent to any concurrent system. The step was valid at execution time.
  • Acceptable: the step amount is bounded by per_step_amount (single step, not entire remaining budget)
  • Frontend should warn: "A pending step may still execute before cancellation takes effect."

F-15: Permissionless Pause Griefing

Trigger: Malicious actor calls pause_campaign with a valid reason (e.g., DelegateOverwritten) during a brief window before the delegate is fully set up, or during a race condition.

Detection: Campaign is unexpectedly paused.

Impact: Campaign stalls. User must manually resume.

Mitigation:

  • pause_campaign verifies the condition on-chain (e.g., delegate really is wrong). If the condition is not actually true, the tx fails.
  • Brief race window between create_campaign and ApproveChecked: the campaign is Active but has no delegate yet. A griefer could pause it with DelegateOverwritten.
  • Fix: either bundle create_campaign + ApproveChecked atomically in the same tx (already recommended), or set initial status to Pending until first successful health check.

F-16: Token-2022 / Token Extensions Incompatibility

Trigger: Input or output mint uses Token-2022 with transfer fees, confidential transfers, transfer hooks, or other extensions.

Detection: CPI calls to token_program fail or produce unexpected amounts.

Impact:

  • Transfer fees: per_step_amount debited but less arrives at the swap. Output amount is less than expected.
  • Confidential transfers: incompatible with delegate model.
  • Transfer hooks: arbitrary external code runs during transfer, could fail or have side effects.

Mitigation:

  • Explicitly validate input_mint and output_mint are standard SPL Token (not Token-2022) in create_campaign, OR
  • Support Token-2022 explicitly: account for transfer fees in amount calculations, use token_2022_program where needed
  • Document which token extensions are supported

F-17: Rounding / Dust from Integer Division

Trigger: Frontend calculates per_step_amount = total_amount / steps_total. Integer division truncates, leaving dust.

Example: User wants to DCA 1000 USDC over 7 days. 1000000000 / 7 = 142857142 per step. 142857142 * 7 = 999999994. 6 lamports of USDC dust remain.

Impact: Remaining dust in the funding account after campaign completes. Allowance may not perfectly align.

Mitigation:

  • Frontend: add remainder to the first or last step, or display the actual per-step amount
  • Program: on the final step, use min(per_step_amount, remaining_balance) instead of exact per_step_amount
  • Add ticket for handling the final-step dust case

F-18: Campaign Spam / Cranker DoS

Trigger: Malicious user creates thousands of Active campaigns with valid parameters but no intention of funding them (or with 1 lamport funding).

Detection: Cranker scans show high volume of campaigns that immediately fail health checks.

Impact: Cranker wastes compute scanning and pre-checking campaigns that will never execute. Could slow down execution for legitimate campaigns.

Mitigation:

  • Cranker: filter campaigns by funding_token_account balance before attempting execution
  • Consider: require a minimum SOL deposit on create_campaign that is refunded on close_campaign (anti-spam deposit)
  • Consider: rate-limit campaigns per user (max active campaigns)

F-19: Step Log Rent Accumulation

Trigger: Each successful step creates a step_log PDA account. A 365-day campaign creates 365 accounts.

Detection: Cranker's SOL balance depletes over time (cranker pays rent for step_log init).

Impact:

  • Cranker: significant rent cost over many campaigns and steps. At ~0.00147 SOL per account (minimum rent-exempt), 365 steps = ~0.54 SOL per campaign.
  • On-chain: account bloat.

Mitigation:

  • Add close_step_log instruction: owner can close step_log accounts after campaign completes to reclaim rent
  • Who receives the rent? If cranker paid, cranker should receive. Store payer in step_log.
  • Alternative: use a single account with a bitmap or counter instead of per-step PDAs for idempotency (loses per-step on-chain data but saves rent)
  • Alternative: emit step data only as events (no step_log accounts), use off-chain indexer. Idempotency enforced by steps_executed counter + next_due_ts check.

F-20: No Campaign Modification

Trigger: User wants to change per_step_amount, cadence_secs, min_output_amount, or steps_total mid-campaign.

Detection: N/A -- no instruction exists for this.

Impact: User must cancel and recreate the campaign, losing progress tracking continuity.

Mitigation:

  • Add update_campaign instruction (owner-only, campaign must be Paused)
  • Constrain what can be changed: min_output_amount, max_retries, catch_up are safe to modify
  • per_step_amount and steps_total changes require re-validation of allowance
  • cadence_secs change requires next_due_ts recalculation

F-21: Jupiter Program Upgrade / Deprecation

Trigger: Jupiter upgrades their swap program to a new version, deprecating the CPI interface.

Detection: execute_step CPI calls fail with program error.

Impact: All active campaigns stall.

Mitigation:

  • Store Jupiter program ID as a configurable authority (admin-updatable global config PDA), not hardcoded
  • Or: accept Jupiter program ID as an account in execute_step and validate it against a whitelist
  • Plan for program upgrade path that updates the Jupiter program reference

F-22: Cranker Fee Economics

Trigger: Cranker pays tx fees + step_log rent for every step but receives no compensation.

Detection: Cranker SOL balance depletes.

Impact: Cranker stops operating. Campaigns stall.

Mitigation:

  • Option A: take a small fee from the swap (e.g., 0.1% of per_step_amount) and send to cranker or protocol treasury
  • Option B: user pre-deposits SOL into the campaign for cranker fees
  • Option C: centralized backend absorbs cost as operational expense (simplest but least decentralized)
  • Whichever option: needs a ticket for fee mechanism design and implementation

F-23: Multiple Campaigns Same Funding Account

Trigger: User creates two campaigns pointing to the same funding token account.

Detection: Both campaigns have delegate set to different PDAs, but only one can be the active delegate.

Impact: One campaign works, the other is immediately in DelegateOverwritten state.

Mitigation:

  • Already mitigated by recommendation to use dedicated funding accounts per campaign
  • Program could enforce: validate that no other active campaign references the same funding_token_account (expensive on-chain lookup)
  • Simpler: document the constraint clearly, frontend prevents reuse

F-24: Program Upgrade During Active Campaigns

Trigger: Program is upgraded while campaigns are active.

Detection: N/A (operational concern).

Impact: Account struct changes could make existing campaigns unreadable.

Mitigation:

  • Use account versioning (version byte in account data)
  • Migration instruction to upgrade campaign accounts to new schema
  • Thorough upgrade testing on devnet with active campaigns

8. Suggested Ticket Breakdown

On-Chain Program

  1. Account structures (Campaign, StepLog) + PDA derivation
  2. create_campaign instruction
  3. execute_step instruction (core swap logic + Jupiter CPI)
  4. cancel_campaign instruction
  5. pause_campaign instruction (with on-chain condition verification)
  6. resume_campaign instruction
  7. close_campaign instruction
  8. close_step_log instruction (reclaim rent from completed campaigns) [F-19]
  9. update_campaign instruction (modify params while paused) [F-20]
  10. Event emission (all events)
  11. Slippage enforcement (min_output_amount)
  12. Catch-up logic in next_due_ts calculation
  13. Final-step dust handling (min(per_step_amount, remaining_balance)) [F-17]
  14. Token-2022 compatibility decision: reject or support with fee accounting [F-16]
  15. Jupiter program ID: configurable via global config PDA [F-21]
  16. Anti-spam: optional minimum SOL deposit or max active campaigns per user [F-18]
  17. Cranker fee mechanism design and implementation [F-22]
  18. Unit tests: happy paths for all instructions
  19. Unit tests: all failure cases (F-1 through F-24)
  20. Unit tests: concurrent execution / idempotency [F-9]
  21. Unit tests: race between cancel and execute in same slot [F-14]
  22. Integration test: full campaign lifecycle (create -> N steps -> complete)
  23. Integration test: pause/resume cycle (each pause reason)
  24. Integration test: catch-up mode (cranker down for N days, resumes)
  25. Integration test: multiple campaigns per user with separate funding accounts [F-23]
  26. Security audit prep: access control review, PDA authority checks
  27. Security audit prep: permissionless pause griefing analysis [F-15]
  28. Security audit prep: cranker route manipulation analysis [F-13]

Backend / Cranker

  1. Campaign scanner: query all Active campaigns where next_due_ts <= now
  2. Pre-execution health checks: validate delegate/allowance/funds/ATA existence before submitting [F-1, F-2, F-3, F-6, F-12]
  3. Jupiter quote fetcher: get route for campaign parameters
  4. Transaction builder: assemble execute_step tx with Jupiter route data
  5. Retry logic: track per-campaign retry count, call pause_campaign on exhaustion [F-4]
  6. Destination ATA recreation: detect missing ATA, include create-ATA ix in execute tx [F-6]
  7. Event indexer: subscribe to program events, write to database
  8. Metrics API: avg price, total acquired, streaks, route stats
  9. TukTuk task registration: create/remove tasks per campaign lifecycle
  10. Campaign spam filtering: skip campaigns with zero/insufficient funding balance [F-18]
  11. Cranker SOL balance monitoring and alerting [F-22]
  12. Jupiter program version monitoring: alert on deprecation/upgrade [F-21]
  13. MEV protection: Jito bundle submission, priority fee strategy [F-10]

Frontend

  1. Campaign creation form + tx builder (including ATA creation + ApproveChecked)
  2. Campaign creation: validate dedicated funding account, warn against reuse [F-23]
  3. Campaign creation: display actual per-step amount after rounding [F-17]
  4. Campaign list view (active, paused, completed, cancelled)
  5. Campaign detail view (progress, next execution, status)
  6. Cancel flow (with Revoke bundling + pending step warning) [F-14]
  7. Pause/Resume flow (with re-approval prompts)
  8. Update campaign flow (modify params while paused) [F-20]
  9. Close flow (rent reclaim for campaign + step logs) [F-19]
  10. Metrics dashboard (charts, stats, streaks)
  11. Error state UI (pause reasons + remediation actions per reason)
  12. Funding account recovery flow: handle closed funding account [F-12]
  13. Token compatibility: filter/warn on Token-2022 mints [F-16]

9. References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment