| 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. |
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.
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 |
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.
Signer: user wallet
Accounts:
user(signer, mut) -- pays for account creationcampaign(PDA, init)funding_token_account(user-owned, token account for input_mint)destination_ata(user-owned, ATA for output_mint -- create if missing)input_mintoutput_mintsystem_programtoken_programassociated_token_program
Args:
campaign_id: u64per_step_amount: u64cadence_secs: i64steps_total: u32min_output_amount: u64max_retries: u8catch_up: bool
Validations:
per_step_amount > 0steps_total > 0cadence_secs >= 60(minimum 1 minute to prevent abuse)funding_token_account.mint == input_mintfunding_token_account.owner == userdestination_ata.mint == output_mintdestination_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) orstart_tsif provided - Emits
CampaignCreatedevent
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.
Signer: cranker (permissionless -- anyone can call this)
Accounts:
cranker(signer) -- pays tx feescampaign(PDA, mut)step_log(PDA, init) -- idempotency guardfunding_token_account(mut) -- user's token account, delegate = campaign PDAcampaign_pda_authority(campaign PDA used as delegate authority)destination_ata(mut) -- user's ATA for output_mintjupiter_program+ Jupiter route accounts (variable)token_programsystem_programclock(sysvar)
Args:
jupiter_route_data: Vec<u8>-- serialized Jupiter swap instruction datamin_out_amount: u64-- from Jupiter quote, must be >= campaign'smin_output_amount
Validations (ordered):
campaign.status == ActiveClock::get().unix_timestamp >= campaign.next_due_tscampaign.steps_executed < campaign.steps_total- Step log PDA does not exist (idempotency)
funding_token_account.delegate == campaign_pda_authorityfunding_token_account.delegated_amount >= campaign.per_step_amountfunding_token_account.amount >= campaign.per_step_amount(actual balance)min_out_amount >= campaign.min_output_amount(slippage guard)destination_ata.owner == campaign.owner(output goes to user)destination_ata.mint == campaign.output_mint
Effects on success:
- Transfers
per_step_amountfrom 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 == trueand steps are behind:next_due_ts = now(immediately eligible for next) - Otherwise:
next_due_ts = next_due_ts + cadence_secs
- If
- If
steps_executed == steps_total: set status toCompleted - Emits
StepExecutedevent
Effects on failure (swap CPI fails):
- Increments
retry_count - If
retry_count >= max_retries: set status toPaused(MaxRetriesExceeded) - Emits
StepFailedevent - 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_retriesfield is informational. Backend callspause_campaignwith reasonMaxRetriesExceededafter exhausting retries. - Option B: Split into two instructions:
prepare_step(locks funds, increments attempt counter) andfinalize_step(completes swap). More complex but fully on-chain.
Signer: campaign owner
Accounts:
owner(signer)campaign(PDA, mut)
Validations:
owner == campaign.ownercampaign.status != Cancelledandcampaign.status != Completed
Effects:
- Sets status to
Cancelled - Emits
CampaignCancelledevent
Note: Does NOT revoke the delegate allowance. Frontend should bundle a Revoke instruction in the same tx, or prompt the user to revoke separately.
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 (includingUserPaused) - If
authority != campaign.owner: reason must be one ofDelegateOverwritten,AllowanceExhausted,InsufficientFunds,MaxRetriesExceededAND the corresponding condition must be verifiable on-chain:DelegateOverwritten:funding_token_account.delegate != campaign_pdaAllowanceExhausted:funding_token_account.delegated_amount < campaign.per_step_amountInsufficientFunds:funding_token_account.amount < campaign.per_step_amountMaxRetriesExceeded: caller attests (backend authority check or separate PDA)
campaign.status == Active
Effects:
- Sets status to
Paused(reason) - Emits
CampaignPausedevent
Signer: campaign owner
Accounts:
owner(signer)campaign(PDA, mut)funding_token_account(read-only, for validation)
Validations:
owner == campaign.ownercampaign.status == Paused(_)- Verify the pause condition is resolved:
funding_token_account.delegate == campaign_pdafunding_token_account.delegated_amount >= remaining_allowance_neededfunding_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)
- If
- Emits
CampaignResumedevent
Signer: campaign owner
Accounts:
owner(signer, mut) -- receives rentcampaign(PDA, mut, close)
Validations:
owner == campaign.ownercampaign.status == Cancelledorcampaign.status == Completed
Effects:
- Closes campaign account, returns rent to owner
- Emits
CampaignClosedevent
| 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 |
Actor: User via frontend
Flow:
- User selects input token, output token, total amount, duration (days)
- Frontend derives:
per_step_amount = total / days,steps_total = days,cadence_secs = 86400 - Frontend creates a dedicated funding token account for this campaign (avoids delegate conflicts with other campaigns or user activity)
- Frontend builds tx with:
create_campaigninstructionApproveCheckedon funding_token_account (delegate = campaign PDA, amount = total)- Create destination ATA if it doesn't exist
- User signs and submits
Ticket scope:
- Program:
create_campaigninstruction handler + validation - Frontend: campaign creation form, tx builder, ATA creation logic
Actor: TukTuk scheduler / permissionless cranker / backend
Flow:
- Cranker queries all
Activecampaigns wherenext_due_ts <= now - For each due campaign:
a. Fetch Jupiter quote for
per_step_amountof input_mint -> output_mint b. Buildexecute_steptx with Jupiter route data c. Submit tx - On success: step_log created, campaign state updated
- On failure: retry up to max_retries (tracked off-chain), then call
pause_campaign
Ticket scope:
- Program:
execute_stepinstruction handler, step_log PDA init, Jupiter CPI, delegate transfer - Backend: campaign scanner, Jupiter quote fetcher, tx builder, retry logic
- TukTuk: task registration per campaign
Actor: User via frontend
Flow:
- User clicks "Cancel" on active or paused campaign
- Frontend builds tx with:
cancel_campaigninstructionRevokeon funding_token_account (removes delegate)
- User signs and submits
- Frontend updates UI to show cancelled state
Ticket scope:
- Program:
cancel_campaigninstruction handler - Frontend: cancel button, tx builder with revoke bundling
Actor: Backend/cranker detects an issue
Flow:
- Before executing a step, cranker reads funding_token_account state
- Detects one of: delegate overwritten, allowance exhausted, insufficient funds
- Calls
pause_campaignwith appropriate reason - Frontend polls campaign state and shows pause reason + remediation action
Ticket scope:
- Program:
pause_campaigninstruction handler with on-chain condition verification - Backend: pre-execution health checks, pause tx builder
- Frontend: pause state display, remediation prompts
Actor: User via frontend
Flow:
- User clicks "Pause" on active campaign
- Frontend builds
pause_campaigntx with reasonUserPaused - User signs and submits
Ticket scope:
- Program: accept
UserPausedreason when signer is owner - Frontend: pause button
Actor: User via frontend
Flow:
- User resolves the issue (re-approves delegate, tops up funds)
- User clicks "Resume"
- Frontend validates on-chain that conditions are met
- Frontend builds
resume_campaigntx - User signs and submits
- Campaign becomes
Active, cranker picks it up on next scan
Ticket scope:
- Program:
resume_campaigninstruction handler with condition checks - Frontend: resume flow with pre-checks, re-approval tx if needed
Actor: System (automatic on final step)
Flow:
execute_stepruns for the last step (steps_executed + 1 == steps_total)- Program sets status to
Completed - Emits
CampaignCompletedevent - Frontend shows completion stats
Ticket scope:
- Program: completion transition in
execute_step - Frontend: completion UI, summary stats
Actor: User via frontend
Flow:
- Campaign is in
CancelledorCompletedstate - User clicks "Close" to reclaim rent
- Frontend builds
close_campaigntx - User signs, campaign account is closed, SOL returned
Ticket scope:
- Program:
close_campaigninstruction handler - Frontend: close button on terminal campaigns
Actor: User via frontend
Flow:
- Frontend reads campaign account state for status, progress, next_due_ts
- Backend indexes
StepExecutedevents into a database - 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)
- Renders dashboard
Ticket scope:
- Backend: event indexer, metrics API endpoints
- Frontend: dashboard UI, charts, stats display
Actor: System (cranker/backend)
Flow:
- Campaign has
catch_up = trueand multiple steps are overdue (e.g., cranker was down for 3 days) - Cranker detects
next_due_tsis far in the past - Executes steps back-to-back:
- Step N executes,
next_due_tsstays atnow(catch-up mode) - Step N+1 executes immediately
- Continues until caught up or a step fails
- Step N executes,
- Once caught up,
next_due_tsresumes normal cadence
Ticket scope:
- Program: catch-up logic in
next_due_tscalculation - Backend: batch execution loop for overdue campaigns
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:
- Backend detects and calls
pause_campaign(DelegateOverwritten) - User re-approves delegate via frontend (or creates a new dedicated funding account)
- User calls
resume_campaign
Prevention: Use a dedicated funding token account per campaign (not the user's main USDC account).
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:
- Backend detects and calls
pause_campaign(AllowanceExhausted) - User calls
ApproveCheckedwith additional allowance - User calls
resume_campaign
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:
- Backend detects and calls
pause_campaign(InsufficientFunds) - User transfers tokens back into funding account
- User calls
resume_campaign
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:
- Backend increments off-chain retry counter
- Re-fetches Jupiter quote with updated pricing
- Resubmits
execute_stepwith new route data - If retries exhausted: calls
pause_campaign(MaxRetriesExceeded)
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_tsadvances to next future slot on resume
Mitigation: Allow permissionless cranking so anyone can trigger due steps, not just the primary backend.
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:
- Backend detects missing ATA before submitting tx
- Backend (or cranker) recreates ATA as part of the
execute_steptx (payer = cranker, owner = user) - Alternatively: pause campaign until user recreates ATA
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:
- Retries will all fail
- Campaign paused with
MaxRetriesExceeded - User cancels campaign
- User's input funds remain untouched in funding account
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).
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.
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_amountappropriately (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
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.
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:
- Backend detects and calls
pause_campaign(InsufficientFunds)(or a dedicatedFundingAccountClosedreason) - User must create a new funding token account, fund it, and re-approve delegate
- Problem: campaign stores
funding_token_accountpubkey. If the user creates a new account, the pubkey differs. Either:- Add an
update_funding_accountinstruction (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)
- Add an
Design implication: Consider deriving the funding token account as a PDA or ATA so the address is deterministic and can be recreated.
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_amounton the campaign enforces a floor- Backend should set
min_out_amountin 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)
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."
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_campaignverifies 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_campaignandApproveChecked: the campaign isActivebut has no delegate yet. A griefer could pause it withDelegateOverwritten. - Fix: either bundle
create_campaign+ApproveCheckedatomically in the same tx (already recommended), or set initial status toPendinguntil first successful health check.
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_amountdebited 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_programwhere needed - Document which token extensions are supported
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 exactper_step_amount - Add ticket for handling the final-step dust case
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_campaignthat is refunded onclose_campaign(anti-spam deposit) - Consider: rate-limit campaigns per user (max active campaigns)
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_loginstruction: owner can close step_log accounts after campaign completes to reclaim rent - Who receives the rent? If cranker paid, cranker should receive. Store
payerin 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_executedcounter +next_due_tscheck.
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_campaigninstruction (owner-only, campaign must bePaused) - Constrain what can be changed:
min_output_amount,max_retries,catch_upare safe to modify per_step_amountandsteps_totalchanges require re-validation of allowancecadence_secschange requiresnext_due_tsrecalculation
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_stepand validate it against a whitelist - Plan for program upgrade path that updates the Jupiter program reference
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
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
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
- Account structures (Campaign, StepLog) + PDA derivation
create_campaigninstructionexecute_stepinstruction (core swap logic + Jupiter CPI)cancel_campaigninstructionpause_campaigninstruction (with on-chain condition verification)resume_campaigninstructionclose_campaigninstructionclose_step_loginstruction (reclaim rent from completed campaigns) [F-19]update_campaigninstruction (modify params while paused) [F-20]- Event emission (all events)
- Slippage enforcement (
min_output_amount) - Catch-up logic in
next_due_tscalculation - Final-step dust handling (
min(per_step_amount, remaining_balance)) [F-17] - Token-2022 compatibility decision: reject or support with fee accounting [F-16]
- Jupiter program ID: configurable via global config PDA [F-21]
- Anti-spam: optional minimum SOL deposit or max active campaigns per user [F-18]
- Cranker fee mechanism design and implementation [F-22]
- Unit tests: happy paths for all instructions
- Unit tests: all failure cases (F-1 through F-24)
- Unit tests: concurrent execution / idempotency [F-9]
- Unit tests: race between cancel and execute in same slot [F-14]
- Integration test: full campaign lifecycle (create -> N steps -> complete)
- Integration test: pause/resume cycle (each pause reason)
- Integration test: catch-up mode (cranker down for N days, resumes)
- Integration test: multiple campaigns per user with separate funding accounts [F-23]
- Security audit prep: access control review, PDA authority checks
- Security audit prep: permissionless pause griefing analysis [F-15]
- Security audit prep: cranker route manipulation analysis [F-13]
- Campaign scanner: query all Active campaigns where
next_due_ts <= now - Pre-execution health checks: validate delegate/allowance/funds/ATA existence before submitting [F-1, F-2, F-3, F-6, F-12]
- Jupiter quote fetcher: get route for campaign parameters
- Transaction builder: assemble
execute_steptx with Jupiter route data - Retry logic: track per-campaign retry count, call
pause_campaignon exhaustion [F-4] - Destination ATA recreation: detect missing ATA, include create-ATA ix in execute tx [F-6]
- Event indexer: subscribe to program events, write to database
- Metrics API: avg price, total acquired, streaks, route stats
- TukTuk task registration: create/remove tasks per campaign lifecycle
- Campaign spam filtering: skip campaigns with zero/insufficient funding balance [F-18]
- Cranker SOL balance monitoring and alerting [F-22]
- Jupiter program version monitoring: alert on deprecation/upgrade [F-21]
- MEV protection: Jito bundle submission, priority fee strategy [F-10]
- Campaign creation form + tx builder (including ATA creation + ApproveChecked)
- Campaign creation: validate dedicated funding account, warn against reuse [F-23]
- Campaign creation: display actual per-step amount after rounding [F-17]
- Campaign list view (active, paused, completed, cancelled)
- Campaign detail view (progress, next execution, status)
- Cancel flow (with Revoke bundling + pending step warning) [F-14]
- Pause/Resume flow (with re-approval prompts)
- Update campaign flow (modify params while paused) [F-20]
- Close flow (rent reclaim for campaign + step logs) [F-19]
- Metrics dashboard (charts, stats, streaks)
- Error state UI (pause reasons + remediation actions per reason)
- Funding account recovery flow: handle closed funding account [F-12]
- Token compatibility: filter/warn on Token-2022 mints [F-16]