diff --git a/solana-programs/packages/remote-example-server/src/index.ts b/solana-programs/packages/remote-example-server/src/index.ts index be32b6a..8e053bc 100644 --- a/solana-programs/packages/remote-example-server/src/index.ts +++ b/solana-programs/packages/remote-example-server/src/index.ts @@ -56,16 +56,15 @@ server.post<{ accounts: remainingAccounts.map((acc) => acc.pubkey), }, }); + const serialized = await RemoteTaskTransactionV0.serialize( + program.coder.accounts, + remoteTx + ); const resp = { - transaction: RemoteTaskTransactionV0.serialize( - program.coder.types, - remoteTx - ).toString("base64"), + transaction: serialized.toString("base64"), signature: Buffer.from( sign.detached( - Uint8Array.from( - RemoteTaskTransactionV0.serialize(program.coder.types, remoteTx) - ), + Uint8Array.from(serialized), serverWallet.secretKey ) ).toString("base64"), diff --git a/solana-programs/packages/tuktuk-sdk/src/transaction.ts b/solana-programs/packages/tuktuk-sdk/src/transaction.ts index 844026d..ac93805 100644 --- a/solana-programs/packages/tuktuk-sdk/src/transaction.ts +++ b/solana-programs/packages/tuktuk-sdk/src/transaction.ts @@ -1,4 +1,5 @@ import { + AccountsCoder, BN, CustomAccountResolver, Idl, @@ -75,12 +76,8 @@ export class RemoteTaskTransactionV0 { this.transaction = { ...fields.transaction, accounts: [] }; } - static serialize(coder: TypesCoder, value: RemoteTaskTransactionV0): Buffer { - return Buffer.concat([ - sighash("tuktuk", "RemoteTaskTransactionV0"), - value.verificationHash, - coder.encode("compiledTransactionV0", value.transaction), - ]); + static async serialize(coder: AccountsCoder, value: RemoteTaskTransactionV0): Promise { + return coder.encode("remoteTaskTransactionV0", value); } } diff --git a/solana-programs/programs/cron/src/instructions/initialize_cron_job_v0.rs b/solana-programs/programs/cron/src/instructions/initialize_cron_job_v0.rs index 0b0daab..3dec4ab 100644 --- a/solana-programs/programs/cron/src/instructions/initialize_cron_job_v0.rs +++ b/solana-programs/programs/cron/src/instructions/initialize_cron_job_v0.rs @@ -127,6 +127,7 @@ pub fn handler(ctx: Context, args: InitializeCronJobArgsV0) current_transaction_id: 0, next_transaction_id: 0, bump_seed: ctx.bumps.cron_job, + removed_from_queue: false, num_transactions: 0, }); ctx.accounts.user_cron_jobs.next_cron_job_id += 1; diff --git a/solana-programs/programs/cron/src/instructions/queue_cron_tasks_v0.rs b/solana-programs/programs/cron/src/instructions/queue_cron_tasks_v0.rs index 71d57db..708a441 100644 --- a/solana-programs/programs/cron/src/instructions/queue_cron_tasks_v0.rs +++ b/solana-programs/programs/cron/src/instructions/queue_cron_tasks_v0.rs @@ -12,10 +12,7 @@ use tuktuk_program::{ RunTaskReturnV0, TaskQueueV0, TaskReturnV0, TransactionSourceV0, TriggerV0, }; -use crate::{ - error::ErrorCode, - state::{CronJobTransactionV0, CronJobV0}, -}; +use crate::state::{CronJobTransactionV0, CronJobV0}; pub const QUEUED_TASKS_PER_QUEUE: u8 = 3; // Queue tasks 5 minutes before the cron job is scheduled to run @@ -166,25 +163,33 @@ pub fn handler(ctx: Context) -> Result { system_program: ctx.accounts.system_program.to_account_info(), tasks, })?; + msg!("Queued {} tasks", total_tasks); + // Transfer needed lamports from the cron job to the task queue let cron_job_info = ctx.accounts.cron_job.to_account_info(); let cron_job_min_lamports = Rent::get()?.minimum_balance(cron_job_info.data_len()); let lamports = ctx.accounts.task_queue.min_crank_reward * total_tasks as u64; - require_gt!( - cron_job_info.lamports(), - cron_job_min_lamports + lamports, - ErrorCode::InsufficientFunds - ); - - cron_job_info.sub_lamports(lamports)?; - ctx.accounts - .task_queue - .to_account_info() - .add_lamports(lamports)?; + if cron_job_info.lamports() < cron_job_min_lamports + lamports { + msg!( + "Not enough lamports to fund tasks. Please requeue cron job when you have enough lamports. {}", + cron_job_info.lamports() + ); + ctx.accounts.cron_job.removed_from_queue = true; + Ok(RunTaskReturnV0 { + tasks: vec![], + accounts: vec![], + }) + } else { + cron_job_info.sub_lamports(lamports)?; + ctx.accounts + .task_queue + .to_account_info() + .add_lamports(lamports)?; - Ok(RunTaskReturnV0 { - tasks: vec![], - accounts: used_accounts, - }) + Ok(RunTaskReturnV0 { + tasks: vec![], + accounts: used_accounts, + }) + } } diff --git a/solana-programs/programs/cron/src/state.rs b/solana-programs/programs/cron/src/state.rs index e45881e..d15d2ce 100644 --- a/solana-programs/programs/cron/src/state.rs +++ b/solana-programs/programs/cron/src/state.rs @@ -25,6 +25,9 @@ pub struct CronJobV0 { pub current_transaction_id: u32, pub num_transactions: u32, pub next_transaction_id: u32, + // A cron job is removed from the queue when it no longer has enough lamports to fund tasks + // Once this is set, you need to requeue the cron job. + pub removed_from_queue: bool, pub bump_seed: u8, } diff --git a/solana-programs/programs/tuktuk/src/error.rs b/solana-programs/programs/tuktuk/src/error.rs index 5b666bd..8929d97 100644 --- a/solana-programs/programs/tuktuk/src/error.rs +++ b/solana-programs/programs/tuktuk/src/error.rs @@ -32,6 +32,6 @@ pub enum ErrorCode { InvalidRentRefund, #[msg("Invalid task id")] InvalidTaskId, - #[msg("Invalid discriminator")] - InvalidDiscriminator, + #[msg("Don't use the dummy instruction")] + DummyInstruction, } diff --git a/solana-programs/programs/tuktuk/src/instructions/run_task_v0.rs b/solana-programs/programs/tuktuk/src/instructions/run_task_v0.rs index d4b3d21..9edf6fe 100644 --- a/solana-programs/programs/tuktuk/src/instructions/run_task_v0.rs +++ b/solana-programs/programs/tuktuk/src/instructions/run_task_v0.rs @@ -75,7 +75,9 @@ impl<'a> Iterator for TasksIterator<'a> { } } -#[derive(AnchorSerialize, AnchorDeserialize, Clone, Default)] +// This isn't actually an account, but we want anchor to put it in the IDL and serialize it with a discriminator +#[account] +#[derive(Default)] pub struct RemoteTaskTransactionV0 { // A hash of [task, task_queued_at, ...remaining_accounts] pub verification_hash: [u8; 32], @@ -359,14 +361,8 @@ pub fn handler<'info>( ix_index.checked_sub(1).unwrap() as usize, &ctx.accounts.sysvar_instructions, )?; - let expected_sighash = sighash("tuktuk", "RemoteTaskTransactionV0"); let data = utils::ed25519::verify_ed25519_ix(&ix, signer.to_bytes().as_slice())?; - let mut remote_tx = RemoteTaskTransactionV0::deserialize(&mut &data[8..])?; - - require!( - data[..8] == expected_sighash, - ErrorCode::InvalidDiscriminator - ); + let mut remote_tx = RemoteTaskTransactionV0::try_deserialize(&mut &data[..])?; let num_accounts = remote_tx .transaction @@ -461,13 +457,3 @@ pub fn handler<'info>( Ok(()) } - -pub fn sighash(namespace: &str, name: &str) -> [u8; 8] { - let preimage = format!("{}:{}", namespace, name); - - let mut sighash = [0u8; 8]; - sighash.copy_from_slice( - &anchor_lang::solana_program::hash::hash(preimage.as_bytes()).to_bytes()[..8], - ); - sighash -} diff --git a/solana-programs/programs/tuktuk/src/lib.rs b/solana-programs/programs/tuktuk/src/lib.rs index 09b422b..d2e0bd4 100644 --- a/solana-programs/programs/tuktuk/src/lib.rs +++ b/solana-programs/programs/tuktuk/src/lib.rs @@ -45,4 +45,14 @@ pub mod tuktuk { pub fn close_task_queue_v0(ctx: Context) -> Result<()> { close_task_queue_v0::handler(ctx) } + + pub fn dummy_ix(ctx: Context) -> Result<()> { + Err(error!(crate::error::ErrorCode::DummyInstruction)) + } +} + +#[derive(Accounts)] +pub struct DummyIx<'info> { + #[account(mut)] + pub dummy: Account<'info, RemoteTaskTransactionV0>, } diff --git a/solana-programs/tests/tuktuk.ts b/solana-programs/tests/tuktuk.ts index 0282d86..4c369bb 100644 --- a/solana-programs/tests/tuktuk.ts +++ b/solana-programs/tests/tuktuk.ts @@ -214,6 +214,7 @@ describe("tuktuk", () => { it("allows running a task", async () => { const taskAcc = await program.account.taskV0.fetch(task); + const ixs = await runTask({ program, task, @@ -227,20 +228,16 @@ describe("tuktuk", () => { accounts: remainingAccounts.map((acc) => acc.pubkey), }, }); + const serialized = await RemoteTaskTransactionV0.serialize( + program.coder.accounts, + remoteTx + ); return { - remoteTaskTransaction: RemoteTaskTransactionV0.serialize( - program.coder.types, - remoteTx - ), + remoteTaskTransaction: serialized, remainingAccounts: remainingAccounts, signature: Buffer.from( sign.detached( - Uint8Array.from( - RemoteTaskTransactionV0.serialize( - program.coder.types, - remoteTx - ) - ), + Uint8Array.from(serialized), signer.secretKey ) ), diff --git a/test-crons.sh b/test-crons.sh index 2390e9c..6267139 100755 --- a/test-crons.sh +++ b/test-crons.sh @@ -1,7 +1,7 @@ #! /bin/bash cargo run -p tuktuk-cli -- -u http://127.0.0.1:8899 -w /Users/noahprince/.config/solana/id.json tuktuk-config create --min-deposit 10 -cargo run -p tuktuk-cli -- -u http://127.0.0.1:8899 -w /Users/noahprince/.config/solana/id.json task-queue create --capacity 10 --name Noah --funding-amount 100000000 --min-crank-reward 10000 +cargo run -p tuktuk-cli -- -u http://127.0.0.1:8899 -w /Users/noahprince/.config/solana/id.json task-queue create --capacity 10 --name Noah --funding-amount 100000000 --min-crank-reward 100000000 cargo run -p tuktuk-cli -- -u http://127.0.0.1:8899 -w /Users/noahprince/.config/solana/id.json cron create --name Noah --task-queue-name Noah --schedule "0 * * * * *" --free-tasks-per-transaction 0 --funding-amount 1000000000 --num-tasks-per-queue-call 8 cargo run -p tuktuk-cli -- -u http://127.0.0.1:8899 -w /Users/noahprince/.config/solana/id.json cron-transaction create-remote --url http://localhost:3002/remote --signer $(solana address) --cron-name Noah --index 0 diff --git a/tuktuk-cli/src/cmd/cron.rs b/tuktuk-cli/src/cmd/cron.rs index 1c8d12a..5f5bf95 100644 --- a/tuktuk-cli/src/cmd/cron.rs +++ b/tuktuk-cli/src/cmd/cron.rs @@ -1,14 +1,21 @@ use std::str::FromStr; +use anchor_client::anchor_lang::InstructionData; use clap::{Args, Subcommand}; use serde::Serialize; use solana_sdk::{ instruction::Instruction, pubkey::Pubkey, signer::Signer, system_instruction::transfer, }; use tuktuk::cron; -use tuktuk_program::cron::cron::{ - accounts::{CronJobNameMappingV0, CronJobV0, UserCronJobsV0}, - types::InitializeCronJobArgsV0, +use tuktuk_program::{ + compile_transaction, + cron::cron::{ + accounts::{CronJobNameMappingV0, CronJobV0, UserCronJobsV0}, + client::{accounts::QueueCronTasksV0, args::QueueCronTasksV0 as QueueCronTasksV0Args}, + types::InitializeCronJobArgsV0, + }, + types::QueueTaskArgsV0, + TaskQueueV0, TransactionSourceV0, TriggerV0, }; use tuktuk_sdk::prelude::*; @@ -54,6 +61,10 @@ pub enum Cmd { #[arg(long, help = "Amount to fund the cron job with, in lamports")] amount: u64, }, + Requeue { + #[command(flatten)] + cron: CronArg, + }, Close { #[command(flatten)] cron: CronArg, @@ -103,6 +114,76 @@ impl CronCmd { Ok(ix) } + async fn requeue_cron_job_ix(client: &CliClient, cron_job_key: &Pubkey) -> Result { + let cron_job: CronJobV0 = client + .rpc_client + .anchor_account(&cron_job_key) + .await? + .ok_or_else(|| anyhow::anyhow!("Cron job not found: {}", cron_job_key))?; + let task_queue: TaskQueueV0 = client + .rpc_client + .anchor_account(&cron_job.task_queue) + .await? + .ok_or_else(|| anyhow::anyhow!("Task queue not found: {}", cron_job.task_queue))?; + let id = task_queue + .next_available_task_id() + .ok_or_else(|| anyhow::anyhow!("No available task id"))?; + let remaining_accounts = (cron_job.current_transaction_id + ..cron_job.current_transaction_id + cron_job.num_tasks_per_queue_call as u32) + .map(|i| { + Pubkey::find_program_address( + &[ + b"cron_job_transaction", + cron_job_key.as_ref(), + &i.to_le_bytes(), + ], + &tuktuk_program::cron::ID, + ) + .0 + }) + .collect::>(); + let (queue_tx, _) = compile_transaction( + vec![Instruction { + program_id: tuktuk_program::cron::ID, + accounts: [ + QueueCronTasksV0 { + cron_job: *cron_job_key, + task_queue: cron_job.task_queue, + task_return_account_1: tuktuk::cron::task_return_account_1_key( + &cron_job_key, + ), + task_return_account_2: tuktuk::cron::task_return_account_1_key( + &cron_job_key, + ), + system_program: solana_sdk::system_program::ID, + } + .to_account_metas(None), + remaining_accounts + .iter() + .map(|pubkey| AccountMeta::new_readonly(*pubkey, false)) + .collect::>(), + ] + .concat(), + data: QueueCronTasksV0Args {}.data(), + }], + vec![], + )?; + Ok(tuktuk::task::queue( + client.rpc_client.as_ref(), + client.payer.pubkey(), + cron_job.task_queue, + QueueTaskArgsV0 { + id, + trigger: TriggerV0::Now, + transaction: TransactionSourceV0::CompiledV0(queue_tx), + crank_reward: None, + free_tasks: cron_job.num_tasks_per_queue_call + 1, + }, + ) + .await? + .1) + } + pub async fn run(&self, opts: Opts) -> Result { match &self.cmd { Cmd::Create { @@ -167,6 +248,7 @@ impl CronCmd { next_transaction_id: cron_job.next_transaction_id, balance: cron_job_balance, num_tasks_per_queue_call: *num_tasks_per_queue_call, + removed_from_queue: cron_job.removed_from_queue, })?; } Cmd::Get { cron } => { @@ -195,21 +277,59 @@ impl CronCmd { name: cron_job.name, balance: cron_job_balance, num_tasks_per_queue_call: cron_job.num_tasks_per_queue_call, + removed_from_queue: cron_job.removed_from_queue, }; print_json(&serializable)?; } + Cmd::Requeue { cron } => { + let client = opts.client().await?; + let cron_job_key = cron.get_pubkey(&client).await?.ok_or_else(|| { + anyhow::anyhow!("Must provide cron-name, cron-id, or cron-pubkey") + })?; + let cron_job: CronJobV0 = client + .rpc_client + .anchor_account(&cron_job_key) + .await? + .ok_or_else(|| anyhow::anyhow!("Cron job not found: {}", cron_job_key))?; + + if cron_job.removed_from_queue { + let ix = Self::requeue_cron_job_ix(&client, &cron_job_key).await?; + send_instructions( + client.rpc_client.clone(), + &client.payer, + client.opts.ws_url().as_str(), + vec![ix], + &[], + ) + .await?; + } else { + println!("Cron job does not need to be requeued"); + } + } Cmd::Fund { cron, amount } => { let client = opts.client().await?; let cron_job_key = cron.get_pubkey(&client).await?.ok_or_else(|| { anyhow::anyhow!("Must provide cron-name, cron-id, or cron-pubkey") })?; + let cron_job: CronJobV0 = client + .rpc_client + .anchor_account(&cron_job_key) + .await? + .ok_or_else(|| anyhow::anyhow!("Cron job not found: {}", cron_job_key))?; + let fund_ix = Self::fund_cron_job_ix(&client, &cron_job_key, *amount).await?; + let mut ixs = vec![fund_ix]; + + if cron_job.removed_from_queue { + ixs.push(Self::requeue_cron_job_ix(&client, &cron_job_key).await?); + } + send_instructions( client.rpc_client.clone(), &client.payer, client.opts.ws_url().as_str(), - vec![fund_ix], + ixs, &[], ) .await?; @@ -272,6 +392,7 @@ impl CronCmd { current_exec_ts: cron_job.current_exec_ts, current_transaction_id: cron_job.current_transaction_id, next_transaction_id: cron_job.next_transaction_id, + removed_from_queue: cron_job.removed_from_queue, name: cron_job.name, balance: cron_job_balance, num_tasks_per_queue_call: cron_job.num_tasks_per_queue_call, @@ -304,5 +425,6 @@ pub struct CronJob { pub current_transaction_id: u32, pub next_transaction_id: u32, pub num_tasks_per_queue_call: u8, + pub removed_from_queue: bool, pub balance: u64, } diff --git a/tuktuk-program/idls/cron.json b/tuktuk-program/idls/cron.json index 8edb7e8..a8ca9db 100644 --- a/tuktuk-program/idls/cron.json +++ b/tuktuk-program/idls/cron.json @@ -538,6 +538,10 @@ "name": "next_transaction_id", "type": "u32" }, + { + "name": "removed_from_queue", + "type": "bool" + }, { "name": "bump_seed", "type": "u8"