antegen_thread_program/instructions/
thread_exec.rs

1use crate::{
2    errors::*,
3    state::{decompile_instruction, CompiledInstructionV0, Signal, PAYER_PUBKEY},
4    *,
5};
6use anchor_lang::{
7    prelude::*,
8    solana_program::program::{get_return_data, invoke_signed},
9};
10
11/// Accounts required by the `thread_exec` instruction.
12#[derive(Accounts)]
13#[instruction(forgo_commission: bool, fiber_cursor: u8)]
14pub struct ThreadExec<'info> {
15    /// The executor sending and paying for the transaction
16    #[account(mut)]
17    pub executor: Signer<'info>,
18
19    /// The thread being executed
20    #[account(
21        mut,
22        seeds = [
23            SEED_THREAD,
24            thread.authority.as_ref(),
25            thread.id.as_slice(),
26        ],
27        bump = thread.bump,
28        constraint = !thread.paused @ AntegenThreadError::ThreadPaused,
29        constraint = !thread.fiber_ids.is_empty() @ AntegenThreadError::InvalidThreadState,
30    )]
31    pub thread: Box<Account<'info, Thread>>,
32
33    /// The fiber to execute (optional - not needed if fiber_cursor == 0 and default fiber exists)
34    /// Seeds validation is done in the instruction body when fiber is Some
35    pub fiber: Option<Box<Account<'info, FiberState>>>,
36
37    /// The config for fee distribution
38    #[account(
39        seeds = [SEED_CONFIG],
40        bump = config.bump,
41    )]
42    pub config: Account<'info, ThreadConfig>,
43
44    // The config admin (for core team fee distribution)
45    /// CHECK: This is validated by the config account
46    #[account(
47        mut,
48        constraint = admin.key().eq(&config.admin) @ AntegenThreadError::InvalidConfigAdmin,
49    )]
50    pub admin: UncheckedAccount<'info>,
51
52    /// The authority of the thread (for fee distribution)
53    /// CHECK: This is validated by the thread account
54    #[account(
55        mut,
56        constraint = thread_authority.key() == thread.authority @ AntegenThreadError::InvalidThreadAuthority,
57    )]
58    pub thread_authority: UncheckedAccount<'info>,
59
60    /// Optional nonce account for durable nonces
61    /// CHECK: Only required if thread has nonce account
62    #[account(mut)]
63    pub nonce_account: Option<UncheckedAccount<'info>>,
64
65    /// CHECK: Recent blockhashes sysvar (optional - only required if thread has nonce account)
66    pub recent_blockhashes: Option<UncheckedAccount<'info>>,
67
68    #[account(address = anchor_lang::system_program::ID)]
69    pub system_program: Program<'info, System>,
70}
71
72pub fn thread_exec(
73    ctx: Context<ThreadExec>,
74    forgo_commission: bool,
75    fiber_cursor: u8,
76) -> Result<()> {
77    let clock: Clock = Clock::get()?;
78    let thread: &mut Box<Account<Thread>> = &mut ctx.accounts.thread;
79    let config: &Account<ThreadConfig> = &ctx.accounts.config;
80
81    let executor: &mut Signer = &mut ctx.accounts.executor;
82    let executor_lamports_start: u64 = executor.lamports();
83
84    // Check global pause
85    require!(
86        !ctx.accounts.config.paused,
87        AntegenThreadError::GlobalPauseActive
88    );
89
90    let thread_pubkey = thread.key();
91
92    // Handle close_fiber execution when Signal::Close is set
93    if thread.fiber_signal == Signal::Close {
94        // Decompile and execute the close_fiber (CPIs to thread_delete)
95        let compiled = CompiledInstructionV0::try_from_slice(&thread.close_fiber)?;
96        let instruction = decompile_instruction(&compiled)?;
97
98        msg!("Executing close_fiber to delete thread");
99
100        // Invoke thread_delete via CPI with thread signing as authority
101        thread.sign(|seeds| invoke_signed(&instruction, ctx.remaining_accounts, &[seeds]))?;
102
103        // Thread is now closed by thread_delete, nothing more to do
104        return Ok(());
105    }
106
107    // Check if this is a chained execution (previous fiber signaled Chain)
108    let is_chained = thread.fiber_signal == Signal::Chain;
109
110    // Sync fiber_cursor for chained executions so advance_to_next_fiber works correctly
111    if is_chained {
112        thread.fiber_cursor = fiber_cursor;
113    }
114
115    // Normal fiber execution path
116    // Validate thread is ready for execution (has fibers and valid exec_index)
117    thread.validate_for_execution()?;
118
119    // Handle nonce using trait method
120    thread.advance_nonce_if_required(
121        &thread.to_account_info(),
122        &ctx.accounts.nonce_account,
123        &ctx.accounts.recent_blockhashes,
124    )?;
125
126    // Validate trigger and get elapsed time (skip for chained executions)
127    let time_since_ready = if is_chained {
128        msg!("Chained execution from fiber_signal={:?}", thread.fiber_signal);
129        0 // No elapsed time for chained executions
130    } else {
131        thread.validate_trigger(&clock, ctx.remaining_accounts, &thread_pubkey)?
132    };
133
134    // Get instruction from default fiber or fiber account
135    let (instruction, _priority_fee, is_inline) =
136        if fiber_cursor == 0 && thread.default_fiber.is_some() {
137            // Use default fiber at index 0
138            let compiled =
139                CompiledInstructionV0::try_from_slice(thread.default_fiber.as_ref().unwrap())?;
140            let mut ix = decompile_instruction(&compiled)?;
141
142            // Replace PAYER_PUBKEY with executor
143            for acc in ix.accounts.iter_mut() {
144                if acc.pubkey.eq(&PAYER_PUBKEY) {
145                    acc.pubkey = executor.key();
146                }
147            }
148
149            (ix, thread.default_fiber_priority_fee, true)
150        } else {
151            // Use fiber account at fiber_cursor
152            let fiber = ctx
153                .accounts
154                .fiber
155                .as_ref()
156                .ok_or(AntegenThreadError::FiberAccountRequired)?;
157
158            // Verify we're loading the correct fiber account
159            let expected_fiber = thread.fiber_at_index(&thread_pubkey, fiber_cursor);
160            require!(
161                fiber.key() == expected_fiber,
162                AntegenThreadError::InvalidFiberIndex
163            );
164
165            (
166                fiber.get_instruction(&executor.key())?,
167                fiber.priority_fee,
168                false,
169            )
170        };
171
172    // Invoke the instruction
173    thread.sign(|seeds| invoke_signed(&instruction, ctx.remaining_accounts, &[seeds]))?;
174
175    // Verify the inner instruction did not write data to the executor account
176    require!(
177        executor.data_is_empty(),
178        AntegenThreadError::UnauthorizedWrite
179    );
180
181    // Parse the signal from return data and store for executor to read
182    let signal: Signal = match get_return_data() {
183        None => Signal::None,
184        Some((program_id, return_data)) => {
185            if program_id.eq(&instruction.program_id) {
186                Signal::try_from_slice(return_data.as_slice()).unwrap_or(Signal::None)
187            } else {
188                Signal::None
189            }
190        }
191    };
192
193    // Calculate and distribute payments when chain ends (signal != Chain)
194    // This ensures fees are calculated once at the end of a chain, capturing total balance change
195    if signal != Signal::Chain {
196        let balance_change = executor.lamports() as i64 - executor_lamports_start as i64;
197        let payments = config.calculate_payments(time_since_ready, balance_change, forgo_commission);
198
199        // Log execution timing and commission details
200        if forgo_commission && payments.executor_commission.eq(&0) {
201            let effective_commission = config.calculate_effective_commission(time_since_ready);
202            let forgone = config.calculate_executor_fee(effective_commission);
203            msg!(
204                "Executed {}s after trigger, forgoing {} commission",
205                time_since_ready,
206                forgone
207            );
208        } else {
209            msg!("Executed {}s after trigger", time_since_ready);
210        }
211
212        // Distribute payments using thread trait
213        thread.distribute_payments(
214            &thread.to_account_info(),
215            &executor.to_account_info(),
216            &ctx.accounts.admin.to_account_info(),
217            &payments,
218        )?;
219    }
220
221    // Store signal for executor to read after simulation
222    thread.fiber_signal = signal.clone();
223
224    // For Immediate triggers: auto-close after fiber completes (unless chaining)
225    // Since Immediate triggers set next = i64::MAX after execution,
226    // there's no reason to keep the thread alive - auto-delete to reclaim rent
227    if matches!(thread.trigger, Trigger::Immediate { .. }) && signal != Signal::Chain {
228        thread.fiber_signal = Signal::Close;
229    }
230
231    match &signal {
232        Signal::Next { index } => {
233            thread.fiber_cursor = *index;
234        }
235        Signal::UpdateTrigger { trigger } => {
236            thread.trigger = trigger.clone();
237            thread.advance_to_next_fiber();
238        }
239        Signal::None => {
240            thread.advance_to_next_fiber();
241        }
242        _ => {}
243    }
244
245    // Update schedule for next execution (skip for chained - only first fiber updates schedule)
246    if !is_chained {
247        thread.update_schedule(&clock, ctx.remaining_accounts, &thread_pubkey)?;
248    }
249
250    // Update fiber tracking
251    if !is_inline {
252        let fiber = ctx
253            .accounts
254            .fiber
255            .as_mut()
256            .ok_or(AntegenThreadError::FiberAccountRequired)?;
257        fiber.last_executed = clock.unix_timestamp;
258        fiber.exec_count += 1;
259    }
260
261    thread.exec_count += 1;
262    thread.last_executor = executor.key();
263    thread.last_error_time = None;
264
265    Ok(())
266}