diff --git a/Cargo.lock b/Cargo.lock index 3d5163350..4aba627ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,7 +98,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef29bd7757778f404bd8ef490a7eb5e61c56a55653cda40c52e27eefdac2ba2b" dependencies = [ "agave-feature-set", - "bincode", + "bincode 1.3.3", "ed25519-dalek 1.0.1", "libsecp256k1 0.7.2", "openssl", @@ -720,6 +720,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -2113,6 +2132,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "ephemeral-rollups-pinocchio" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a175e532d887f6ae43fd6d314ced9d9c44e714dc38692cb312403897c6b6bdce" +dependencies = [ + "bincode 2.0.1", + "pinocchio 0.11.2", + "pinocchio-system 0.6.1", + "solana-address 2.6.1", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2919,6 +2950,19 @@ dependencies = [ "typenum", ] +[[package]] +name = "hydra-api" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25017a85c1e189844ff869fe606fa7621e7346a28329661f03a2b33b8bf44465" +dependencies = [ + "ephemeral-rollups-pinocchio", + "pinocchio 0.11.2", + "solana-address 2.6.1", + "solana-instruction 3.4.0", + "solana-pubkey 4.2.0", +] + [[package]] name = "hyper" version = "1.11.1" @@ -3877,7 +3921,7 @@ version = "0.11.1" source = "git+https://github.com/magicblock-labs/magicblock-engine.git?tag=0.11.1#2e29c4670db6d2d56d6571d9df8f2338c8036d64" dependencies = [ "ahash 0.8.12", - "bincode", + "bincode 1.3.3", "bytemuck", "clonetree", "derive_more", @@ -3972,7 +4016,7 @@ dependencies = [ "arc-swap", "assert_matches", "async-trait", - "bincode", + "bincode 1.3.3", "borsh", "futures-util", "helius-laserstream", @@ -4138,15 +4182,15 @@ name = "magicblock-delegation-program-api" version = "3.1.0" source = "git+https://github.com/magicblock-labs/delegation-program.git?rev=fb6668c7b5ffd6a89bedee1d13b16402fd0e65de#fb6668c7b5ffd6a89bedee1d13b16402fd0e65de" dependencies = [ - "bincode", + "bincode 1.3.3", "borsh", "bytemuck", "const-crypto", "libsodium-rs", "num_enum", - "pinocchio", + "pinocchio 0.10.2", "pinocchio-log", - "pinocchio-system", + "pinocchio-system 0.5.0", "rkyv 0.7.46", "serde", "solana-address 2.6.1", @@ -4298,7 +4342,7 @@ dependencies = [ name = "magicblock-ledger-deprecated" version = "0.15.4" dependencies = [ - "bincode", + "bincode 1.3.3", "byteorder", "fs_extra", "libc", @@ -4325,7 +4369,7 @@ dependencies = [ name = "magicblock-magic-program-api" version = "0.15.4" dependencies = [ - "bincode", + "bincode 1.3.3", "const-crypto", "serde", "solana-instruction 3.4.0", @@ -4559,22 +4603,21 @@ dependencies = [ name = "magicblock-task-scheduler" version = "0.15.4" dependencies = [ - "chrono", - "futures-util", - "magicblock-config", + "hydra-api", "magicblock-core", "magicblock-engine", "magicblock-engine-nucleus", "magicblock-keeper", "magicblock-program", - "rusqlite", + "solana-account 4.3.1", "solana-instruction 3.4.0", - "solana-message 4.2.4", + "solana-keypair", "solana-pubkey 4.2.0", - "solana-rpc-client-api", - "solana-signature 3.4.1", + "solana-rpc-client", + "solana-sha256-hasher 3.1.0", + "solana-signer", + "solana-system-interface 3.2.0", "solana-transaction", - "solana-transaction-error 3.3.1", "thiserror 2.0.20", "tokio", "tokio-util", @@ -4587,7 +4630,7 @@ name = "magicblock-validator" version = "0.15.4" dependencies = [ "anyhow", - "bincode", + "bincode 1.3.3", "console-subscriber", "magicblock-aperture", "magicblock-chainlink", @@ -4616,6 +4659,7 @@ dependencies = [ "solana-rpc-client", "solana-rpc-client-api", "solana-signer", + "solana-system-interface 3.2.0", "solana-transaction", "solana-transaction-error 3.3.1", "thiserror 2.0.20", @@ -5325,10 +5369,23 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06810dac15a4ef83d3dabdb4f2f22fb39c9adff669cd2781da4f716510a647c" dependencies = [ - "solana-account-view", + "solana-account-view 1.0.0", "solana-address 2.6.1", "solana-define-syscall 4.0.1", - "solana-instruction-view", + "solana-instruction-view 1.0.0", + "solana-program-error 3.0.1", +] + +[[package]] +name = "pinocchio" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cababcb62a2e739c7078ae16eda02789a6e3edd21bbdded864e85c38a7914d" +dependencies = [ + "solana-account-view 2.0.0", + "solana-address 2.6.1", + "solana-define-syscall 5.1.0", + "solana-instruction-view 2.1.0", "solana-program-error 3.0.1", ] @@ -5358,7 +5415,17 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24044a0815753862b558e179e78f03f7344cb755de48617a09d7d23b50883b6c" dependencies = [ - "pinocchio", + "pinocchio 0.10.2", + "solana-address 2.6.1", +] + +[[package]] +name = "pinocchio-system" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ae2ee67b42e2ac797dd88312da473c895660e270d8093e0ba2749b885b5778" +dependencies = [ + "pinocchio 0.11.2", "solana-address 2.6.1", ] @@ -6887,7 +6954,7 @@ name = "solana-account" version = "4.3.1" source = "git+https://github.com/magicblock-labs/magicblock-engine.git?tag=0.11.1#2e29c4670db6d2d56d6571d9df8f2338c8036d64" dependencies = [ - "bincode", + "bincode 1.3.3", "bitflags 2.13.2", "serde", "serde_bytes", @@ -6909,7 +6976,7 @@ checksum = "d2f5eca13228e4e175b7390222ac9c13b80543cd42a25a47b4bc6373c63caa6d" dependencies = [ "Inflector", "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bs58", "bv", "serde", @@ -6966,7 +7033,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8f5152a288ef1912300fc6efa6c2d1f9bb55d9398eb6c72326360b8063987da" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "solana-program-error 2.2.2", "solana-program-memory 2.3.1", @@ -6979,7 +7046,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9cf16495d9eb53e3d04e72366a33bb1c20c24e78c171d8b8f5978357b63ae95" dependencies = [ - "bincode", + "bincode 1.3.3", "serde_core", "solana-address 2.6.1", "solana-program-error 3.0.1", @@ -6996,6 +7063,16 @@ dependencies = [ "solana-program-error 3.0.1", ] +[[package]] +name = "solana-account-view" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc141b940560430425ebaadb7645496c45f6a10fad9911d719bd03eab7f4d422" +dependencies = [ + "solana-address 2.6.1", + "solana-program-error 3.0.1", +] + [[package]] name = "solana-address" version = "1.1.0" @@ -7035,7 +7112,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1673f67efe870b64a65cb39e6194be5b26527691ce5922909939961a6e6b395" dependencies = [ - "bincode", + "bincode 1.3.3", "bytemuck", "serde", "serde_derive", @@ -7052,7 +7129,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115b4f773acc4f3f3cb986b0d335e9845c0368c82b0940410935bc11ae065578" dependencies = [ - "bincode", + "bincode 1.3.3", "bytemuck", "serde", "serde_derive", @@ -7111,7 +7188,7 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19a3787b8cf9c9fe3dd360800e8b70982b9e5a8af9e11c354b6665dd4a003adc" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "solana-instruction 2.3.3", ] @@ -7122,7 +7199,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "278a1a5bad62cd9da89ac8d4b7ec444e83caa8ae96aa656dfc27684b28d49a5d" dependencies = [ - "bincode", + "bincode 1.3.3", "serde_core", "solana-instruction-error", ] @@ -7328,7 +7405,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e401ae56aed512821cc7a0adaa412ff97fecd2dff4602be7b1330d2daec0c4" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "serde_derive", "solana-account 3.4.0", @@ -7572,7 +7649,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43f5c5382b449e8e4e3016fb05e418c53d57782d8b5c30aa372fc265654b956d" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "serde_derive", "solana-account 2.2.1", @@ -7591,7 +7668,7 @@ version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd7545e02f91da1d6996f32b18f7796aa01e0682f8f3a7434b82cd1a10448add" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "serde_derive", "solana-account 4.3.1", @@ -7696,7 +7773,7 @@ version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bab5682934bd1f65f8d2c16f21cb532526fcc1a09f796e2cacdb091eee5774ad" dependencies = [ - "bincode", + "bincode 1.3.3", "getrandom 0.2.17", "js-sys", "num-traits", @@ -7714,7 +7791,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37ebb0ffd19263051bc3f683fcc086134b8ff23af894dcb63f7563c7137b42f1" dependencies = [ - "bincode", + "bincode 1.3.3", "borsh", "serde", "serde_derive", @@ -7743,12 +7820,24 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60147e4d0a4620013df40bf30a86dd299203ff12fcb8b593cd51014fce0875d8" dependencies = [ - "solana-account-view", + "solana-account-view 1.0.0", "solana-address 2.6.1", "solana-define-syscall 4.0.1", "solana-program-error 3.0.1", ] +[[package]] +name = "solana-instruction-view" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab7a27d0c4214b9f7389c3dd00b68c93093a67f1dcc5b7893aebe299bbcbb47" +dependencies = [ + "solana-account-view 2.0.0", + "solana-address 2.6.1", + "solana-define-syscall 5.1.0", + "solana-program-error 3.0.1", +] + [[package]] name = "solana-instructions-sysvar" version = "2.2.2" @@ -7962,7 +8051,7 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1796aabce376ff74bf89b78d268fa5e683d7d7a96a0a4e4813ec34de49d5314b" dependencies = [ - "bincode", + "bincode 1.3.3", "blake3", "lazy_static", "serde", @@ -8135,7 +8224,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98eca145bd3545e2fbb07166e895370576e47a00a7d824e325390d33bf467210" dependencies = [ - "bincode", + "bincode 1.3.3", "blake3", "bs58", "bytemuck", @@ -8357,7 +8446,7 @@ version = "4.2.2" source = "git+https://github.com/magicblock-labs/magicblock-engine.git?tag=0.11.1#2e29c4670db6d2d56d6571d9df8f2338c8036d64" dependencies = [ "base64 0.22.1", - "bincode", + "bincode 1.3.3", "cfg-if", "itertools 0.14.0", "scc", @@ -8507,7 +8596,7 @@ dependencies = [ "agave-votor-messages", "async-trait", "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bs58", "futures", "indicatif", @@ -9001,7 +9090,7 @@ dependencies = [ name = "solana-storage-proto" version = "0.15.4" dependencies = [ - "bincode", + "bincode 1.3.3", "bs58", "enum-iterator", "prost", @@ -9122,7 +9211,7 @@ version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d31c37dc2425afc13ddb2fc8365db8dbd02ac860a454c25b3897831036a64dd8" dependencies = [ - "bincode", + "bincode 1.3.3", "libsecp256k1 0.7.2", "num-traits", "solana-account 4.3.1", @@ -9211,7 +9300,7 @@ version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2610e0b091b920da610ad3e5ba850530702f5ac911b4e9dc5ab9a025a772138a" dependencies = [ - "bincode", + "bincode 1.3.3", "log", "solana-account 4.3.1", "solana-bincode 3.1.0", @@ -9236,7 +9325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c3595f95069f3d90f275bb9bd235a1973c4d059028b0a7f81baca2703815db" dependencies = [ "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bytemuck", "bytemuck_derive", "lazy_static", @@ -9273,7 +9362,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ada7045bbfdd802af08fbc9c7e2b56ddabb20599d22e4c3840fcef5e7afb8324" dependencies = [ "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bytemuck", "bytemuck_derive", "lazy_static", @@ -9355,7 +9444,7 @@ name = "solana-transaction-context" version = "4.2.2" source = "git+https://github.com/magicblock-labs/magicblock-engine.git?tag=0.11.1#2e29c4670db6d2d56d6571d9df8f2338c8036d64" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "solana-account 4.3.1", "solana-instruction 3.4.0", @@ -9398,7 +9487,7 @@ dependencies = [ "Inflector", "agave-reserved-account-keys", "base64 0.22.1", - "bincode", + "bincode 1.3.3", "borsh", "bs58", "serde", @@ -9440,7 +9529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "723bb4c23d2b7d0c6bf0fca14637d1e3a802258270ff8dd481fc4a7c67463019" dependencies = [ "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bs58", "serde", "serde_json", @@ -9479,7 +9568,7 @@ version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b80d57478d6599d30acc31cc5ae7f93ec2361a06aefe8ea79bc81739a08af4c3" dependencies = [ - "bincode", + "bincode 1.3.3", "num-derive", "num-traits", "serde", @@ -9503,7 +9592,7 @@ version = "6.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a7a204b455a53fe8c9a26a6885391a5896835e06694dbe0cf37c580f82f128" dependencies = [ - "bincode", + "bincode 1.3.3", "cfg_eval", "num-derive", "num-traits", @@ -9567,7 +9656,7 @@ checksum = "9602bcb1f7af15caef92b91132ec2347e1c51a72ecdbefdaefa3eac4b8711475" dependencies = [ "aes-gcm-siv", "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bytemuck", "bytemuck_derive", "curve25519-dalek 4.1.3", @@ -10427,7 +10516,6 @@ dependencies = [ "futures-sink", "libc", "pin-project-lite", - "slab", "tokio", ] @@ -10834,6 +10922,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "ureq" version = "3.4.1" @@ -10951,6 +11045,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "void" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index cf8b0892f..51f08088c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ members = [ "magicblock-task-scheduler", "magicblock-version", "programs/magicblock", - "storage-proto" + "storage-proto", ] # This prevents a Travis CI error when building for Windows. @@ -47,9 +47,7 @@ version = "0.15.4" magicblock-aml = { path = "magicblock-aml" } magicblock-aperture = { path = "magicblock-aperture" } magicblock-chainlink = { path = "magicblock-chainlink" } -magicblock-committor-program = { path = "magicblock-committor-program", features = [ - "no-entrypoint" -] } +magicblock-committor-program = { path = "magicblock-committor-program", features = ["no-entrypoint"] } magicblock-committor-service = { path = "magicblock-committor-service" } magicblock-config = { path = "magicblock-config" } magicblock-core = { path = "magicblock-core" } @@ -104,6 +102,7 @@ git-version = "0.3.9" helius-laserstream = { git = "https://github.com/magicblock-labs/laserstream-sdk.git", branch = "mbv-4.0+conn-fix" } http-body-util = "0.1.5" humantime = { version = "1.1.1", package = "humantime-serde" } +hydra-api = "0.2" hyper = "1.11.1" hyper-util = "0.1.20" isocountry = "0.3.2" @@ -186,12 +185,8 @@ solana-instructions-sysvar = { version = "4.0" } solana-keypair = { version = "3.1" } solana-loader-v3-interface = { version = "7.0" } solana-loader-v4-interface = { version = "3.1" } -solana-log-collector = { package = "solana-svm-log-collector", version = "=4.2.2", features = [ - "agave-unstable-api" -] } -solana-measure = { package = "solana-svm-measure", version = "=4.2.2", features = [ - "agave-unstable-api" -] } +solana-log-collector = { package = "solana-svm-log-collector", version = "=4.2.2", features = ["agave-unstable-api"] } +solana-measure = { package = "solana-svm-measure", version = "=4.2.2", features = ["agave-unstable-api"] } solana-message = { version = ">=4.2.3,<4.5.0" } solana-metrics = { version = "=4.2.2", features = ["agave-unstable-api"] } solana-native-token = { version = "3.0" } diff --git a/bins/magicblock-validator/Cargo.toml b/bins/magicblock-validator/Cargo.toml index ff3a7d785..fe76c36f7 100644 --- a/bins/magicblock-validator/Cargo.toml +++ b/bins/magicblock-validator/Cargo.toml @@ -38,6 +38,7 @@ solana-pubkey = { workspace = true } solana-rpc-client = { workspace = true } solana-rpc-client-api = { workspace = true } solana-signer = { workspace = true } +solana-system-interface = { workspace = true, features = ["bincode"] } solana-transaction = { workspace = true } solana-transaction-error = { workspace = true } thiserror = { workspace = true } diff --git a/bins/magicblock-validator/src/leader.rs b/bins/magicblock-validator/src/leader.rs index 6d4c61e31..cc86acc93 100644 --- a/bins/magicblock-validator/src/leader.rs +++ b/bins/magicblock-validator/src/leader.rs @@ -24,7 +24,7 @@ use magicblock_services::{ actions_callback_service::ActionsCallbackService, undelegation_request_service::UndelegationRequestService, }; -use magicblock_task_scheduler::{SchedulerDatabase, TaskSchedulerService}; +use magicblock_task_scheduler::TaskSchedulerService; use nucleus::{ metrics::EventTimer, shutdown::{Service, ShutdownManager, ShutdownReason}, @@ -168,12 +168,10 @@ impl Leader { rpc_shutdown.terminate(reason); }); - let task_scheduler_db_path = SchedulerDatabase::path(&engine_ledger); - debug!(path = %task_scheduler_db_path.display(), "Initializing task scheduler"); + debug!("Initializing task scheduler"); let task_scheduler = Some(TaskSchedulerService::new( - &task_scheduler_db_path, - &config.task_scheduler, engine.clone(), + config.aperture.listen.http(), config.engine.blockstore.blocktime, )?); timer.record("Task scheduler initialized"); diff --git a/config.validator.example.toml b/config.validator.example.toml index 5e67d5249..e91a4564c 100644 --- a/config.validator.example.toml +++ b/config.validator.example.toml @@ -232,33 +232,6 @@ request-timeout = "5s" # Env: MBV_CHAINLINK__RISK__CHECK_STRATEGY check-strategy = "all-signers" -# ============================================================================== -# Internal Task Scheduler -# ============================================================================== -[task-scheduler] -# If true, clears all pending scheduled tasks on startup. -# Default: false -# Env: MBV_TASK_SCHEDULER__RESET -reset = false - -# The minimum interval between task executions. -# Supports humantime (e.g. "10ms", "1s"). -# Default: "10ms" -# Env: MBV_TASK_SCHEDULER__MIN_INTERVAL -min-interval = "10ms" - -# How long failed task executions and failed scheduling records are retained. -# Supports humantime (e.g. "1h", "7d"). -# Default: "14d" -# Env: MBV_TASK_SCHEDULER__FAILED_TASK_RETENTION -failed-task-retention = "14d" - -# How often old failed task executions and failed scheduling records are deleted. -# Supports humantime (e.g. "1m", "1h"). -# Default: "1h" -# Env: MBV_TASK_SCHEDULER__FAILED_TASK_CLEANUP_INTERVAL -failed-task-cleanup-interval = "1h" - # ============================================================================== # Pre-loaded Programs # ============================================================================== diff --git a/magicblock-config/src/config/mod.rs b/magicblock-config/src/config/mod.rs index f23988db3..52a7fcf46 100644 --- a/magicblock-config/src/config/mod.rs +++ b/magicblock-config/src/config/mod.rs @@ -6,7 +6,6 @@ pub mod grpc; pub mod ledger; pub mod metrics; pub mod program; -pub mod scheduler; pub use aperture::ApertureConfig; pub use chain::{ @@ -17,4 +16,3 @@ pub use engine::{EngineConfig, FollowerReplication, LeaderReplication}; pub use grpc::GrpcConfig; pub use ledger::LedgerConfig; pub use program::LoadableProgram; -pub use scheduler::TaskSchedulerConfig; diff --git a/magicblock-config/src/config/scheduler.rs b/magicblock-config/src/config/scheduler.rs deleted file mode 100644 index e27eadee6..000000000 --- a/magicblock-config/src/config/scheduler.rs +++ /dev/null @@ -1,42 +0,0 @@ -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -use crate::consts; - -/// Configuration for the internal task scheduler. -#[derive(Deserialize, Serialize, Debug, Clone)] -#[serde(rename_all = "kebab-case", deny_unknown_fields, default)] -pub struct TaskSchedulerConfig { - /// If true, clears all pending scheduled tasks on startup. - pub reset: bool, - /// The minimum interval between task executions. - /// Supports humantime (e.g. "10ms", "1s"). - #[serde(with = "humantime")] - pub min_interval: Duration, - /// How long failed task and scheduling records are retained. - /// Supports humantime (e.g. "1h", "7d"). - #[serde(with = "humantime")] - pub failed_task_retention: Duration, - /// How often failed task and scheduling records are cleaned up. - /// Supports humantime (e.g. "1m", "1h"). - #[serde(with = "humantime")] - pub failed_task_cleanup_interval: Duration, -} - -impl Default for TaskSchedulerConfig { - fn default() -> Self { - Self { - reset: false, - min_interval: Duration::from_millis( - consts::DEFAULT_TASK_SCHEDULER_MIN_INTERVAL_MILLIS, - ), - failed_task_retention: Duration::from_secs( - consts::DEFAULT_TASK_SCHEDULER_FAILED_TASK_RETENTION_SECS, - ), - failed_task_cleanup_interval: Duration::from_secs( - consts::DEFAULT_TASK_SCHEDULER_FAILED_TASK_CLEANUP_INTERVAL_SECS, - ), - } - } -} diff --git a/magicblock-config/src/consts.rs b/magicblock-config/src/consts.rs index 2378bc25c..af7c44bc2 100644 --- a/magicblock-config/src/consts.rs +++ b/magicblock-config/src/consts.rs @@ -65,13 +65,6 @@ pub const DEFAULT_METRICS_ADDR: &str = "0.0.0.0:9000"; /// Default frequency of metrics collection in seconds pub const DEFAULT_METRICS_COLLECT_FREQUENCY_SEC: u64 = 30; -// Task Scheduler Defaults -pub const DEFAULT_TASK_SCHEDULER_MIN_INTERVAL_MILLIS: u64 = 10; -pub const DEFAULT_TASK_SCHEDULER_FAILED_TASK_RETENTION_SECS: u64 = - 14 * 24 * 60 * 60; // 14 days -pub const DEFAULT_TASK_SCHEDULER_FAILED_TASK_CLEANUP_INTERVAL_SECS: u64 = - 60 * 60; // 1 hour - // ChainLink Defaults /// Default delay in milliseconds between resubscribing to accounts after a pubsub reconnection pub const DEFAULT_RESUBSCRIPTION_DELAY_MS: u64 = 50; diff --git a/magicblock-config/src/lib.rs b/magicblock-config/src/lib.rs index 22119af16..9cf887b18 100644 --- a/magicblock-config/src/lib.rs +++ b/magicblock-config/src/lib.rs @@ -27,10 +27,7 @@ mod tests; pub mod types; use crate::{ - config::{ - ChainLinkConfig, CommittorConfig, LedgerConfig, LoadableProgram, - TaskSchedulerConfig, - }, + config::{ChainLinkConfig, CommittorConfig, LedgerConfig, LoadableProgram}, types::Remote, }; @@ -74,7 +71,6 @@ pub struct LeaderParams { pub commit: CommittorConfig, pub ledger: LedgerConfig, pub chainlink: ChainLinkConfig, - pub task_scheduler: TaskSchedulerConfig, pub programs: Vec, } diff --git a/magicblock-magic-program-api/src/ephemeral.rs b/magicblock-magic-program-api/src/ephemeral.rs new file mode 100644 index 000000000..3ac2b4e8e --- /dev/null +++ b/magicblock-magic-program-api/src/ephemeral.rs @@ -0,0 +1,13 @@ +use solana_instruction::error::InstructionError; + +use crate::{EPHEMERAL_ACCOUNT_STATIC_SIZE, EPHEMERAL_RENT_PER_BYTE}; + +/// Calculates rent for an ephemeral account based on its data length. +pub fn rent_for(data_len: u32) -> Result { + let total_size = u64::from(data_len) + .checked_add(EPHEMERAL_ACCOUNT_STATIC_SIZE) + .ok_or(InstructionError::ArithmeticOverflow)?; + total_size + .checked_mul(EPHEMERAL_RENT_PER_BYTE) + .ok_or(InstructionError::ArithmeticOverflow) +} diff --git a/magicblock-magic-program-api/src/instruction.rs b/magicblock-magic-program-api/src/instruction.rs index 6b6a2e380..33bf408c2 100644 --- a/magicblock-magic-program-api/src/instruction.rs +++ b/magicblock-magic-program-api/src/instruction.rs @@ -364,18 +364,10 @@ pub enum MagicBlockInstruction { /// | `1` | Account. Account to evict. | WRITE | EvictAccount { pubkey: Pubkey }, - /// Executes a crank - /// - /// # Account references - /// | Index | Account | Access | - /// | --- | --- | --- | - /// | `0` | Validator Authority. Authorizes crank execution. | SIGNER | - /// | `1` | Crank signer PDA. PDA signer used by embedded instructions. | - | - /// | `2..n` | Instruction accounts. Accounts required by the embedded instructions. | - | - ExecuteCrank { - authority: Pubkey, - instructions: Vec, - }, + /// Reserved: crank execution moved to hydra. The variant is kept so + /// `CloseMagicAta` retains its wire discriminant for existing clients + /// (MagicRoot / eSPL). + _ExecuteCrank, /// Closes a drained Magic ATA previously created via /// `CreateMagicAta`. No-op unless the account matches the diff --git a/magicblock-magic-program-api/src/lib.rs b/magicblock-magic-program-api/src/lib.rs index 8b7f45f55..6a9419477 100644 --- a/magicblock-magic-program-api/src/lib.rs +++ b/magicblock-magic-program-api/src/lib.rs @@ -2,6 +2,7 @@ pub mod args; pub mod compat; +pub mod ephemeral; pub mod instruction; pub mod pda; pub mod response; @@ -37,3 +38,11 @@ pub const MAGIC_CONTEXT_SIZE: usize = 1024 * 1024 * 5; // 5 MB /// Rent rate for ephemeral accounts: 32 lamports per byte. /// This is ~109x cheaper than Solana's base rent (3,480 lamports/byte). pub const EPHEMERAL_RENT_PER_BYTE: u64 = 32; + +/// Fixed per-account storage overhead charged as rent on top of the data. +/// +/// Rent is a policy of the magic program rather than of the account layout, so +/// the value is pinned here: lamports (8) + owner (32) + slot (8) + flags (4) + +/// data capacity (4) + data length (4). It previously came from the account +/// crate, which no longer publishes its static size. +pub const EPHEMERAL_ACCOUNT_STATIC_SIZE: u64 = 60; diff --git a/magicblock-magic-program-api/src/pda.rs b/magicblock-magic-program-api/src/pda.rs index 234713ca5..21081b58e 100644 --- a/magicblock-magic-program-api/src/pda.rs +++ b/magicblock-magic-program-api/src/pda.rs @@ -1,14 +1,5 @@ use crate::Pubkey; -pub const CRANK_SEED: &[u8] = b"crank-executor"; -pub fn crank_signer_pda(authority: &Pubkey) -> Pubkey { - Pubkey::find_program_address( - &[CRANK_SEED, authority.as_ref()], - &crate::CRANK_PROGRAM_ID, - ) - .0 -} - /// Callback signer PDA info pub const CALLBACK_SEED: &[u8] = b"callback-executor"; const CALLBACK_SIGNER_PDA: ([u8; 32], u8) = diff --git a/magicblock-runtime/src/lib.rs b/magicblock-runtime/src/lib.rs index d63471bcc..a3ebd1d99 100644 --- a/magicblock-runtime/src/lib.rs +++ b/magicblock-runtime/src/lib.rs @@ -7,7 +7,7 @@ use std::{collections::HashMap, fs, path::PathBuf}; use keeper::builder::KeeperBuilder; use magicblock_config::config::{EngineConfig, LoadableProgram}; use magicblock_program::magicblock_processor::{ - CallbackEntrypoint, CrankEntrypoint, Entrypoint, EphemeralSystemEntrypoint, + CallbackEntrypoint, Entrypoint, EphemeralSystemEntrypoint, }; use solana_program_runtime::{ invoke_context::BuiltinFunctionWithContext, @@ -57,10 +57,6 @@ fn builtins() -> HashMap { magicblock_program::ID, (Entrypoint::vm, Entrypoint::codegen), ); - builtins.insert( - magicblock_program::CRANK_PROGRAM_ID, - (CrankEntrypoint::vm, CrankEntrypoint::codegen), - ); builtins.insert( magicblock_program::CALLBACK_PROGRAM_ID, (CallbackEntrypoint::vm, CallbackEntrypoint::codegen), diff --git a/magicblock-task-scheduler/Cargo.toml b/magicblock-task-scheduler/Cargo.toml index 30a0f5709..d28327b35 100644 --- a/magicblock-task-scheduler/Cargo.toml +++ b/magicblock-task-scheduler/Cargo.toml @@ -8,24 +8,23 @@ repository.workspace = true version.workspace = true [dependencies] -chrono = { workspace = true } engine = { workspace = true } -futures-util = { workspace = true } +hydra-api = { workspace = true, features = ["client", "ephemeral"] } keeper = { workspace = true } -magicblock-config = { workspace = true } magicblock-program = { workspace = true } nucleus = { workspace = true } -rusqlite = { workspace = true } +solana-account = { workspace = true } solana-instruction = { workspace = true, features = ["wincode"] } -solana-message = { workspace = true } +solana-keypair = { workspace = true } solana-pubkey = { workspace = true } -solana-rpc-client-api = { workspace = true } -solana-signature = { workspace = true } +solana-sha256-hasher = { workspace = true } +solana-signer = { workspace = true } +solana-system-interface = { workspace = true, features = ["bincode"] } solana-transaction = { workspace = true } -solana-transaction-error = { workspace = true } +solana-rpc-client = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -tokio-util = { workspace = true, features = ["time"] } +tokio-util = { workspace = true } tracing = { workspace = true } wincode = { workspace = true } diff --git a/magicblock-task-scheduler/src/crank.rs b/magicblock-task-scheduler/src/crank.rs new file mode 100644 index 000000000..383db77b8 --- /dev/null +++ b/magicblock-task-scheduler/src/crank.rs @@ -0,0 +1,114 @@ +use std::time::Duration as StdDuration; + +use hydra_api::instruction::{CreateArgs, SchedMeta, ScheduledIx, ephemeral}; +use solana_instruction::Instruction; +use solana_pubkey::Pubkey; + +/// Magic Program cranks used a u64 ID, Hydra cranks use a 32-byte seed. +/// The seed is `hash(authority, task_id)`, so each authority gets its own crank +/// namespace: a different authority scheduling the same `task_id` gets an +/// independent crank, and cancel/reschedule need no database lookup. +fn crank_seed(authority: &Pubkey, task_id: i64) -> [u8; 32] { + solana_sha256_hasher::hashv(&[authority.as_ref(), &task_id.to_le_bytes()]) + .to_bytes() +} + +/// Derives the deterministic hydra crank account address for a task. +pub fn crank_pubkey(authority: &Pubkey, task_id: i64) -> Pubkey { + let seed = crank_seed(authority, task_id); + ephemeral::find_crank_pda(&seed).0 +} + +/// Builds the hydra `Create` instruction embedding the task's instructions as +/// the scheduled crank payload. Account signer flags are intentionally dropped: +/// hydra rejects scheduled instructions that declare signers. +#[allow(clippy::too_many_arguments)] +pub fn build_create_ix( + sponsor: &Pubkey, + authority: &Pubkey, + task_id: i64, + crank: Pubkey, + start_slot: u64, + interval_slots: u64, + iterations: u64, + instructions: &[Instruction], +) -> Instruction { + let seed = crank_seed(authority, task_id); + + let metas_per_ix: Vec> = instructions + .iter() + .map(|ix| { + ix.accounts + .iter() + .map(|acc| SchedMeta { + pubkey: acc.pubkey.to_bytes(), + is_writable: acc.is_writable, + }) + .collect() + }) + .collect(); + + let scheduled: Vec = instructions + .iter() + .zip(metas_per_ix.iter()) + .map(|(ix, metas)| ScheduledIx { + program_id: ix.program_id.to_bytes(), + metas: metas.as_slice(), + data: ix.data.as_slice(), + }) + .collect(); + + let args = CreateArgs { + seed, + // The sponsor is also the cancel authority for the crank. + authority: sponsor.to_bytes(), + start_slot, + interval_slots, + remaining: iterations, + priority_tip: 0, + cu_limit: 0, + scheduled: scheduled.as_slice(), + }; + + ephemeral::create(*sponsor, crank, &args) +} + +pub fn is_valid_task_interval(interval: i64) -> bool { + interval > 0 && interval < u32::MAX as i64 +} + +/// Converts a millisecond execution interval into a slot count (rounding up, +/// with a one-slot minimum) for hydra's slot-based cadence. +pub fn interval_slots(interval_millis: i64, slot_interval: StdDuration) -> u64 { + let slot_millis = (slot_interval.as_millis() as i64).max(1); + let interval_millis = interval_millis.max(0); + // Ceiling division without the unstable `i64::div_ceil`. + let slots = (interval_millis + slot_millis - 1) / slot_millis; + slots.max(1) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_interval_millis_rounds_up_to_slots() { + let slot = StdDuration::from_millis(50); + assert_eq!(interval_slots(1, slot), 1); + assert_eq!(interval_slots(50, slot), 1); + assert_eq!(interval_slots(51, slot), 2); + assert_eq!(interval_slots(100, slot), 2); + } + + #[test] + fn test_crank_pubkey_namespaced_by_authority_and_id() { + let a = Pubkey::new_unique(); + let b = Pubkey::new_unique(); + // Deterministic. + assert_eq!(crank_pubkey(&a, 1), crank_pubkey(&a, 1)); + // Different task id -> different crank. + assert_ne!(crank_pubkey(&a, 1), crank_pubkey(&a, 2)); + // Different authority, same id -> different crank (per-authority namespace). + assert_ne!(crank_pubkey(&a, 1), crank_pubkey(&b, 1)); + } +} diff --git a/magicblock-task-scheduler/src/db.rs b/magicblock-task-scheduler/src/db.rs deleted file mode 100644 index 1f532e462..000000000 --- a/magicblock-task-scheduler/src/db.rs +++ /dev/null @@ -1,586 +0,0 @@ -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::Arc, -}; - -use chrono::Utc; -use magicblock_program::args::ScheduleTaskRequest; -use rusqlite::{Connection, OptionalExtension, params}; -use solana_instruction::Instruction; -use solana_pubkey::Pubkey; -use tokio::sync::Mutex; - -use crate::errors::TaskSchedulerResult; - -/// Represents a task in the database -/// Uses i64 for all timestamps and IDs to avoid overflows -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DbTask { - /// Unique identifier for this task - pub id: i64, - /// Instructions to execute when triggered - pub instructions: Vec, - /// Authority that can modify or cancel this task - pub authority: Pubkey, - /// How frequently the task should be executed, in milliseconds - pub execution_interval_millis: i64, - /// Number of times this task still needs to be executed. - pub executions_left: i64, - /// Timestamp of the last execution of this task in milliseconds since UNIX epoch - pub last_execution_millis: i64, - /// Timestamp of the latest persisted mutation of this task in milliseconds since UNIX epoch - pub updated_at: i64, -} - -impl From for DbTask { - fn from(task: ScheduleTaskRequest) -> Self { - Self { - id: task.id, - instructions: task.instructions, - authority: task.authority, - execution_interval_millis: task.execution_interval_millis, - executions_left: task.iterations, - last_execution_millis: 0, - updated_at: 0, - } - } -} - -#[derive(Debug, Clone, Copy)] -pub struct CrankSuccessUpdate { - /// Task whose successful execution should be persisted. - pub task_id: i64, - /// Actual execution timestamp to store in `tasks.last_execution_millis`. - pub last_execution_millis: i64, - /// Optimistic concurrency token. The update is applied only if the current - /// row still has this `tasks.updated_at` value. - pub expected_updated_at: i64, -} - -#[derive(Debug, Clone, Copy)] -pub struct CrankSuccessRemoval { - /// Task whose final successful execution should remove it from `tasks`. - pub task_id: i64, - /// Optimistic concurrency token. The removal is applied only if the current - /// row still has this `tasks.updated_at` value. - pub expected_updated_at: i64, -} - -#[derive(Debug, Clone)] -pub struct CrankFailedMove { - /// Task to remove from `tasks` and append to `failed_tasks`. - pub task_id: i64, - /// Optimistic concurrency token. The move is applied only if the current - /// row still has this `tasks.updated_at` value. - pub expected_updated_at: i64, - /// Error text persisted in `failed_tasks.error` when the move succeeds. - pub error: String, -} - -#[derive(Debug, Clone, Copy)] -pub struct CrankRetryCheck { - /// Task that failed but remains retryable. - pub task_id: i64, - /// Optimistic concurrency token. The retry is considered valid only if the - /// current row still has this `tasks.updated_at` value. - pub expected_updated_at: i64, -} - -/// Applied metadata for a successful task execution that remains scheduled. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CrankSuccessUpdateCompletion { - /// Optimistic token supplied by the caller and matched against the previous - /// `tasks.updated_at` value. - pub expected_updated_at: i64, - /// New `tasks.updated_at` value written by the database transaction. - pub new_updated_at: i64, -} - -/// Applied metadata for a task removed after its final successful execution. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CrankSuccessRemovalCompletion { - /// Optimistic token supplied by the caller and matched against the previous - /// `tasks.updated_at` value before deleting the row. - pub expected_updated_at: i64, -} - -/// Applied metadata for a task moved from `tasks` to `failed_tasks`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CrankFailedMoveCompletion { - /// Optimistic token supplied by the caller and matched against the previous - /// `tasks.updated_at` value before moving the task. - pub expected_updated_at: i64, -} - -/// Applied metadata for a retryable failed task that still matches its DB row. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CrankRetryCheckCompletion { - /// Actual `tasks.updated_at` value read from the database. This equals the - /// requested optimistic token when the retry check succeeds. - pub current_updated_at: i64, -} - -/// Results for a crank batch transaction keyed by task ID. -/// -/// Each map contains only entries whose optimistic `expected_updated_at` token -/// matched the database state and whose corresponding DB operation succeeded. -#[derive(Debug, Default)] -pub struct CrankBatchCompletion { - /// Continued successful executions. Values include both the matched - /// optimistic token and the new `tasks.updated_at` written by the DB. - pub success_updates: HashMap, - /// Final successful executions removed from `tasks`. Values contain the - /// matched optimistic token used for the delete. - pub success_removals: HashMap, - /// Failed executions moved to `failed_tasks`. Values contain the matched - /// optimistic token used for the move. - pub failed_moves: HashMap, - /// Retryable failed executions whose `tasks` row still exists unchanged. - /// Values contain the actual `tasks.updated_at` read from the DB. - pub retry_checks: HashMap, -} - -#[derive(Debug, Clone)] -pub struct FailedScheduling { - pub id: i64, - pub timestamp: i64, - pub task_id: i64, - pub error: String, -} - -#[derive(Debug, Clone)] -pub struct FailedTask { - pub id: i64, - pub timestamp: i64, - pub task_id: i64, - pub error: String, -} - -#[derive(Clone)] -pub struct SchedulerDatabase { - conn: Arc>, -} - -impl SchedulerDatabase { - pub fn path(path: &Path) -> PathBuf { - path.join("task_scheduler.sqlite") - } - - pub fn new>(path: P) -> TaskSchedulerResult { - let conn = Connection::open(path)?; - conn.execute_batch( - " - PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - PRAGMA busy_timeout=5000; - PRAGMA cache_size=-65536; - ", - )?; - - // Create tables - conn.execute( - "CREATE TABLE IF NOT EXISTS tasks ( - id INTEGER PRIMARY KEY, - instructions BLOB NOT NULL, - authority TEXT NOT NULL, - execution_interval_millis INTEGER NOT NULL, - executions_left INTEGER NOT NULL, - last_execution_millis INTEGER NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )", - [], - )?; - - conn.execute( - "CREATE TABLE IF NOT EXISTS failed_scheduling ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp INTEGER NOT NULL, - task_id INTEGER, - error TEXT NOT NULL - )", - [], - )?; - - conn.execute( - "CREATE TABLE IF NOT EXISTS failed_tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp INTEGER NOT NULL, - task_id INTEGER, - error TEXT NOT NULL - )", - [], - )?; - - Ok(Self { - conn: Arc::new(Mutex::new(conn)), - }) - } - - pub async fn insert_task(&self, task: &DbTask) -> TaskSchedulerResult { - let instructions_bin = wincode::serialize(&task.instructions)?; - let authority_str = task.authority.to_string(); - let conn = self.conn.lock().await; - let previous_updated_at: Option = conn - .query_row( - "SELECT updated_at FROM tasks WHERE id = ?", - [task.id], - |row| row.get(0), - ) - .optional()?; - let now = previous_updated_at - .map(|updated_at| Utc::now().timestamp_millis().max(updated_at + 1)) - .unwrap_or_else(|| Utc::now().timestamp_millis()); - - conn.execute( - "INSERT OR REPLACE INTO tasks - (id, instructions, authority, execution_interval_millis, executions_left, last_execution_millis, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - params![ - task.id, - instructions_bin, - authority_str, - task.execution_interval_millis, - task.executions_left, - task.last_execution_millis, - now, - now, - ], - )?; - - Ok(now) - } - - pub async fn update_task_after_execution( - &self, - task_id: i64, - last_execution: i64, - ) -> TaskSchedulerResult<()> { - let now = Utc::now().timestamp_millis(); - - self.conn.lock().await.execute( - "UPDATE tasks - SET executions_left = executions_left - 1, - last_execution_millis = ?, - updated_at = ? - WHERE id = ?", - params![last_execution, now, task_id], - )?; - - Ok(()) - } - - pub async fn insert_failed_scheduling( - &self, - task_id: i64, - error: String, - ) -> TaskSchedulerResult<()> { - self.conn.lock().await.execute( - "INSERT INTO failed_scheduling (timestamp, task_id, error) VALUES (?, ?, ?)", - params![Utc::now().timestamp_millis(), task_id, error], - )?; - - Ok(()) - } - - pub async fn insert_failed_task( - &self, - task_id: i64, - error: String, - ) -> TaskSchedulerResult<()> { - self.conn.lock().await.execute( - "INSERT INTO failed_tasks (timestamp, task_id, error) VALUES (?, ?, ?)", - params![Utc::now().timestamp_millis(), task_id, error], - )?; - - Ok(()) - } - - pub async fn unschedule_task( - &self, - task_id: i64, - ) -> TaskSchedulerResult<()> { - self.conn.lock().await.execute( - "UPDATE tasks SET executions_left = 0 WHERE id = ?", - [task_id], - )?; - - Ok(()) - } - - pub async fn remove_task(&self, task_id: i64) -> TaskSchedulerResult<()> { - self.conn - .lock() - .await - .execute("DELETE FROM tasks WHERE id = ?", [task_id])?; - - Ok(()) - } - - pub async fn get_task( - &self, - task_id: i64, - ) -> TaskSchedulerResult> { - let db = self.conn.lock().await; - let mut stmt = db.prepare( - "SELECT id, instructions, authority, execution_interval_millis, executions_left, last_execution_millis, - updated_at - FROM tasks WHERE id = ?" - )?; - - let mut rows = stmt.query_map([task_id], |row| { - let instructions_bin: Vec = row.get(1)?; - let instructions: Vec = - wincode::deserialize(&instructions_bin).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "instructions: {}", - e - )) - })?; - let authority_str: String = row.get(2)?; - let authority: Pubkey = authority_str.parse().map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "authority: {}", - e - )) - })?; - - Ok(DbTask { - id: row.get(0)?, - instructions, - authority, - execution_interval_millis: row.get(3)?, - executions_left: row.get(4)?, - last_execution_millis: row.get(5)?, - updated_at: row.get(6)?, - }) - })?; - - Ok(rows.next().transpose()?) - } - - pub async fn get_tasks(&self) -> TaskSchedulerResult> { - let db = self.conn.lock().await; - let mut stmt = db.prepare( - "SELECT id, instructions, authority, execution_interval_millis, executions_left, last_execution_millis, - updated_at - FROM tasks" - )?; - - let mut tasks = Vec::new(); - let rows = stmt.query_map([], |row| { - let instructions_bin: Vec = row.get(1)?; - let instructions: Vec = - wincode::deserialize(&instructions_bin).map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "instructions: {}", - e - )) - })?; - let authority_str: String = row.get(2)?; - let authority: Pubkey = authority_str.parse().map_err(|e| { - rusqlite::Error::InvalidParameterName(format!( - "authority: {}", - e - )) - })?; - - Ok(DbTask { - id: row.get(0)?, - instructions, - authority, - execution_interval_millis: row.get(3)?, - executions_left: row.get(4)?, - last_execution_millis: row.get(5)?, - updated_at: row.get(6)?, - }) - })?; - - for row in rows { - tasks.push(row?); - } - - Ok(tasks) - } - - pub async fn get_task_ids(&self) -> TaskSchedulerResult> { - let db = self.conn.lock().await; - let mut stmt = db.prepare( - "SELECT id - FROM tasks", - )?; - - let rows = stmt.query_map([], |row| row.get(0))?; - - Ok(rows.collect::, rusqlite::Error>>()?) - } - - pub async fn get_failed_schedulings( - &self, - ) -> TaskSchedulerResult> { - let db = self.conn.lock().await; - let mut stmt = db.prepare( - "SELECT * - FROM failed_scheduling", - )?; - - let rows = stmt.query_map([], |row| { - Ok(FailedScheduling { - id: row.get(0)?, - timestamp: row.get(1)?, - task_id: row.get(2)?, - error: row.get(3)?, - }) - })?; - - Ok(rows.collect::, rusqlite::Error>>()?) - } - - pub async fn get_failed_tasks( - &self, - ) -> TaskSchedulerResult> { - let db = self.conn.lock().await; - let mut stmt = db.prepare( - "SELECT * - FROM failed_tasks", - )?; - - let rows = stmt.query_map([], |row| { - Ok(FailedTask { - id: row.get(0)?, - timestamp: row.get(1)?, - task_id: row.get(2)?, - error: row.get(3)?, - }) - })?; - - Ok(rows.collect::, rusqlite::Error>>()?) - } - - /// One transaction for a crank completion batch — replaces N separate commits from - /// per-task `update_task_after_execution` / `remove_task` / `move_task_to_failed` calls. - pub async fn apply_crank_batch_completion( - &self, - success_updates: &[CrankSuccessUpdate], - success_removals: &[CrankSuccessRemoval], - failed_moves: &[CrankFailedMove], - retry_checks: &[CrankRetryCheck], - ) -> TaskSchedulerResult { - if success_updates.is_empty() - && success_removals.is_empty() - && failed_moves.is_empty() - && retry_checks.is_empty() - { - return Ok(CrankBatchCompletion::default()); - } - - let mut conn = self.conn.lock().await; - let tx = conn.transaction()?; - let mut completion = CrankBatchCompletion::default(); - - // Continued executions — decrement executions_left via existing UPDATE semantics - for update in success_updates { - let now = Utc::now() - .timestamp_millis() - .max(update.expected_updated_at + 1); - let affected = tx.execute( - "UPDATE tasks SET executions_left = executions_left - 1, - last_execution_millis = ?, updated_at = ? - WHERE id = ? AND updated_at = ?", - params![ - update.last_execution_millis, - now, - update.task_id, - update.expected_updated_at - ], - )?; - if affected == 1 { - completion.success_updates.insert( - update.task_id, - CrankSuccessUpdateCompletion { - expected_updated_at: update.expected_updated_at, - new_updated_at: now, - }, - ); - } - } - - for removal in success_removals { - let affected = tx.execute( - "DELETE FROM tasks WHERE id = ? AND updated_at = ?", - params![removal.task_id, removal.expected_updated_at], - )?; - if affected == 1 { - completion.success_removals.insert( - removal.task_id, - CrankSuccessRemovalCompletion { - expected_updated_at: removal.expected_updated_at, - }, - ); - } - } - - for failed in failed_moves { - let affected = tx.execute( - "DELETE FROM tasks WHERE id = ? AND updated_at = ?", - params![failed.task_id, failed.expected_updated_at], - )?; - if affected == 1 { - let now = Utc::now().timestamp_millis(); - tx.execute( - "INSERT INTO failed_tasks (timestamp, task_id, error) - VALUES (?, ?, ?)", - params![now, failed.task_id, failed.error], - )?; - completion.failed_moves.insert( - failed.task_id, - CrankFailedMoveCompletion { - expected_updated_at: failed.expected_updated_at, - }, - ); - } - } - - for check in retry_checks { - let matched: Option = tx - .query_row( - "SELECT updated_at FROM tasks - WHERE id = ? AND updated_at = ?", - params![check.task_id, check.expected_updated_at], - |row| row.get(0), - ) - .optional()?; - if let Some(updated_at) = matched { - completion.retry_checks.insert( - check.task_id, - CrankRetryCheckCompletion { - current_updated_at: updated_at, - }, - ); - } - } - - tx.commit()?; - Ok(completion) - } - - pub async fn delete_failed_records_older_than( - &self, - cutoff_timestamp_millis: i64, - ) -> TaskSchedulerResult { - let mut conn = self.conn.lock().await; - let tx = conn.transaction()?; - let failed_scheduling_deleted = tx.execute( - "DELETE FROM failed_scheduling WHERE timestamp < ?", - [cutoff_timestamp_millis], - )?; - let failed_tasks_deleted = tx.execute( - "DELETE FROM failed_tasks WHERE timestamp < ?", - [cutoff_timestamp_millis], - )?; - tx.commit()?; - - Ok(failed_scheduling_deleted + failed_tasks_deleted) - } -} diff --git a/magicblock-task-scheduler/src/errors.rs b/magicblock-task-scheduler/src/errors.rs index 004ea4e89..0474b30cb 100644 --- a/magicblock-task-scheduler/src/errors.rs +++ b/magicblock-task-scheduler/src/errors.rs @@ -4,33 +4,9 @@ pub type TaskSchedulerResult = Result; #[derive(Error, Debug)] pub enum TaskSchedulerError { - #[error("Invalid configuration: {0}")] - InvalidConfiguration(String), - - #[error(transparent)] - DatabaseConnection(#[from] rusqlite::Error), - - #[error(transparent)] - Wincode(#[from] wincode::WriteError), - - #[error(transparent)] - Rpc(#[from] Box), - - #[error("Transaction execution failed: {0}")] - TransactionExecution(String), - - #[error(transparent)] - Io(#[from] std::io::Error), - #[error(transparent)] Keeper(#[from] keeper::error::KeeperError), - #[error("Crank worker failed: {0}")] - CrankWorker(#[from] tokio::task::JoinError), - - #[error("Task {0} already exists and is owned by {1}, not {2}")] - UnauthorizedReplacing(i64, String, String), - - #[error("Batch size mismatch: expected {0}, got {1}")] - SizeMismatch(usize, usize), + #[error(transparent)] + RpcClient(#[from] solana_rpc_client::api::client_error::Error), } diff --git a/magicblock-task-scheduler/src/lib.rs b/magicblock-task-scheduler/src/lib.rs index 4ab54ed39..750025368 100644 --- a/magicblock-task-scheduler/src/lib.rs +++ b/magicblock-task-scheduler/src/lib.rs @@ -1,9 +1,13 @@ #![doc = include_str!("../README.md")] -pub mod db; +pub mod crank; pub mod errors; pub mod service; -pub use db::SchedulerDatabase; +/// Derives the on-chain hydra crank account address for a task from its +/// `(authority, task_id)` — the deterministic, per-authority namespace the +/// scheduler uses. Tests and tooling can use this to locate the crank account a +/// schedule request created. +pub use crank::crank_pubkey; pub use errors::TaskSchedulerError; pub use service::TaskSchedulerService; diff --git a/magicblock-task-scheduler/src/service.rs b/magicblock-task-scheduler/src/service.rs index 53e9f0c3f..b37dbbc57 100644 --- a/magicblock-task-scheduler/src/service.rs +++ b/magicblock-task-scheduler/src/service.rs @@ -1,137 +1,79 @@ -use std::{ - collections::HashMap, - path::Path, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, -}; +use std::sync::Arc; use engine::Engine; -use futures_util::{FutureExt, StreamExt, future::poll_fn}; -use magicblock_config::config::TaskSchedulerConfig; -use magicblock_program::{ - args::{CancelTaskRequest, ScheduleTaskRequest, TaskRequest}, - instruction_utils::InstructionUtils, +use hydra_api::{ + ephemeral::ID as EPHEMERAL_PROGRAM_ID, instruction::ephemeral, }; -use nucleus::shutdown::{ShutdownHandle, ShutdownReason}; -use solana_message::Message; -use tokio::{ - select, - sync::mpsc, - task::JoinSet, - time::{Duration, MissedTickBehavior, interval}, +use magicblock_program::args::{ + CancelTaskRequest, ScheduleTaskRequest, TaskRequest, }; -use tokio_util::time::{DelayQueue, delay_queue::Key}; +use nucleus::shutdown::{ShutdownHandle, ShutdownReason}; +use solana_account::ReadableAccount; +use solana_instruction::Instruction; +use solana_pubkey::Pubkey; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use solana_signer::Signer; +use solana_transaction::Transaction; +use tokio::{select, sync::mpsc}; use tracing::*; use crate::{ - db::{ - CrankFailedMove, CrankRetryCheck, CrankSuccessRemoval, - CrankSuccessUpdate, DbTask, SchedulerDatabase, + crank::{ + build_create_ix, crank_pubkey, interval_slots, is_valid_task_interval, }, - errors::{TaskSchedulerError, TaskSchedulerResult}, + errors::TaskSchedulerResult, }; -const MAX_TASK_EXECUTION_RETRIES: u32 = 10; -const TASK_EXECUTION_RETRY_BASE_DELAY: Duration = Duration::from_millis(100); -const TASK_EXECUTION_RETRY_MAX_DELAY: Duration = Duration::from_secs(5); - +/// Serves runtime schedule/cancel requests by sending hydra transactions. +/// The service is stateless: it derives each task's crank PDA deterministically +/// from `(authority, task_id)`, so cancel and reschedule need no persisted +/// lookup. pub struct TaskSchedulerService { - /// Database for persisting tasks - db: SchedulerDatabase, /// Receives service messages the engine publishes once a transaction /// commits; task requests arrive on this stream. service_messages: mpsc::Receiver>, - /// Submits crank transactions and provides the latest blockhash; the engine - /// signs them with its own authority. - engine: Engine, - /// Queue of tasks to execute - task_queue: DelayQueue, - /// Map of task IDs to their corresponding keys in the task queue - task_queue_keys: HashMap, - /// Latest in-memory instance version for queued or in-flight tasks - task_versions: HashMap, - /// Number of consecutive failed execution attempts for each task - task_execution_retries: HashMap, - /// Minimum interval between task executions - min_interval: Duration, - /// How long failed task and scheduling records are retained. - failed_task_retention: Duration, - /// How often failed task and scheduling records are cleaned up. - failed_task_cleanup_interval: Duration, - /// Slot interval of the validator - slot_interval: Duration, - /// Shared counter for noop instructions (unique crank transactions). - tx_counter: Arc, + /// Cloneable schedule/cancel worker; cloned into each spawned request + /// so the receive loop never awaits RPC. + processor: Processor, } -enum ProcessingOutcome { - Success, - Recoverable(Box), +/// Schedule/cancel worker. `Clone` so each request can run without holding +/// the service-message loop. +#[derive(Clone)] +struct Processor { + /// Reads account state and the latest blockhash/slot, and provides the + /// validator identity that sponsors and signs crank transactions. + engine: Engine, + /// RPC client used to send transactions. + /// Otherwise, accounts are not ensured. + rpc_client: Arc, + /// Slot interval of the validator, used to convert millisecond intervals + /// into the slot-based cadence hydra expects. + slot_interval: tokio::time::Duration, } -// SAFETY: TaskSchedulerService is moved into a single Tokio task in `run()` and never cloned. -// It runs exclusively on that task's thread. All fields (SchedulerDatabase, Engine, -// mpsc::Receiver, DelayQueue, HashMap, and AtomicU64) are Send+Sync, -// and the service maintains exclusive ownership throughout its lifetime. -unsafe impl Send for TaskSchedulerService {} -unsafe impl Sync for TaskSchedulerService {} impl TaskSchedulerService { - /// Creates a new `TaskSchedulerService` with the given configuration. + /// Creates a new `TaskSchedulerService`. pub fn new( - path: &Path, - config: &TaskSchedulerConfig, engine: Engine, - slot_interval: Duration, + self_rpc_url: String, + slot_interval: tokio::time::Duration, ) -> TaskSchedulerResult { - if config.min_interval.as_millis() > u32::MAX as u128 { - return Err(TaskSchedulerError::InvalidConfiguration(format!( - "min_interval must be less than or equal to u32::MAX: {}", - config.min_interval.as_millis() - ))); - } - if config.reset { - match std::fs::remove_file(path) { - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - debug!("Database file not found, skip resetting"); - } - Err(e) => { - warn!("Failed to remove database file: {}", e); - return Err(TaskSchedulerError::Io(e)); - } - } - } - - // Reschedule all persisted tasks - let db = SchedulerDatabase::new(path)?; Ok(Self { - db, service_messages: engine .transactions() .subscribe_service_messages()?, - engine, - task_queue: DelayQueue::new(), - task_queue_keys: HashMap::new(), - task_versions: HashMap::new(), - task_execution_retries: HashMap::new(), - tx_counter: Arc::new(AtomicU64::default()), - min_interval: config.min_interval, - failed_task_retention: config.failed_task_retention, - failed_task_cleanup_interval: config.failed_task_cleanup_interval, - slot_interval, + processor: Processor { + engine, + rpc_client: Arc::new(RpcClient::new(self_rpc_url)), + slot_interval, + }, }) } - /// Runs the task scheduler and reports its terminal status after all crank - /// workers have stopped. + /// Runs the task scheduler and reports its terminal status. pub async fn run(mut self, mut shutdown: ShutdownHandle) { - let result = async { - self.load_persisted_tasks().await?; - self.run_loop(&shutdown).await - } - .await; + let result = self.run_loop(&shutdown).await; let reason = match result { Err(error) => ShutdownReason::Error(Box::new(error)), Ok(()) if shutdown.requested() => ShutdownReason::Signalled, @@ -140,96 +82,13 @@ impl TaskSchedulerService { shutdown.terminate(reason); } - async fn load_persisted_tasks(&mut self) -> TaskSchedulerResult<()> { - self.task_queue.clear(); - self.task_queue_keys.clear(); - self.task_execution_retries.clear(); - - // Reschedule all tasks that are due - let tasks = self.db.get_tasks().await?; - let now = chrono::Utc::now().timestamp_millis(); - debug!( - "Task scheduler starting at {} with {} tasks", - now, - tasks.len() - ); - for task in tasks { - if !is_valid_task_interval(task.execution_interval_millis) - || task.executions_left <= 0 - { - warn!( - "Task {} has an invalid parameters: (interval={}, executions_left={}). Skipping.", - task.id, - task.execution_interval_millis, - task.executions_left - ); - self.db.remove_task(task.id).await?; - continue; - } - - let next_execution = - task.last_execution_millis + task.execution_interval_millis; - // Earliest reschedule time is 2 slot interval. - // This avoids, scheduling before the first blockhash is produced on restart. - let timeout = Duration::from_millis( - next_execution - .saturating_sub(now) - .max(2 * self.slot_interval.as_millis() as i64) - as u64, - ); - let task_id = task.id; - self.task_versions.insert(task_id, task.updated_at); - let key = self.task_queue.insert(task, timeout); - self.task_queue_keys.insert(task_id, key); - } - - Ok(()) - } - - /// Main loop of the task scheduler. + /// Main loop: serves runtime schedule/cancel requests until cancelled. async fn run_loop( - mut self, + &mut self, shutdown: &ShutdownHandle, ) -> TaskSchedulerResult<()> { - let mut failed_task_cleanup = interval( - self.failed_task_cleanup_interval - .max(Duration::from_millis(1)), - ); - failed_task_cleanup.set_missed_tick_behavior(MissedTickBehavior::Delay); - - let mut crank_tasks = JoinSet::new(); - - let result = async { - loop { + loop { select! { - Some(expired) = self.task_queue.next() => { - // A task expired, batch all expired tasks - let first = expired.into_inner(); - self.task_queue_keys.remove(&first.id); - let mut batch = vec![first]; - while let Some(expired) = poll_fn(|cx| self.task_queue.poll_expired(cx)) - .now_or_never() - .flatten() - { - let task = expired.into_inner(); - self.task_queue_keys.remove(&task.id); - batch.push(task); - } - - let engine = self.engine.clone(); - let tx_counter = self.tx_counter.clone(); - - crank_tasks.spawn(async move { - let result = - Self::send_crank_batch(&engine, tx_counter, &batch).await; - (batch, result) - }); - } - Some(result) = crank_tasks.join_next(), if !crank_tasks.is_empty() => { - let (batch, result) = result?; - // The batch has been sent, updates queue and db - self.on_crank_batch_completed(batch, result).await?; - } message = self.service_messages.recv() => { let encoded = match message { Some(encoded) => encoded, @@ -240,885 +99,235 @@ impl TaskSchedulerService { }; // The stream carries every service message, not only task // requests; anything that is not a `TaskRequest` is ignored. - let Ok(task) = wincode::deserialize::(&encoded) + let Ok(request) = + wincode::deserialize::(&encoded) else { continue; }; - let id = task.id(); - match self.process_request(task).await { - Ok(ProcessingOutcome::Success) => {} - Ok(ProcessingOutcome::Recoverable(e)) => { - warn!("Failed to process request ID={}: {e:?}", id); - } - Err(e) => { - error!("Failed to process request: {}", e); - return Err(e); - } - } - } - _ = failed_task_cleanup.tick() => { - let cutoff = chrono::Utc::now().timestamp_millis().saturating_sub( - self.failed_task_retention.as_millis().min(i64::MAX as u128) as i64, - ); - let deleted = match self.db.delete_failed_records_older_than(cutoff).await { - Ok(deleted) => deleted, - Err(e) => { - error!("Failed to cleanup old failed task records: {}", e); - continue; - } - }; - if deleted > 0 { - debug!( - deleted, - cutoff_timestamp_millis = cutoff, - "Cleaned up old failed task records" - ); - } + let processor = self.processor.clone(); + tokio::spawn(async move { + processor.process_request(request).await; + }); } _ = shutdown.signalled() => { break; } } - } - - Ok(()) } - .await; info!("TaskSchedulerService shutdown!"); - if let Err(error) = result { - crank_tasks.shutdown().await; - return Err(error); - } - - let result = async { - while let Some(worker) = crank_tasks.join_next().await { - let (batch, worker_result) = worker?; - self.on_crank_batch_completed(batch, worker_result).await?; - } - Ok(()) - } - .await; - if result.is_err() { - crank_tasks.shutdown().await; - } - - result + Ok(()) } +} - /// Processes [TaskRequest] provided by the transaction executor. - async fn process_request( - &mut self, - request: TaskRequest, - ) -> TaskSchedulerResult { +impl Processor { + /// Processes a [TaskRequest] from the transaction executor. + async fn process_request(&self, request: TaskRequest) { let task_id = request.id(); - match request { + let result = match request { TaskRequest::Schedule(schedule_request) => { - if let Err(e) = - self.process_schedule_request(schedule_request).await - { - self.db - .insert_failed_scheduling(task_id, format!("{:?}", e)) - .await?; - error!( - "Failed to process schedule request {}: {}", - task_id, e - ); - - return Ok(ProcessingOutcome::Recoverable(Box::new(e))); - } + self.process_schedule_request(schedule_request).await } TaskRequest::Cancel(cancel_request) => { - if let Err(e) = - self.process_cancel_request(&cancel_request).await - { - self.db - .insert_failed_scheduling(task_id, format!("{:?}", e)) - .await?; - error!( - "Failed to process cancel request for task {}: {}", - task_id, e - ); - - return Ok(ProcessingOutcome::Recoverable(Box::new(e))); - } + self.process_cancel_request(&cancel_request).await } }; - - Ok(ProcessingOutcome::Success) + if let Err(e) = result { + error!("Failed to process task request {}: {}", task_id, e); + } } - /// Processes [ScheduleTaskRequest] provided by the transaction executor. + /// Schedules a task: creates and funds its hydra crank. async fn process_schedule_request( - &mut self, - mut task: ScheduleTaskRequest, + &self, + task: ScheduleTaskRequest, ) -> TaskSchedulerResult<()> { if !is_valid_task_interval(task.execution_interval_millis) { - // If the interval is too large or zero, we don't schedule the task + // Too large or zero: ignore. return Ok(()); } - - task.execution_interval_millis = task - .execution_interval_millis - .clamp(self.min_interval.as_millis() as i64, u32::MAX as i64); - - let mut task = DbTask::from(task); - let task_id = task.id; - - // Check if the task already exists in the database - if let Some(db_task) = self.db.get_task(task_id).await? - && db_task.authority != task.authority - { - return Err(TaskSchedulerError::UnauthorizedReplacing( - task_id, - db_task.authority.to_string(), - task.authority.to_string(), - )); - } - - task.updated_at = self.db.insert_task(&task).await?; - self.remove_task_from_queue(task_id); - self.task_execution_retries.remove(&task_id); - self.task_versions.insert(task_id, task.updated_at); - let key = self.task_queue.insert(task, Duration::from_millis(0)); - self.task_queue_keys.insert(task_id, key); - debug!("Registered task {} from context", task_id); - + let interval_millis = + task.execution_interval_millis.clamp(1, u32::MAX as i64); + + self.schedule_crank( + &task.authority, + task.id, + interval_millis, + task.iterations, + &task.instructions, + ) + .await?; + debug!("Created hydra crank for task {}", task.id); Ok(()) } - /// Processes [CancelTaskRequest] provided by the transaction executor. + /// Cancels a task's hydra crank, if one exists for `(authority, task_id)`. async fn process_cancel_request( - &mut self, - cancel_request: &CancelTaskRequest, - ) -> TaskSchedulerResult<()> { - let Some(task) = self.db.get_task(cancel_request.task_id).await? else { - // Task not found in the database, cleanup the queue - self.remove_task_from_queue(cancel_request.task_id); - self.task_execution_retries.remove(&cancel_request.task_id); - return Ok(()); - }; - - // Check if the task authority is the same as the cancel request authority - if task.authority != cancel_request.authority { - error!( - "Task authority {} does not match cancel request authority {}", - task.authority, cancel_request.authority - ); - return Ok(()); - } - - // Remove task from queue and database - self.remove_task_runtime_state(task.id); - self.task_execution_retries.remove(&task.id); - self.db.remove_task(task.id).await?; - debug!("Removed task {} from database", task.id); - - Ok(()) - } - - /// Submits a batch of crank transactions to the engine, which signs them - /// with its own authority. - async fn send_crank_batch( - engine: &Engine, - tx_counter: Arc, - tasks: &[DbTask], - ) -> TaskSchedulerResult)>> { - let mut join_set: JoinSet<(DbTask, TaskSchedulerResult<()>)> = - JoinSet::new(); - let validator_authority = engine.authority(); - for task in tasks { - let engine = engine.clone(); - let tx_counter = tx_counter.clone(); - let task = task.clone(); - join_set.spawn(async move { - let ixs = vec![ - InstructionUtils::noop_instruction( - tx_counter.fetch_add(1, Ordering::Relaxed), - ), - InstructionUtils::execute_task_instruction( - validator_authority, - task.authority, - task.instructions.clone(), - ), - ]; - let message = Message::new(&ixs, Some(&validator_authority)); - let res = Self::submit_crank(&engine, message).await; - (task, res) - }); - } - Ok(join_set.join_all().await) - } - - /// Composes, signs (via the engine authority) and executes a crank message, - /// flattening the engine and transaction-level results. - async fn submit_crank( - engine: &Engine, - message: Message, - ) -> TaskSchedulerResult<()> { - engine - .transaction(message) - .map_err(|err| { - TaskSchedulerError::TransactionExecution(err.to_string()) - })? - .execute() - .await - .map_err(|err| { - TaskSchedulerError::TransactionExecution(err.to_string()) - })? - .map_err(|err| { - TaskSchedulerError::TransactionExecution(err.to_string()) - })?; - Ok(()) - } - - /// Called when a crank batch is completed. - async fn on_crank_batch_completed( - &mut self, - batch: Vec, - result: TaskSchedulerResult)>>, - ) -> TaskSchedulerResult<()> { - let now_millis = chrono::Utc::now().timestamp_millis(); - let mut success_updates: Vec = Vec::new(); - let mut success_removals: Vec = Vec::new(); - let mut failed_records: Vec = Vec::new(); - let mut retry_checks: Vec = Vec::new(); - - // Decide what must happen to cranks - match result { - Ok(ref result) => { - // Batch completed, update individual crank based on tx status - for (task, res) in result { - if let Err(e) = res { - self.prepare_crank_failure_outcome( - task, - e, - &mut failed_records, - &mut retry_checks, - )?; - } else { - self.prepare_crank_success_outcome( - task, - now_millis, - &mut success_updates, - &mut success_removals, - )?; - } - } - } - Err(ref e) => { - // Whole batch failed, fail all cranks - for task in &batch { - self.prepare_crank_failure_outcome( - task, - e, - &mut failed_records, - &mut retry_checks, - )?; - } - } - } - - // Apply db updates for the whole batch - let completion = self - .db - .apply_crank_batch_completion( - &success_updates, - &success_removals, - &failed_records, - &retry_checks, - ) - .await?; - - // Update queue, retries and versions based on decisions - match result { - Ok(result) => { - for (task, res) in &result { - if let Err(e) = res { - if completion.failed_moves.get(&task.id).is_some_and( - |m| m.expected_updated_at == task.updated_at, - ) || completion - .retry_checks - .get(&task.id) - .is_some_and(|c| { - c.current_updated_at == task.updated_at - }) - { - self.apply_crank_failure_outcome(task, e)?; - } - } else if let Some(update) = - completion.success_updates.get(&task.id) - { - if update.expected_updated_at == task.updated_at { - self.apply_crank_success_outcome( - task, - now_millis, - Some(update.new_updated_at), - )?; - } - } else if completion - .success_removals - .get(&task.id) - .is_some_and(|r| { - r.expected_updated_at == task.updated_at - }) - { - self.apply_crank_success_outcome( - task, now_millis, None, - )?; - } - } - } - Err(ref e) => { - for task in &batch { - if completion.failed_moves.get(&task.id).is_some_and(|m| { - m.expected_updated_at == task.updated_at - }) || completion.retry_checks.get(&task.id).is_some_and( - |c| c.current_updated_at == task.updated_at, - ) { - self.apply_crank_failure_outcome(task, e)?; - } - } - } - } - - Ok(()) - } - - /// Either: - /// - reschedules crank with remaining iterations - /// - remove exhausted cranks - fn prepare_crank_success_outcome( &self, - task: &DbTask, - now_millis: i64, - success_updates: &mut Vec, - success_removals: &mut Vec, + cancel_request: &CancelTaskRequest, ) -> TaskSchedulerResult<()> { - let executed_at = next_execution_millis( - task.last_execution_millis, - task.execution_interval_millis, - now_millis, - ); + let crank = + crank_pubkey(&cancel_request.authority, cancel_request.task_id); - if task.executions_left > 1 { - success_updates.push(CrankSuccessUpdate { - task_id: task.id, - last_execution_millis: executed_at, - expected_updated_at: task.updated_at, - }); - } else { - success_removals.push(CrankSuccessRemoval { - task_id: task.id, - expected_updated_at: task.updated_at, - }); - } + // Does not check if the crank exists, so it will fail if it does not exist + self.send_cancel(crank).await?; + debug!("Cancelled hydra crank for task {}", cancel_request.task_id); Ok(()) } - /// Either: - /// - re-queues for retry - /// - queues a permanent failure for SQLite along with clearing retry counters and stale queue keys. - fn prepare_crank_failure_outcome( + /// Creates and funds the hydra crank for a task. If a crank already exists + /// at the deterministic PDA (a reschedule), it is closed first so the new + /// schedule can recreate it. + async fn schedule_crank( &self, - task: &DbTask, - error: &TaskSchedulerError, - failed_records: &mut Vec, - retry_checks: &mut Vec, + authority: &Pubkey, + task_id: i64, + interval_millis: i64, + iterations: i64, + instructions: &[Instruction], ) -> TaskSchedulerResult<()> { - debug!("Failed to execute task {}: {}", task.id, error); - - if !is_retryable_task_execution_error(error) { - // Unretryable crank are moved to failed cranks - failed_records.push(CrankFailedMove { - task_id: task.id, - expected_updated_at: task.updated_at, - error: error.to_string(), - }); + if iterations <= 0 { return Ok(()); } - - let retries = *self.task_execution_retries.get(&task.id).unwrap_or(&0); - if retries >= MAX_TASK_EXECUTION_RETRIES { - // Crank exhausted retries, fail it - failed_records.push(CrankFailedMove { - task_id: task.id, - expected_updated_at: task.updated_at, - error: error.to_string(), - }); - return Ok(()); - } - - // Schedule for retry - retry_checks.push(CrankRetryCheck { - task_id: task.id, - expected_updated_at: task.updated_at, - }); - + self.send_create( + authority, + task_id, + interval_millis, + iterations, + instructions, + ) + .await?; Ok(()) } - /// Updates the delay queue for the next firing after SQLite confirms the same task instance. - fn apply_crank_success_outcome( - &mut self, - task: &DbTask, - now_millis: i64, - updated_at: Option, - ) -> TaskSchedulerResult<()> { - if !self.task_version_matches(task) { - debug!( - task_id = task.id, - expected_updated_at = task.updated_at, - "Skipping stale successful crank completion" - ); - return Ok(()); - } - - let executed_at = next_execution_millis( - task.last_execution_millis, - task.execution_interval_millis, - now_millis, - ); - - if let Some(updated_at) = updated_at { - let next_execution = executed_at + task.execution_interval_millis; - let delay = delay_until_millis(next_execution, now_millis); - let new_task = DbTask { - executions_left: task.executions_left - 1, - last_execution_millis: executed_at, - updated_at, - ..task.clone() - }; - let key = self.task_queue.insert(new_task, delay); - self.task_queue_keys.insert(task.id, key); - self.task_versions.insert(task.id, updated_at); - } else { - self.remove_task_runtime_state(task.id); - } - - self.task_execution_retries.remove(&task.id); - Ok(()) + /// Returns whether a hydra-owned crank account currently exists at `crank`. + fn crank_exists(&self, crank: &Pubkey) -> bool { + self.engine + .accounts() + .loader() + .read(crank, |account| *account.owner() == EPHEMERAL_PROGRAM_ID) + .ok() + .flatten() + .unwrap_or(false) } - /// Either: - /// - re-queues for retry - /// - records a permanent failure in runtime state after SQLite confirms the same task instance. - fn apply_crank_failure_outcome( - &mut self, - task: &DbTask, - error: &TaskSchedulerError, + /// Builds and sends the transaction that creates and funds a hydra crank. + /// It cancels the crank first if it already exists. + async fn send_create( + &self, + authority: &Pubkey, + task_id: i64, + interval_millis: i64, + iterations: i64, + instructions: &[Instruction], ) -> TaskSchedulerResult<()> { - if !self.task_version_matches(task) { - debug!( - task_id = task.id, - expected_updated_at = task.updated_at, - "Skipping stale failed crank completion" - ); - return Ok(()); - } - - if !is_retryable_task_execution_error(error) { - self.task_execution_retries.remove(&task.id); - self.remove_task_runtime_state(task.id); - return Ok(()); - } - - let Some(retries) = ({ - let retries = - self.task_execution_retries.entry(task.id).or_default(); - if *retries >= MAX_TASK_EXECUTION_RETRIES { - None - } else { - *retries += 1; - Some(*retries) - } - }) else { - self.task_execution_retries.remove(&task.id); - self.remove_task_runtime_state(task.id); - return Ok(()); + let crank = crank_pubkey(authority, task_id); + let crank_exists = self.crank_exists(&crank); + + let start_slot = self.engine.blocks().current_slot(); + + let interval_slots = + interval_slots(interval_millis, self.slot_interval); + // `i64::MAX` iterations is how the magic API spells "run forever". Hydra + // has its own sentinel for that — wire-level `0`, which `Create` stores + // as `REMAINING_INFINITE` — and passing the raw count instead produces a + // *finite* crank of ~9.2e18 executions. + let iterations = if iterations == i64::MAX { + 0 + } else { + iterations as u64 }; - let delay = self.task_execution_retry_delay(retries); - debug!( - task_id = task.id, - retry = retries, - max_retries = MAX_TASK_EXECUTION_RETRIES, - delay_ms = delay.as_millis(), - error = %error, - "Retrying failed task execution" + let sponsor = self.engine.signer().pubkey(); + let create_ix = build_create_ix( + &sponsor, + authority, + task_id, + crank, + start_slot, + interval_slots, + iterations, + instructions, ); - let key = self.task_queue.insert(task.clone(), delay); - self.task_queue_keys.insert(task.id, key); - self.task_versions.insert(task.id, task.updated_at); - - Ok(()) - } - - fn task_version_matches(&self, task: &DbTask) -> bool { - self.task_versions.get(&task.id) == Some(&task.updated_at) - } - - /// Removes a task from the queue. - fn remove_task_from_queue(&mut self, task_id: i64) { - if let Some(key) = self.task_queue_keys.remove(&task_id) { - self.task_queue.remove(&key); - } - } + let ixs = if crank_exists { + let cancel_ix = ephemeral::cancel(sponsor, crank, sponsor); + vec![cancel_ix, create_ix] + } else { + vec![create_ix] + }; - fn remove_task_runtime_state(&mut self, task_id: i64) { - self.remove_task_from_queue(task_id); - self.task_versions.remove(&task_id); + self.submit(&ixs).await } - /// Calculates the retry delay for the next task execution. - fn task_execution_retry_delay(&self, retry: u32) -> Duration { - let multiplier = 1u32 - .checked_shl(retry.saturating_sub(1)) - .unwrap_or(u32::MAX); - self.slot_interval - .max(TASK_EXECUTION_RETRY_BASE_DELAY) - .checked_mul(multiplier) - .unwrap_or(TASK_EXECUTION_RETRY_MAX_DELAY) - .min(TASK_EXECUTION_RETRY_MAX_DELAY) + /// Sends a crank cancellation. The crank's remaining lamports are returned + /// to the validator identity (the cancel recipient). + async fn send_cancel(&self, crank: Pubkey) -> TaskSchedulerResult<()> { + let sponsor = self.engine.signer().pubkey(); + let cancel_ix = ephemeral::cancel(sponsor, crank, sponsor); + self.submit(&[cancel_ix]).await } -} - -fn is_valid_task_interval(interval: i64) -> bool { - interval > 0 && interval < u32::MAX as i64 -} -fn next_execution_millis( - last_execution_millis: i64, - execution_interval_millis: i64, - now: i64, -) -> i64 { - if last_execution_millis == 0 { - now - } else { - last_execution_millis + execution_interval_millis + /// Signs `instructions` with the validator identity — the crank sponsor — + /// and submits them. Send and forget since the write lock on the identity + /// account prevents races. + async fn submit( + &self, + instructions: &[Instruction], + ) -> TaskSchedulerResult<()> { + let validator = self.engine.signer(); + let transaction = Transaction::new_signed_with_payer( + instructions, + Some(&validator.pubkey()), + &[validator], + self.engine.blockhash(), + ); + self.rpc_client.send_transaction(&transaction).await?; + Ok(()) } } -fn delay_until_millis(execution_millis: i64, now: i64) -> Duration { - Duration::from_millis(execution_millis.saturating_sub(now).max(0) as u64) -} - -fn is_retryable_task_execution_error(error: &TaskSchedulerError) -> bool { - // `send_crank_batch` maps engine submission and execution failures to - // TransactionExecution; legacy Rpc failures remain retryable too. - matches!( - error, - TaskSchedulerError::Rpc(_) - | TaskSchedulerError::TransactionExecution(_) - ) -} - #[cfg(test)] mod tests { use engine::testkit::TestEngine; - use magicblock_program::{ - args::ScheduleTaskRequest, - validator::generate_validator_authority_if_needed, - }; use nucleus::shutdown::{Service, ShutdownManager}; - use solana_pubkey::Pubkey; - use tokio::time::timeout; use super::*; - /// Builds a service wired to a real test engine and returns, alongside it, - /// the engine (which the caller must keep alive) and a sender for feeding - /// wincode-encoded task requests onto the service-message stream. - async fn setup( - db: SchedulerDatabase, - ) -> (TestEngine, mpsc::Sender>, TaskSchedulerService) { + /// Builds a service wired to a real test engine and returns it alongside + /// the engine, which the caller must keep alive. + async fn test_service() -> (TestEngine, TaskSchedulerService) { let engine = TestEngine::new().await; - let (service_tx, service_messages) = mpsc::channel(64); let service = TaskSchedulerService { - db, - service_messages, - engine: engine.clone(), - task_queue: DelayQueue::new(), - task_queue_keys: HashMap::new(), - task_versions: HashMap::new(), - task_execution_retries: HashMap::new(), - tx_counter: Arc::new(AtomicU64::default()), - min_interval: Duration::from_millis(1000), - failed_task_retention: Duration::from_secs(60), - failed_task_cleanup_interval: Duration::from_secs(60), - slot_interval: Duration::from_millis(1000), + service_messages: engine + .transactions() + .subscribe_service_messages() + .unwrap(), + processor: Processor { + engine: engine.clone(), + rpc_client: Arc::new(RpcClient::new( + "http://localhost:8899".to_string(), + )), + slot_interval: tokio::time::Duration::from_millis(1000), + }, }; - (engine, service_tx, service) - } - - /// Encodes a task request the way the magic program does before it reaches - /// the engine's service-message stream. - fn encode_task(task: &TaskRequest) -> Vec { - wincode::serialize(task).unwrap() - } - - #[test] - fn test_first_execution_anchors_cadence_at_now() { - assert_eq!(next_execution_millis(0, 50, 1_000), 1_000); - } - - #[test] - fn test_recurring_execution_preserves_fixed_rate_cadence() { - let executed_at = next_execution_millis(1_000, 50, 1_090); - assert_eq!(executed_at, 1_050); - - let delay = delay_until_millis(executed_at + 50, 1_090); - assert_eq!(delay, Duration::from_millis(10)); - } - - #[test] - fn test_overdue_execution_is_rescheduled_immediately() { - assert_eq!(delay_until_millis(1_100, 1_150), Duration::from_millis(0)); + (engine, service) } #[tokio::test] - async fn test_schedule_invalid_tasks() { + async fn test_service_shuts_down_on_cancel() { magicblock_core::logger::init_for_tests(); - generate_validator_authority_if_needed(); - - let db = SchedulerDatabase::new(":memory:").unwrap(); - - let (_engine, tx, service) = setup(db.clone()).await; + let (_engine, service) = test_service().await; let mut shutdown = ShutdownManager::default(); let handle = tokio::spawn(service.run(shutdown.handle(Service::TaskScheduler))); - // Invalid task interval - tx.try_send(encode_task(&TaskRequest::Schedule(ScheduleTaskRequest { - id: 1, - authority: Pubkey::new_unique(), - execution_interval_millis: u32::MAX as i64, - iterations: 1, - instructions: vec![], - }))) - .unwrap(); - // Valid task interval - tx.try_send(encode_task(&TaskRequest::Schedule(ScheduleTaskRequest { - id: 1, - authority: Pubkey::new_unique(), - execution_interval_millis: u32::MAX as i64 - 1, - iterations: 1, - instructions: vec![], - }))) - .unwrap(); - - // After processing the requests, only one task stays in the DB - timeout(Duration::from_secs(1), async move { - loop { - let tasks = db.get_tasks().await.unwrap(); - if tasks.len() > 1 { - return Err::<(), String>(format!( - "Tasks should be 1, got {}", - tasks.len() - )); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await - .unwrap_err(); - let _ = shutdown.terminate().await; - handle.await.unwrap(); - } - - #[tokio::test] - async fn test_remove_invalid_tasks_on_startup() { - magicblock_core::logger::init_for_tests(); - - let db = SchedulerDatabase::new(":memory:").unwrap(); - // Invalid task interval - db.insert_task(&DbTask { - id: 1, - authority: Pubkey::new_unique(), - execution_interval_millis: u32::MAX as i64, - executions_left: 1, - last_execution_millis: chrono::Utc::now().timestamp_millis(), - updated_at: 0, - instructions: vec![], - }) - .await - .unwrap(); - // Valid task interval - db.insert_task(&DbTask { - id: 2, - authority: Pubkey::new_unique(), - execution_interval_millis: u32::MAX as i64 - 1, - executions_left: 1, - last_execution_millis: chrono::Utc::now().timestamp_millis(), - updated_at: 0, - instructions: vec![], - }) - .await - .unwrap(); - let (_engine, _tx, service) = setup(db.clone()).await; - - let mut shutdown = ShutdownManager::default(); - let handle = - tokio::spawn(service.run(shutdown.handle(Service::TaskScheduler))); - - // After starting, only one task should be in the database - timeout(Duration::from_secs(1), async move { - loop { - let tasks = db.get_tasks().await?; - if tasks.len() == 1 { - return Ok::<_, TaskSchedulerError>(()); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await - .unwrap() - .unwrap(); - let _ = shutdown.terminate().await; - handle.await.unwrap(); - } - - #[tokio::test] - async fn test_completed_tasks_are_removed_on_startup() { - magicblock_core::logger::init_for_tests(); - - let db = SchedulerDatabase::new(":memory:").unwrap(); - db.insert_task(&DbTask { - id: 1, - authority: Pubkey::new_unique(), - execution_interval_millis: 50, - executions_left: 0, - last_execution_millis: chrono::Utc::now().timestamp_millis(), - updated_at: 0, - instructions: vec![], - }) - .await - .unwrap(); - db.insert_task(&DbTask { - id: 2, - authority: Pubkey::new_unique(), - execution_interval_millis: 50, - executions_left: 1, - last_execution_millis: chrono::Utc::now().timestamp_millis(), - updated_at: 0, - instructions: vec![], - }) - .await - .unwrap(); - - let (_engine, _tx, mut service) = setup(db.clone()).await; - service.min_interval = Duration::from_millis(10); - - let mut shutdown = ShutdownManager::default(); - let handle = - tokio::spawn(service.run(shutdown.handle(Service::TaskScheduler))); - - timeout(Duration::from_secs(1), async move { - loop { - let tasks = db.get_task_ids().await?; - if tasks == vec![2] { - return Ok::<_, TaskSchedulerError>(()); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await - .unwrap() - .unwrap(); - let _ = shutdown.terminate().await; - handle.await.unwrap(); - } - - #[tokio::test] - async fn test_stale_crank_completion_does_not_mutate_replaced_task() { - magicblock_core::logger::init_for_tests(); - - let db = SchedulerDatabase::new(":memory:").unwrap(); - let authority = Pubkey::new_unique(); - let mut old_task = DbTask { - id: 1, - authority, - execution_interval_millis: 50, - executions_left: 2, - last_execution_millis: 0, - updated_at: 0, - instructions: vec![], - }; - old_task.updated_at = db.insert_task(&old_task).await.unwrap(); - - let (_engine, _tx, mut service) = setup(db.clone()).await; - - let mut replacement = DbTask { - executions_left: 5, - ..old_task.clone() - }; - replacement.updated_at = db.insert_task(&replacement).await.unwrap(); - let key = service - .task_queue - .insert(replacement.clone(), Duration::from_secs(10)); - service.task_queue_keys.insert(replacement.id, key); - service - .task_versions - .insert(replacement.id, replacement.updated_at); - - service - .on_crank_batch_completed( - vec![old_task.clone()], - Ok(vec![(old_task, Ok(()))]), - ) - .await - .unwrap(); - - let persisted = db.get_task(replacement.id).await.unwrap().unwrap(); - assert_eq!(persisted.executions_left, replacement.executions_left); - assert_eq!(persisted.updated_at, replacement.updated_at); - - let key = service.task_queue_keys.remove(&replacement.id).unwrap(); - let queued = service.task_queue.remove(&key).into_inner(); - assert_eq!(queued.updated_at, replacement.updated_at); - assert_eq!(queued.executions_left, replacement.executions_left); - } - - #[tokio::test] - async fn test_failed_records_are_cleaned_up_periodically() { - magicblock_core::logger::init_for_tests(); - - let db = SchedulerDatabase::new(":memory:").unwrap(); - - db.insert_failed_scheduling(1, "schedule failed".to_string()) + tokio::time::timeout(tokio::time::Duration::from_secs(2), handle) .await - .unwrap(); - db.insert_failed_task(2, "task failed".to_string()) - .await - .unwrap(); - tokio::time::sleep(Duration::from_millis(2)).await; - - let (_engine, _tx, mut service) = setup(db.clone()).await; - service.failed_task_retention = Duration::from_millis(1); - service.failed_task_cleanup_interval = Duration::from_millis(5); - - let mut shutdown = ShutdownManager::default(); - let handle = - tokio::spawn(service.run(shutdown.handle(Service::TaskScheduler))); - - timeout(Duration::from_secs(1), async move { - loop { - if db.get_failed_schedulings().await?.is_empty() - && db.get_failed_tasks().await?.is_empty() - { - return Ok::<_, TaskSchedulerError>(()); - } - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .unwrap() - .unwrap(); - let _ = shutdown.terminate().await; - handle.await.unwrap(); + .expect("service should shut down promptly") + .expect("task should not panic"); } } diff --git a/programs/magicblock/src/ephemeral_accounts/mod.rs b/programs/magicblock/src/ephemeral_accounts/mod.rs index 3d008354a..439b8a2e6 100644 --- a/programs/magicblock/src/ephemeral_accounts/mod.rs +++ b/programs/magicblock/src/ephemeral_accounts/mod.rs @@ -10,7 +10,6 @@ mod process_create_magic_ata; mod process_resize; mod validation; -use magicblock_magic_program_api::EPHEMERAL_RENT_PER_BYTE; pub(crate) use process_close::process_close_ephemeral_account; pub(crate) use process_close_magic_ata::process_close_magic_ata; pub(crate) use process_create::process_create_ephemeral_account; @@ -36,24 +35,6 @@ const VAULT_IDX: u16 = 2; /// Maximum allowed data length for ephemeral accounts (10 MB, matching Solana's limit) const MAX_DATA_LEN: u32 = 10 * 1024 * 1024; -/// Fixed per-account storage overhead charged as rent on top of the data. -/// -/// Rent is a policy of this program rather than of the account layout, so the -/// value is pinned here: lamports (8) + owner (32) + slot (8) + flags (4) + -/// data capacity (4) + data length (4). It previously came from the account -/// crate, which no longer publishes its static size. -const ACCOUNT_STATIC_SIZE: u64 = 60; - -/// Calculates rent for an ephemeral account based on its data length. -fn rent_for(data_len: u32) -> Result { - let total_size = u64::from(data_len) - .checked_add(ACCOUNT_STATIC_SIZE) - .ok_or(InstructionError::ArithmeticOverflow)?; - total_size - .checked_mul(EPHEMERAL_RENT_PER_BYTE) - .ok_or(InstructionError::ArithmeticOverflow) -} - /// Returns the data length of an ephemeral account as a `u32`. fn get_ephemeral_data_len( ephemeral: &InstructionAccount<'_, '_>, diff --git a/programs/magicblock/src/ephemeral_accounts/process_close.rs b/programs/magicblock/src/ephemeral_accounts/process_close.rs index a20dc4f1a..94d8b4051 100644 --- a/programs/magicblock/src/ephemeral_accounts/process_close.rs +++ b/programs/magicblock/src/ephemeral_accounts/process_close.rs @@ -1,5 +1,6 @@ //! Close ephemeral account instruction processor +use magicblock_magic_program_api::ephemeral; use solana_account::{AccountMode, WritableAccount}; use solana_instruction::error::InstructionError; use solana_log_collector::ic_msg; @@ -8,7 +9,7 @@ use solana_sdk_ids::system_program; use solana_transaction_context::transaction::TransactionContext; use super::{ - get_ephemeral_data_len, rent_for, transfer_rent, + get_ephemeral_data_len, transfer_rent, validation::{validate_common, validate_existing_ephemeral}, }; use crate::utils::account_actions::set_account_mode; @@ -23,7 +24,7 @@ pub(crate) fn process_close_ephemeral_account( validate_existing_ephemeral(transaction_context, &caller_program_id)?; let data_len = get_ephemeral_data_len(&ephemeral)?; - let refund = rent_for(data_len)?; + let refund = ephemeral::rent_for(data_len)?; transfer_rent(transaction_context, -(refund as i64))?; // Reset account to empty state diff --git a/programs/magicblock/src/ephemeral_accounts/process_create.rs b/programs/magicblock/src/ephemeral_accounts/process_create.rs index 68a5f8041..1ed8257a1 100644 --- a/programs/magicblock/src/ephemeral_accounts/process_create.rs +++ b/programs/magicblock/src/ephemeral_accounts/process_create.rs @@ -1,5 +1,6 @@ //! Create ephemeral account instruction processor +use magicblock_magic_program_api::ephemeral; use solana_account::{AccountMode, WritableAccount}; use solana_instruction::error::InstructionError; use solana_log_collector::ic_msg; @@ -7,7 +8,7 @@ use solana_program_runtime::invoke_context::InvokeContext; use solana_transaction_context::transaction::TransactionContext; use super::{ - MAX_DATA_LEN, rent_for, transfer_rent, + MAX_DATA_LEN, transfer_rent, validation::{ validate_common, validate_ephemeral_signer, validate_new_ephemeral, }, @@ -29,7 +30,7 @@ pub(crate) fn process_create_ephemeral_account( validate_ephemeral_signer(transaction_context)?; let ephemeral = validate_new_ephemeral(transaction_context)?; - let rent = rent_for(data_len)?; + let rent = ephemeral::rent_for(data_len)?; transfer_rent(transaction_context, rent as i64)?; // Initialize ephemeral account diff --git a/programs/magicblock/src/ephemeral_accounts/process_resize.rs b/programs/magicblock/src/ephemeral_accounts/process_resize.rs index 76a8018dc..ba58a5812 100644 --- a/programs/magicblock/src/ephemeral_accounts/process_resize.rs +++ b/programs/magicblock/src/ephemeral_accounts/process_resize.rs @@ -1,12 +1,13 @@ //! Resize ephemeral account instruction processor +use magicblock_magic_program_api::ephemeral; use solana_instruction::error::InstructionError; use solana_log_collector::ic_msg; use solana_program_runtime::invoke_context::InvokeContext; use solana_transaction_context::transaction::TransactionContext; use super::{ - MAX_DATA_LEN, get_ephemeral_data_len, rent_for, transfer_rent, + MAX_DATA_LEN, get_ephemeral_data_len, transfer_rent, validation::{validate_common, validate_existing_ephemeral}, }; @@ -25,8 +26,8 @@ pub(crate) fn process_resize_ephemeral_account( validate_existing_ephemeral(transaction_context, &caller_program_id)?; let old_len = get_ephemeral_data_len(&ephemeral)?; - let new_rent = rent_for(new_data_len)?; - let old_rent = rent_for(old_len)?; + let new_rent = ephemeral::rent_for(new_data_len)?; + let old_rent = ephemeral::rent_for(old_len)?; let delta = new_rent as i64 - old_rent as i64; transfer_rent(transaction_context, delta)?; diff --git a/programs/magicblock/src/magicblock_processor.rs b/programs/magicblock/src/magicblock_processor.rs index c63eacfe1..dd9e91400 100644 --- a/programs/magicblock/src/magicblock_processor.rs +++ b/programs/magicblock/src/magicblock_processor.rs @@ -15,9 +15,7 @@ use crate::{ }, errors::MagicBlockProgramError, process_scheduled_commit_sent, - schedule_task::{ - process_cancel_task, process_execute_crank, process_schedule_task, - }, + schedule_task::{process_cancel_task, process_schedule_task}, schedule_transactions::{ ProcessScheduleCommitOptions, process_accept_scheduled_commits, process_add_action_callback, process_execute_callback, @@ -150,6 +148,13 @@ declare_process_instruction!( EvictAccount { .. } => { composition_removed(invoke_context, "EvictAccount") } + _ExecuteCrank => { + solana_log_collector::ic_msg!( + invoke_context, + "ExecuteCrank is no longer supported: crank execution is handled by hydra" + ); + Err(InstructionError::InvalidInstructionData) + } Noop(_) => Ok(()), CloneAccount { .. } => { composition_removed(invoke_context, "CloneAccount") @@ -173,40 +178,6 @@ declare_process_instruction!( invoke_context, "FinalizeV1ProgramFromBuffer", ), - ExecuteCrank { - authority, - instructions, - } => process_execute_crank( - signers, - invoke_context, - &authority, - instructions, - ), - } - } -); - -declare_process_instruction!( - CrankEntrypoint, - DEFAULT_COMPUTE_UNITS, - |invoke_context| { - let instruction = deserialize_instruction(invoke_context)?; - let transaction_context = &invoke_context.transaction_context; - let instruction_context = - transaction_context.get_current_instruction_context()?; - let signers = instruction_context.get_signers()?; - - match instruction { - MagicBlockInstruction::ExecuteCrank { - authority, - instructions, - } => process_execute_crank( - signers, - invoke_context, - &authority, - instructions, - ), - _ => Err(InstructionError::InvalidInstructionData), } } ); @@ -265,7 +236,6 @@ declare_process_instruction!( #[cfg(test)] mod test { - use magicblock_magic_program_api::args::ScheduleTaskArgs; use solana_instruction::AccountMeta; use solana_program_runtime::{ invoke_context::mock_process_instruction, @@ -274,31 +244,6 @@ mod test { use super::*; - #[test] - fn crank_entrypoint_rejects_non_execute_crank_instructions() { - let data = wincode::serialize(&MagicBlockInstruction::ScheduleTask( - ScheduleTaskArgs { - task_id: 1, - execution_interval_millis: 10, - iterations: 1, - instructions: vec![], - }, - )) - .unwrap(); - - mock_process_instruction( - &crate::CRANK_PROGRAM_ID, - None, - &data, - Vec::new(), - vec![AccountMeta::new_readonly(crate::CRANK_PROGRAM_ID, false)], - Err(InstructionError::InvalidInstructionData), - (CrankEntrypoint::vm, CrankEntrypoint::codegen), - |_invoke_context| {}, - |_invoke_context| {}, - ); - } - #[test] fn callback_entrypoint_rejects_invalid_instruction_data() { let data = vec![0xFF, 0xFF, 0xFF, 0xFF]; diff --git a/programs/magicblock/src/schedule_task/mod.rs b/programs/magicblock/src/schedule_task/mod.rs index 0b4f4e155..9807d8e8c 100644 --- a/programs/magicblock/src/schedule_task/mod.rs +++ b/programs/magicblock/src/schedule_task/mod.rs @@ -1,44 +1,31 @@ mod process_cancel_task; -mod process_execute_task; mod process_schedule_task; -use magicblock_magic_program_api::{ - instruction::MagicBlockInstruction, pda::crank_signer_pda, -}; +use magicblock_magic_program_api::instruction::MagicBlockInstruction; pub(crate) use process_cancel_task::*; -pub(crate) use process_execute_task::*; pub(crate) use process_schedule_task::*; use solana_instruction::{Instruction, error::InstructionError}; use solana_log_collector::ic_msg; use solana_program_runtime::invoke_context::InvokeContext; -use solana_pubkey::Pubkey; use crate::validator::authority as engine_authority; -// Assert that the task instructions do not have signers aside from the crank signer +// Assert that the task instructions do not have signers // Assert they don't use the validator either // Assert they are not a privileged instruction pub(crate) fn validate_cranks_instructions( invoke_context: &mut InvokeContext, - authority: &Pubkey, instructions: &[Instruction], ) -> Result<(), InstructionError> { - let crank_signer = crank_signer_pda(authority); for instruction in instructions { for account in &instruction.accounts { - if account.is_signer && account.pubkey.ne(&crank_signer) { + if account.is_signer { ic_msg!( invoke_context, - "Crank ERR: only the crank signer PDA can be a signer in cranks (invalid signer: '{}')", + "Crank ERR: scheduled crank instructions cannot include signer accounts (invalid signer: '{}')", account.pubkey, ); return Err(InstructionError::MissingRequiredSignature); - } else if account.is_writable && account.pubkey.eq(&crank_signer) { - ic_msg!( - invoke_context, - "Crank ERR: the crank signer PDA cannot be a writable account in cranks", - ); - return Err(InstructionError::Immutable); } else if account.pubkey.eq(&engine_authority()) { ic_msg!( invoke_context, diff --git a/programs/magicblock/src/schedule_task/process_execute_task.rs b/programs/magicblock/src/schedule_task/process_execute_task.rs deleted file mode 100644 index d64453881..000000000 --- a/programs/magicblock/src/schedule_task/process_execute_task.rs +++ /dev/null @@ -1,389 +0,0 @@ -use std::collections::HashSet; - -use magicblock_magic_program_api::{CRANK_PROGRAM_ID, pda::crank_signer_pda}; -use solana_instruction::{Instruction, error::InstructionError}; -use solana_log_collector::ic_msg; -use solana_program_runtime::invoke_context::InvokeContext; -use solana_pubkey::Pubkey; - -use crate::{ - schedule_task::validate_cranks_instructions, - utils::accounts::get_instruction_pubkey_with_idx, - validator::authority as engine_authority, -}; - -pub(crate) fn process_execute_crank( - signers: HashSet, - invoke_context: &mut InvokeContext, - authority: &Pubkey, - instructions: Vec, -) -> Result<(), InstructionError> { - const VALIDATOR_IDX: u16 = 0; - const CRANK_SIGNER_IDX: u16 = 1; - let crank_signer = crank_signer_pda(authority); - - const ACCOUNTS_START: usize = CRANK_SIGNER_IDX as usize + 1; - - { - let transaction_context = &*invoke_context.transaction_context; - let ix_ctx = transaction_context.get_current_instruction_context()?; - let ix_accs_len = ix_ctx.get_number_of_instruction_accounts() as usize; - - // Assert crank executor program. - let program_key = ix_ctx.get_program_key()?; - if program_key != &crate::id() && program_key != &CRANK_PROGRAM_ID { - ic_msg!( - invoke_context, - "ExecuteCrank ERR: crank executor program account not found" - ); - return Err(InstructionError::UnsupportedProgramId); - } - - // Assert enough accounts - if ix_accs_len < ACCOUNTS_START { - ic_msg!( - invoke_context, - "ExecuteCrank ERR: not enough accounts to execute crank ({}), need crank signer and instructions", - ix_accs_len - ); - return Err(InstructionError::MissingAccount); - } - - // Assert Validator is signer - // Only the validator can execute a crank - let validator_pubkey = get_instruction_pubkey_with_idx( - transaction_context, - VALIDATOR_IDX, - )?; - let validator_authority = engine_authority(); - if validator_pubkey != &validator_authority { - ic_msg!( - invoke_context, - "ExecuteCrank ERR: validator pubkey {} is not the expected validator", - validator_pubkey - ); - return Err(InstructionError::IncorrectAuthority); - } - if !signers.contains(validator_pubkey) { - ic_msg!( - invoke_context, - "ExecuteCrank ERR: validator pubkey {} is not in signers", - validator_pubkey - ); - return Err(InstructionError::MissingRequiredSignature); - } - - // Assert Crank signer is provided - let crank_signer_pubkey = get_instruction_pubkey_with_idx( - transaction_context, - CRANK_SIGNER_IDX, - )?; - if crank_signer_pubkey != &crank_signer { - ic_msg!( - invoke_context, - "ExecuteCrank ERR: crank signer pubkey {} is not the expected Crank signer", - crank_signer_pubkey - ); - return Err(InstructionError::InvalidSeeds); - } - } - - // Already validated when scheduling the task. - // This check prevents the validator from manually sending transactions disguised as cranks. - validate_cranks_instructions(invoke_context, authority, &instructions)?; - - let len = instructions.len(); - for ix in instructions { - invoke_context.native_invoke_as(crate::id(), ix, &[crank_signer])?; - } - - ic_msg!(invoke_context, "Executed crank with {} instructions", len); - - Ok(()) -} - -#[cfg(test)] -mod test { - use magicblock_magic_program_api::args::ScheduleTaskArgs; - use solana_account::AccountSharedData; - use solana_instruction::AccountMeta; - use solana_keypair::Keypair; - use solana_sdk_ids::system_program; - - use super::*; - use crate::{ - test_utils::process_instruction, - utils::instruction_utils::InstructionUtils, - validator::{init_validator_authority, validator_authority_id}, - }; - - pub fn complex_ix(payer: Pubkey) -> Instruction { - let mut ix = InstructionUtils::noop_instruction(0); - ix.accounts.push(AccountMeta::new(payer, false)); - ix - } - - #[test] - fn test_execute_task_simple() { - init_validator_authority(Keypair::new()); - let authority = Pubkey::new_unique(); - let crank_signer = crank_signer_pda(&authority); - let ix = InstructionUtils::execute_task_instruction( - validator_authority_id(), - authority, - vec![InstructionUtils::noop_instruction(0)], - ); - let transaction_accounts = vec![ - ( - validator_authority_id(), - AccountSharedData::new(0, 0, &system_program::id()), - ), - ( - crank_signer, - AccountSharedData::new(0, 0, &system_program::id()), - ), - ]; - process_instruction( - &ix.data, - transaction_accounts, - ix.accounts, - Ok(()), - ); - } - - #[test] - fn test_execute_task_complex() { - init_validator_authority(Keypair::new()); - let authority = Pubkey::new_unique(); - let crank_signer = crank_signer_pda(&authority); - let payer = Pubkey::new_unique(); - let ix = InstructionUtils::execute_task_instruction( - validator_authority_id(), - authority, - vec![complex_ix(payer)], - ); - let transaction_accounts = vec![ - ( - validator_authority_id(), - AccountSharedData::new(0, 0, &system_program::id()), - ), - ( - crank_signer, - AccountSharedData::new(0, 0, &system_program::id()), - ), - ( - payer, - AccountSharedData::new(1000000, 0, &system_program::id()), - ), - ]; - process_instruction( - &ix.data, - transaction_accounts, - ix.accounts, - Ok(()), - ); - } - - #[test] - fn fail_execute_task_without_crank_signer() { - init_validator_authority(Keypair::new()); - let authority = Pubkey::new_unique(); - let crank_signer = crank_signer_pda(&authority); - let ix = InstructionUtils::execute_task_instruction( - validator_authority_id(), - authority, - vec![InstructionUtils::noop_instruction(0)], - ); - let transaction_accounts = vec![ - ( - validator_authority_id(), - AccountSharedData::new(0, 0, &system_program::id()), - ), - ( - crank_signer, - AccountSharedData::new(0, 0, &system_program::id()), - ), - ]; - process_instruction( - &ix.data, - transaction_accounts, - vec![], - Err(InstructionError::MissingAccount), - ); - } - - #[test] - fn fail_execute_task_wrong_validator() { - init_validator_authority(Keypair::new()); - let authority = Pubkey::new_unique(); - let crank_signer = crank_signer_pda(&authority); - let ix = InstructionUtils::execute_task_instruction( - validator_authority_id(), - authority, - vec![InstructionUtils::noop_instruction(0)], - ); - let wrong_validator = Pubkey::new_unique(); - let transaction_accounts = vec![ - ( - wrong_validator, - AccountSharedData::new(0, 0, &system_program::id()), - ), - ( - crank_signer, - AccountSharedData::new(0, 0, &system_program::id()), - ), - ]; - process_instruction( - &ix.data, - transaction_accounts, - ix.accounts, - Err(InstructionError::IncorrectAuthority), - ); - } - - #[test] - fn fail_execute_task_validator_not_in_signers() { - init_validator_authority(Keypair::new()); - let authority = Pubkey::new_unique(); - let crank_signer = crank_signer_pda(&authority); - let mut ix = InstructionUtils::execute_task_instruction( - validator_authority_id(), - authority, - vec![InstructionUtils::noop_instruction(0)], - ); - ix.accounts[0].is_signer = false; - let transaction_accounts = vec![ - ( - validator_authority_id(), - AccountSharedData::new(0, 0, &system_program::id()), - ), - ( - crank_signer, - AccountSharedData::new(0, 0, &system_program::id()), - ), - ]; - process_instruction( - &ix.data, - transaction_accounts, - ix.accounts, - Err(InstructionError::MissingRequiredSignature), - ); - } - - #[test] - fn fail_execute_task_wrong_crank_signer() { - init_validator_authority(Keypair::new()); - let authority = Pubkey::new_unique(); - let ix = InstructionUtils::execute_task_instruction( - validator_authority_id(), - authority, - vec![InstructionUtils::noop_instruction(0)], - ); - let wrong_crank_signer = Pubkey::new_unique(); - let transaction_accounts = vec![ - ( - validator_authority_id(), - AccountSharedData::new(0, 0, &system_program::id()), - ), - ( - wrong_crank_signer, - AccountSharedData::new(0, 0, &system_program::id()), - ), - ]; - process_instruction( - &ix.data, - transaction_accounts, - ix.accounts, - Err(InstructionError::InvalidSeeds), - ); - } - - #[test] - fn fail_execute_task_missing_accounts() { - init_validator_authority(Keypair::new()); - let payer = Pubkey::new_unique(); - let authority = Pubkey::new_unique(); - let crank_signer = crank_signer_pda(&authority); - let ix = InstructionUtils::execute_task_instruction( - validator_authority_id(), - authority, - vec![complex_ix(payer)], - ); - let transaction_accounts = vec![ - ( - validator_authority_id(), - AccountSharedData::new(0, 0, &system_program::id()), - ), - ( - crank_signer, - AccountSharedData::new(0, 0, &system_program::id()), - ), - ]; - process_instruction( - &ix.data, - transaction_accounts, - ix.accounts, - Err(InstructionError::MissingAccount), - ); - } - - #[test] - fn fail_execute_task_with_invalid_instructions() { - init_validator_authority(Keypair::new()); - let authority = Pubkey::new_unique(); - let crank_signer = crank_signer_pda(&authority); - let payer = Pubkey::new_unique(); - let mut inner_ix = InstructionUtils::schedule_task_instruction( - &payer, - ScheduleTaskArgs { - task_id: 0, - execution_interval_millis: 1, - iterations: 1, - instructions: vec![InstructionUtils::noop_instruction(0)], - }, - ); - - for (signer, writable, pubkey, expected) in [ - ( - true, - false, - Pubkey::new_unique(), - InstructionError::MissingRequiredSignature, - ), - (false, true, crank_signer, InstructionError::Immutable), - ( - false, - false, - validator_authority_id(), - InstructionError::IncorrectAuthority, - ), - ] { - inner_ix.accounts[0].is_signer = signer; - inner_ix.accounts[0].is_writable = writable; - inner_ix.accounts[0].pubkey = pubkey; - let ix = InstructionUtils::execute_task_instruction( - validator_authority_id(), - authority, - vec![inner_ix.clone()], - ); - - let transaction_accounts = vec![ - ( - validator_authority_id(), - AccountSharedData::new(0, 0, &system_program::id()), - ), - ( - crank_signer, - AccountSharedData::new(0, 0, &system_program::id()), - ), - ]; - - process_instruction( - &ix.data, - transaction_accounts, - ix.accounts, - Err(expected), - ); - } - } -} diff --git a/programs/magicblock/src/schedule_task/process_schedule_task.rs b/programs/magicblock/src/schedule_task/process_schedule_task.rs index 6076044ce..a42b038ad 100644 --- a/programs/magicblock/src/schedule_task/process_schedule_task.rs +++ b/programs/magicblock/src/schedule_task/process_schedule_task.rs @@ -93,11 +93,7 @@ pub(crate) fn process_schedule_task( return Err(InstructionError::InvalidInstructionData); } - validate_cranks_instructions( - invoke_context, - &payer_pubkey, - &args.instructions, - )?; + validate_cranks_instructions(invoke_context, &args.instructions)?; let schedule_request = ScheduleTaskRequest { id: args.task_id, diff --git a/programs/magicblock/src/utils/instruction_utils.rs b/programs/magicblock/src/utils/instruction_utils.rs index 1df5709a6..4d5ac7247 100644 --- a/programs/magicblock/src/utils/instruction_utils.rs +++ b/programs/magicblock/src/utils/instruction_utils.rs @@ -1,8 +1,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use magicblock_magic_program_api::{ - CRANK_PROGRAM_ID, MAGIC_CONTEXT_PUBKEY, args::ScheduleTaskArgs, - instruction::MagicBlockInstruction, pda::crank_signer_pda, + MAGIC_CONTEXT_PUBKEY, args::ScheduleTaskArgs, + instruction::MagicBlockInstruction, }; use solana_hash::Hash; use solana_instruction::{AccountMeta, Instruction}; @@ -214,39 +214,6 @@ impl InstructionUtils { ) } - // ----------------- - // Execute Crank - // ----------------- - pub fn execute_task_instruction( - validator_authority: Pubkey, - authority: Pubkey, - instructions: Vec, - ) -> Instruction { - let mut account_metas = vec![ - AccountMeta::new_readonly(validator_authority, true), - AccountMeta::new_readonly(crank_signer_pda(&authority), false), - ]; - for instruction in &instructions { - account_metas - .push(AccountMeta::new_readonly(instruction.program_id, false)); - account_metas.extend(instruction.accounts.iter().map(|account| { - AccountMeta { - pubkey: account.pubkey, - is_signer: false, - is_writable: account.is_writable, - } - })); - } - Instruction::new_with_wincode( - CRANK_PROGRAM_ID, - &MagicBlockInstruction::ExecuteCrank { - authority, - instructions, - }, - account_metas, - ) - } - // ----------------- // Noop // ----------------- diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index 99451f652..3680a2039 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -91,7 +91,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef29bd7757778f404bd8ef490a7eb5e61c56a55653cda40c52e27eefdac2ba2b" dependencies = [ "agave-feature-set", - "bincode", + "bincode 1.3.3", "ed25519-dalek 1.0.1", "libsecp256k1 0.7.2", "openssl", @@ -707,6 +707,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bitcode" version = "0.6.9" @@ -1983,6 +2002,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "ephemeral-rollups-pinocchio" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a175e532d887f6ae43fd6d314ced9d9c44e714dc38692cb312403897c6b6bdce" +dependencies = [ + "bincode 2.0.1", + "pinocchio 0.11.2", + "pinocchio-system 0.6.1", + "solana-address 2.6.1", +] + [[package]] name = "ephemeral-rollups-sdk" version = "0.14.4" @@ -1990,7 +2021,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "838564736408ada011e9986c819b26cf0195bb2dd238e10be258731979213d75" dependencies = [ "base64ct", - "bincode", + "bincode 1.3.3", "bytemuck", "ephemeral-rollups-sdk-attribute-action", "ephemeral-rollups-sdk-attribute-commit", @@ -2771,6 +2802,19 @@ dependencies = [ "typenum", ] +[[package]] +name = "hydra-api" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25017a85c1e189844ff869fe606fa7621e7346a28329661f03a2b33b8bf44465" +dependencies = [ + "ephemeral-rollups-pinocchio", + "pinocchio 0.11.2", + "solana-address 2.6.1", + "solana-instruction 3.4.0", + "solana-pubkey 4.2.0", +] + [[package]] name = "hyper" version = "1.11.1" @@ -3611,7 +3655,7 @@ version = "0.11.1" source = "git+https://github.com/magicblock-labs/magicblock-engine.git?tag=0.11.1#2e29c4670db6d2d56d6571d9df8f2338c8036d64" dependencies = [ "ahash 0.8.12", - "bincode", + "bincode 1.3.3", "bytemuck", "clonetree", "derive_more", @@ -3648,7 +3692,7 @@ dependencies = [ "agave-feature-set", "arc-swap", "async-trait", - "bincode", + "bincode 1.3.3", "borsh 1.8.1", "futures-util", "helius-laserstream", @@ -3807,7 +3851,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bea16e41e04319201919afca98dd4241b18549f2db0c68f58e4bc531537b5dab" dependencies = [ - "bincode", + "bincode 1.3.3", "borsh 0.10.4", "borsh 1.8.1", "bytemuck", @@ -3817,7 +3861,7 @@ dependencies = [ "pinocchio 0.10.2", "pinocchio-log", "pinocchio-pubkey", - "pinocchio-system", + "pinocchio-system 0.5.0", "rkyv", "serde", "solana-address 2.6.1", @@ -3839,7 +3883,7 @@ name = "magicblock-delegation-program-api" version = "3.1.0" source = "git+https://github.com/magicblock-labs/delegation-program.git?rev=fb6668c7b5ffd6a89bedee1d13b16402fd0e65de#fb6668c7b5ffd6a89bedee1d13b16402fd0e65de" dependencies = [ - "bincode", + "bincode 1.3.3", "borsh 1.8.1", "bytemuck", "const-crypto", @@ -3847,7 +3891,7 @@ dependencies = [ "num_enum", "pinocchio 0.10.2", "pinocchio-log", - "pinocchio-system", + "pinocchio-system 0.5.0", "rkyv", "serde", "solana-address 2.6.1", @@ -4001,7 +4045,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dc8fba0307c90b91b70c9ed06d4242d6c4159f331b2f05bf8f875c2a94e0e98" dependencies = [ - "bincode", + "bincode 1.3.3", "const-crypto", "serde", "solana-program 3.0.0", @@ -4165,21 +4209,20 @@ dependencies = [ name = "magicblock-task-scheduler" version = "0.15.4" dependencies = [ - "chrono", - "futures-util", - "magicblock-config", + "hydra-api", "magicblock-engine", "magicblock-engine-nucleus", "magicblock-keeper", "magicblock-program", - "rusqlite", + "solana-account 4.3.1", "solana-instruction 3.4.0", - "solana-message 4.2.4", + "solana-keypair", "solana-pubkey 4.2.0", - "solana-rpc-client-api", - "solana-signature", + "solana-rpc-client", + "solana-sha256-hasher 3.1.0", + "solana-signer", + "solana-system-interface 3.2.0", "solana-transaction 4.1.5", - "solana-transaction-error 3.3.1", "thiserror 2.0.20", "tokio", "tokio-util", @@ -4686,10 +4729,23 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06810dac15a4ef83d3dabdb4f2f22fb39c9adff669cd2781da4f716510a647c" dependencies = [ - "solana-account-view", + "solana-account-view 1.0.0", "solana-address 2.6.1", "solana-define-syscall 4.0.1", - "solana-instruction-view", + "solana-instruction-view 1.0.0", + "solana-program-error 3.0.1", +] + +[[package]] +name = "pinocchio" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cababcb62a2e739c7078ae16eda02789a6e3edd21bbdded864e85c38a7914d" +dependencies = [ + "solana-account-view 2.0.0", + "solana-address 2.6.1", + "solana-define-syscall 5.2.0", + "solana-instruction-view 2.1.0", "solana-program-error 3.0.1", ] @@ -4734,6 +4790,16 @@ dependencies = [ "solana-address 2.6.1", ] +[[package]] +name = "pinocchio-system" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ae2ee67b42e2ac797dd88312da473c895660e270d8093e0ba2749b885b5778" +dependencies = [ + "pinocchio 0.11.2", + "solana-address 2.6.1", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -6167,7 +6233,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efc0ed36decb689413b9da5d57f2be49eea5bebb3cf7897015167b0c4336e731" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "serde_bytes", "serde_derive", @@ -6184,7 +6250,7 @@ name = "solana-account" version = "4.3.1" source = "git+https://github.com/magicblock-labs/magicblock-engine.git?tag=0.11.1#2e29c4670db6d2d56d6571d9df8f2338c8036d64" dependencies = [ - "bincode", + "bincode 1.3.3", "bitflags 2.13.2", "serde", "serde_bytes", @@ -6206,7 +6272,7 @@ checksum = "d2f5eca13228e4e175b7390222ac9c13b80543cd42a25a47b4bc6373c63caa6d" dependencies = [ "Inflector", "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bs58", "bv", "serde", @@ -6263,7 +6329,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8f5152a288ef1912300fc6efa6c2d1f9bb55d9398eb6c72326360b8063987da" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "solana-program-error 2.2.2", "solana-program-memory 2.3.1", @@ -6276,7 +6342,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9cf16495d9eb53e3d04e72366a33bb1c20c24e78c171d8b8f5978357b63ae95" dependencies = [ - "bincode", + "bincode 1.3.3", "serde_core", "solana-address 2.6.1", "solana-program-error 3.0.1", @@ -6293,6 +6359,16 @@ dependencies = [ "solana-program-error 3.0.1", ] +[[package]] +name = "solana-account-view" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc141b940560430425ebaadb7645496c45f6a10fad9911d719bd03eab7f4d422" +dependencies = [ + "solana-address 2.6.1", + "solana-program-error 3.0.1", +] + [[package]] name = "solana-address" version = "1.1.0" @@ -6333,7 +6409,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1673f67efe870b64a65cb39e6194be5b26527691ce5922909939961a6e6b395" dependencies = [ - "bincode", + "bincode 1.3.3", "bytemuck", "serde", "serde_derive", @@ -6350,7 +6426,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115b4f773acc4f3f3cb986b0d335e9845c0368c82b0940410935bc11ae065578" dependencies = [ - "bincode", + "bincode 1.3.3", "bytemuck", "serde", "serde_derive", @@ -6409,7 +6485,7 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19a3787b8cf9c9fe3dd360800e8b70982b9e5a8af9e11c354b6665dd4a003adc" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "solana-instruction 2.3.3", ] @@ -6420,7 +6496,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "278a1a5bad62cd9da89ac8d4b7ec444e83caa8ae96aa656dfc27684b28d49a5d" dependencies = [ - "bincode", + "bincode 1.3.3", "serde_core", "solana-instruction-error", ] @@ -6616,7 +6692,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e401ae56aed512821cc7a0adaa412ff97fecd2dff4602be7b1330d2daec0c4" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "serde_derive", "solana-account 3.4.0", @@ -6892,7 +6968,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43f5c5382b449e8e4e3016fb05e418c53d57782d8b5c30aa372fc265654b956d" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "serde_derive", "solana-account 2.2.1", @@ -6911,7 +6987,7 @@ version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd7545e02f91da1d6996f32b18f7796aa01e0682f8f3a7434b82cd1a10448add" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "serde_derive", "solana-account 4.3.1", @@ -7039,7 +7115,7 @@ version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bab5682934bd1f65f8d2c16f21cb532526fcc1a09f796e2cacdb091eee5774ad" dependencies = [ - "bincode", + "bincode 1.3.3", "getrandom 0.2.17", "js-sys", "num-traits", @@ -7057,7 +7133,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37ebb0ffd19263051bc3f683fcc086134b8ff23af894dcb63f7563c7137b42f1" dependencies = [ - "bincode", + "bincode 1.3.3", "borsh 1.8.1", "serde", "serde_derive", @@ -7086,12 +7162,24 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60147e4d0a4620013df40bf30a86dd299203ff12fcb8b593cd51014fce0875d8" dependencies = [ - "solana-account-view", + "solana-account-view 1.0.0", "solana-address 2.6.1", "solana-define-syscall 4.0.1", "solana-program-error 3.0.1", ] +[[package]] +name = "solana-instruction-view" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab7a27d0c4214b9f7389c3dd00b68c93093a67f1dcc5b7893aebe299bbcbb47" +dependencies = [ + "solana-account-view 2.0.0", + "solana-address 2.6.1", + "solana-define-syscall 5.2.0", + "solana-program-error 3.0.1", +] + [[package]] name = "solana-instructions-sysvar" version = "2.2.2" @@ -7319,7 +7407,7 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1796aabce376ff74bf89b78d268fa5e683d7d7a96a0a4e4813ec34de49d5314b" dependencies = [ - "bincode", + "bincode 1.3.3", "blake3", "lazy_static", "serde", @@ -7342,7 +7430,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0448b1fd891c5f46491e5dc7d9986385ba3c852c340db2911dd29faa01d2b08d" dependencies = [ - "bincode", + "bincode 1.3.3", "blake3", "lazy_static", "serde", @@ -7531,7 +7619,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98eca145bd3545e2fbb07166e895370576e47a00a7d824e325390d33bf467210" dependencies = [ - "bincode", + "bincode 1.3.3", "blake3", "bs58", "bytemuck", @@ -7800,7 +7888,7 @@ version = "4.2.2" source = "git+https://github.com/magicblock-labs/magicblock-engine.git?tag=0.11.1#2e29c4670db6d2d56d6571d9df8f2338c8036d64" dependencies = [ "base64 0.22.1", - "bincode", + "bincode 1.3.3", "cfg-if", "itertools 0.14.0", "scc", @@ -7966,7 +8054,7 @@ dependencies = [ "agave-votor-messages", "async-trait", "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bs58", "futures", "indicatif", @@ -8080,7 +8168,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f03df7969f5e723ad31b6c9eadccc209037ac4caa34d8dc259316b05c11e82b" dependencies = [ - "bincode", + "bincode 1.3.3", "bs58", "serde", "solana-account 3.4.0", @@ -8604,7 +8692,7 @@ version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d31c37dc2425afc13ddb2fc8365db8dbd02ac860a454c25b3897831036a64dd8" dependencies = [ - "bincode", + "bincode 1.3.3", "libsecp256k1 0.7.2", "num-traits", "solana-account 4.3.1", @@ -8693,7 +8781,7 @@ version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2610e0b091b920da610ad3e5ba850530702f5ac911b4e9dc5ab9a025a772138a" dependencies = [ - "bincode", + "bincode 1.3.3", "log", "solana-account 4.3.1", "solana-bincode 3.1.0", @@ -8718,7 +8806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c3595f95069f3d90f275bb9bd235a1973c4d059028b0a7f81baca2703815db" dependencies = [ "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bytemuck", "bytemuck_derive", "lazy_static", @@ -8755,7 +8843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6690d3dd88f15c21edff68eb391ef8800df7a1f5cec84ee3e8d1abf05affdf74" dependencies = [ "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bytemuck", "bytemuck_derive", "lazy_static", @@ -8789,7 +8877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ada7045bbfdd802af08fbc9c7e2b56ddabb20599d22e4c3840fcef5e7afb8324" dependencies = [ "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bytemuck", "bytemuck_derive", "lazy_static", @@ -8850,7 +8938,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96697cff5075a028265324255efed226099f6d761ca67342b230d09f72cc48d2" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "serde_derive", "solana-address 2.6.1", @@ -8893,7 +8981,7 @@ name = "solana-transaction-context" version = "4.2.2" source = "git+https://github.com/magicblock-labs/magicblock-engine.git?tag=0.11.1#2e29c4670db6d2d56d6571d9df8f2338c8036d64" dependencies = [ - "bincode", + "bincode 1.3.3", "serde", "solana-account 4.3.1", "solana-instruction 3.4.0", @@ -8936,7 +9024,7 @@ dependencies = [ "Inflector", "agave-reserved-account-keys", "base64 0.22.1", - "bincode", + "bincode 1.3.3", "borsh 1.8.1", "bs58", "serde", @@ -8978,7 +9066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "723bb4c23d2b7d0c6bf0fca14637d1e3a802258270ff8dd481fc4a7c67463019" dependencies = [ "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bs58", "serde", "serde_json", @@ -9017,7 +9105,7 @@ version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b80d57478d6599d30acc31cc5ae7f93ec2361a06aefe8ea79bc81739a08af4c3" dependencies = [ - "bincode", + "bincode 1.3.3", "num-derive", "num-traits", "serde", @@ -9041,7 +9129,7 @@ version = "6.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a7a204b455a53fe8c9a26a6885391a5896835e06694dbe0cf37c580f82f128" dependencies = [ - "bincode", + "bincode 1.3.3", "cfg_eval", "num-derive", "num-traits", @@ -9105,7 +9193,7 @@ checksum = "9602bcb1f7af15caef92b91132ec2347e1c51a72ecdbefdaefa3eac4b8711475" dependencies = [ "aes-gcm-siv", "base64 0.22.1", - "bincode", + "bincode 1.3.3", "bytemuck", "bytemuck_derive", "curve25519-dalek 4.1.3", @@ -9782,6 +9870,7 @@ dependencies = [ "anyhow", "chrono", "cleanass", + "hydra-api", "integration-solana-sdk", "integration-test-tools", "magicblock-config", @@ -10015,7 +10104,6 @@ dependencies = [ "futures-sink", "libc", "pin-project-lite", - "slab", "tokio", ] @@ -10414,6 +10502,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "ureq" version = "2.12.1" @@ -10546,6 +10640,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "void" version = "1.0.2" diff --git a/test-integration/Cargo.toml b/test-integration/Cargo.toml index 45d7dfa8c..e639da713 100644 --- a/test-integration/Cargo.toml +++ b/test-integration/Cargo.toml @@ -15,7 +15,7 @@ members = [ "test-table-mania", "test-task-scheduler", "test-tools", - "test-validator-services" + "test-validator-services", ] resolver = "2" @@ -37,6 +37,7 @@ engine = { package = "magicblock-engine", git = "https://github.com/magicblock-l ] } ephemeral-rollups-sdk = { version = "0.14", default-features = false, features = ["modular-sdk"] } futures = "0.3.31" +hydra-api = "0.2" integration-test-tools = { path = "test-tools" } isocountry = "0.3.2" keeper = { package = "magicblock-keeper", git = "https://github.com/magicblock-labs/magicblock-engine.git", tag = "0.11.1", features = [ @@ -50,9 +51,7 @@ light-sdk-types = "0.23" magicblock-aml = { path = "../magicblock-aml" } magicblock-chainlink = { path = "../magicblock-chainlink", features = ["dev-context"] } -magicblock-committor-program = { path = "../magicblock-committor-program", features = [ - "no-entrypoint" -] } +magicblock-committor-program = { path = "../magicblock-committor-program", features = ["no-entrypoint"] } magicblock-committor-service = { path = "../magicblock-committor-service" } magicblock-config = { path = "../magicblock-config" } magicblock-core = { path = "../magicblock-core" } diff --git a/test-integration/configs/schedule-task.devnet.toml b/test-integration/configs/schedule-task.devnet.toml index bbc3ce2fe..5c215eeae 100644 --- a/test-integration/configs/schedule-task.devnet.toml +++ b/test-integration/configs/schedule-task.devnet.toml @@ -21,6 +21,10 @@ path = "../../target/deploy/magicblock_committor_program.so" id = "9hgprgZiRWmy8KkfvUuaVkDGrqo9GzeXMohwq6BazgUY" path = "../target/deploy/program_schedulecommit.so" +[[programs]] +id = "eHyd5BU8QffvHi4GnXwxrK4WpS7pM2x9UGKHBWii7mf" +path = "../programs/hydra/hydra.so" + [metrics] address = "0.0.0.0:9000" diff --git a/test-integration/programs/hydra/hydra.so b/test-integration/programs/hydra/hydra.so new file mode 100755 index 000000000..50cb29c90 Binary files /dev/null and b/test-integration/programs/hydra/hydra.so differ diff --git a/test-integration/test-task-scheduler/Cargo.toml b/test-integration/test-task-scheduler/Cargo.toml index 745a5fbdc..9f91748eb 100644 --- a/test-integration/test-task-scheduler/Cargo.toml +++ b/test-integration/test-task-scheduler/Cargo.toml @@ -7,6 +7,7 @@ version = "0.1.0" anyhow = { workspace = true } chrono = { workspace = true } cleanass = { workspace = true } +hydra-api = { workspace = true, features = ["client"] } integration-test-tools = { workspace = true } magicblock-config = { path = "../../magicblock-config" } magicblock-program = { path = "../../programs/magicblock" } diff --git a/test-integration/test-task-scheduler/src/lib.rs b/test-integration/test-task-scheduler/src/lib.rs index ca367bfd3..6a6e543ad 100644 --- a/test-integration/test-task-scheduler/src/lib.rs +++ b/test-integration/test-task-scheduler/src/lib.rs @@ -1,10 +1,12 @@ use std::{ + path::PathBuf, process::Child, str::FromStr, time::{Duration, Instant}, }; use cleanass::assert; +use hydra_api::ephemeral::ID as HYDRA_EPHEMERAL_PROGRAM_ID; use integration_test_tools::{ expect, loaded_accounts::LoadedAccounts, @@ -13,45 +15,57 @@ use integration_test_tools::{ cleanup, start_magicblock_validator_with_config_struct_and_temp_dir, TMP_DIR_CONFIG, }, - workspace_paths::path_relative_to_workspace, IntegrationTestContext, }; use magicblock_config::{ - config::{LoadableProgram, TaskSchedulerConfig}, + config::LoadableProgram, types::{network::Remote, SerdePubkey}, LeaderParams, }; -use magicblock_program::Pubkey; +use magicblock_program::{ + args::ScheduleTaskArgs, instruction_utils::InstructionUtils, Pubkey, +}; use program_schedulecommit::MainAccount; +use solana_sdk::{ + instruction::Instruction, signature::Keypair, signer::Signer, + transaction::Transaction, +}; use tempfile::TempDir; pub const TASK_SCHEDULER_TICK_MILLIS: u64 = 50; -pub fn setup_validator() -> (TempDir, Child, IntegrationTestContext) { +/// Absolute path to the ephemeral hydra program the ER preloads. +fn hydra_program_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../programs/hydra/hydra.so") +} + +/// Starts a validator whose task scheduler sponsors hydra cranks from the +/// validator identity, and returns that identity alongside the usual handles. +pub fn setup_validator() -> (TempDir, Child, IntegrationTestContext, Pubkey) { let (default_tmpdir, temp_dir) = resolve_tmp_dir(TMP_DIR_CONFIG); + + let loaded_accounts = + LoadedAccounts::with_delegation_program_test_authority(); + let sponsor = loaded_accounts.validator_authority(); + let config = LeaderParams { remotes: vec![ Remote::from_str(IntegrationTestContext::url_chain()).unwrap(), Remote::from_str(IntegrationTestContext::ws_url_chain()).unwrap(), ], - task_scheduler: TaskSchedulerConfig { - reset: true, - min_interval: Duration::from_millis(TASK_SCHEDULER_TICK_MILLIS), - ..Default::default() - }, + // `eHyd5…` is the *ephemeral* hydra program: it executes cranks inside + // the ER, so it is preloaded here rather than cloned from the base + // chain (which only hosts hydra's base-chain counterpart). programs: vec![LoadableProgram { - id: SerdePubkey(program_schedulecommit::ID), - path: path_relative_to_workspace( - "target/deploy/program_schedulecommit.so", - ) - .into(), + id: SerdePubkey(HYDRA_EPHEMERAL_PROGRAM_ID), + path: hydra_program_path(), }], ..Default::default() }; let (default_tmpdir_config, Some(mut validator), port) = start_magicblock_validator_with_config_struct_and_temp_dir( config, - &LoadedAccounts::with_delegation_program_test_authority(), + &loaded_accounts, default_tmpdir, temp_dir, ) @@ -62,7 +76,152 @@ pub fn setup_validator() -> (TempDir, Child, IntegrationTestContext) { IntegrationTestContext::try_new_with_ephem_port(port), validator ); - (default_tmpdir_config, validator, ctx) + (default_tmpdir_config, validator, ctx, sponsor) +} + +/// Sends an ephemeral transaction and asserts it committed successfully. +pub fn send_ephem_tx( + ctx: &IntegrationTestContext, + validator: &mut Child, + instructions: &[Instruction], + payer: &Keypair, +) { + let blockhash = expect!(ctx.try_get_latest_blockhash_ephem(), validator); + // Confirm rather than just send: the caller's next step usually reads state + // this transaction produced, which would otherwise race the ledger. + let (signature, confirmed) = expect!( + ctx.send_and_confirm_transaction_ephem( + &mut Transaction::new_signed_with_payer( + instructions, + Some(&payer.pubkey()), + &[payer], + blockhash, + ), + &[payer] + ), + validator + ); + assert!( + confirmed, + cleanup(validator), + "ephemeral transaction {} was not confirmed", signature + ); +} + +/// The payload a scheduled crank carries in these tests: a single magic-program +/// noop. +/// +/// Cranks reject scheduled instructions that declare signers, so the payload +/// cannot be a transfer or any other instruction that needs authorization. A +/// noop keeps the tests focused on the scheduler and hydra — crank creation, +/// funding, execution and teardown — rather than on a payload program's state. +pub fn noop_task_instructions() -> Vec { + vec![InstructionUtils::noop_instruction(0)] +} + +/// Schedules a recurring noop task through the magic program, as any user +/// program would. The task scheduler observes the request and creates the +/// matching hydra crank. +pub fn schedule_noop_task( + ctx: &IntegrationTestContext, + validator: &mut Child, + payer: &Keypair, + task_id: i64, + execution_interval_millis: i64, + iterations: i64, +) { + send_ephem_tx( + ctx, + validator, + &[InstructionUtils::schedule_task_instruction( + &payer.pubkey(), + ScheduleTaskArgs { + task_id, + execution_interval_millis, + iterations, + instructions: noop_task_instructions(), + }, + )], + payer, + ); +} + +/// Cancels a previously scheduled task through the magic program. +pub fn cancel_task( + ctx: &IntegrationTestContext, + validator: &mut Child, + payer: &Keypair, + task_id: i64, +) { + send_ephem_tx( + ctx, + validator, + &[InstructionUtils::cancel_task_instruction( + &payer.pubkey(), + task_id, + )], + payer, + ); +} + +/// Waits until the hydra crank account for a task exists and is hydra-owned. +pub fn wait_for_hydra_crank( + ctx: &IntegrationTestContext, + crank_pda: &Pubkey, + max_timeout: Duration, + validator: &mut Child, +) { + let now = Instant::now(); + while now.elapsed() < max_timeout { + let maybe_account = ctx + .try_ephem_client() + .ok() + .and_then(|client| client.get_account(crank_pda).ok()); + if let Some(account) = maybe_account { + assert!( + account.owner.to_bytes() + == HYDRA_EPHEMERAL_PROGRAM_ID.to_bytes(), + cleanup(validator), + "crank account {} not owned by hydra program (owner: {})", + crank_pda, + account.owner + ); + return; + } + expect!(ctx.wait_for_next_slot_ephem(), validator); + } + assert!( + false, + cleanup(validator), + "hydra crank account {} was not created before timeout", crank_pda + ); +} + +/// Waits until the hydra crank account for a task has been closed (cancelled). +pub fn wait_for_hydra_crank_closed( + ctx: &IntegrationTestContext, + crank_pda: &Pubkey, + max_timeout: Duration, + validator: &mut Child, +) { + let now = Instant::now(); + while now.elapsed() < max_timeout { + let client = expect!(ctx.try_ephem_client(), validator); + let account = expect!( + client.get_account_with_commitment(crank_pda, ctx.commitment), + validator + ) + .value; + if account.is_none_or(|account| account.lamports == 0) { + return; + } + expect!(ctx.wait_for_next_slot_ephem(), validator); + } + assert!( + false, + cleanup(validator), + "hydra crank account {} was not closed before timeout", crank_pda + ); } pub fn wait_for_committed_count( diff --git a/test-integration/test-task-scheduler/tests/test_schedule_magic_cpi_crank.rs b/test-integration/test-task-scheduler/tests/test_schedule_magic_cpi_crank.rs deleted file mode 100644 index d1436e3dd..000000000 --- a/test-integration/test-task-scheduler/tests/test_schedule_magic_cpi_crank.rs +++ /dev/null @@ -1,131 +0,0 @@ -use std::time::Duration; - -use integration_test_tools::{expect, validator::cleanup}; -use magicblock_program::{ - args::ScheduleTaskArgs, instruction_utils::InstructionUtils, - pda::crank_signer_pda, MAGIC_CONTEXT_PUBKEY, -}; -use program_schedulecommit::{ - api::{ - delegate_account_cpi_instruction, init_account_instruction, - schedule_commit_cpi_instruction, UserSeeds, - }, - ScheduleCommitType, -}; -use solana_sdk::{ - native_token::LAMPORTS_PER_SOL, pubkey::Pubkey, signature::Keypair, - signer::Signer, transaction::Transaction, -}; -use test_task_scheduler::{setup_validator, wait_for_committed_count}; - -#[test] -fn test_crank_can_execute_program_that_cpis_into_magic() { - let (_temp_dir, mut validator, ctx) = setup_validator(); - - let player = Keypair::new(); - let (committee, _) = Pubkey::find_program_address( - &[ - UserSeeds::MagicScheduleCommit.bytes(), - player.pubkey().as_ref(), - ], - &program_schedulecommit::ID, - ); - - expect!( - ctx.airdrop_chain(&player.pubkey(), 10 * LAMPORTS_PER_SOL), - validator - ); - - let chain_blockhash = expect!( - ctx.try_chain_client().and_then(|client| client - .get_latest_blockhash() - .map_err(|e| anyhow::anyhow!( - "Failed to get latest chain blockhash: {}", - e - ))), - validator - ); - - expect!( - ctx.send_transaction_chain( - &mut Transaction::new_signed_with_payer( - &[init_account_instruction( - player.pubkey(), - player.pubkey(), - committee, - )], - Some(&player.pubkey()), - &[&player], - chain_blockhash, - ), - &[&player] - ), - validator - ); - - expect!( - ctx.send_transaction_chain( - &mut Transaction::new_signed_with_payer( - &[delegate_account_cpi_instruction( - player.pubkey(), - None, - player.pubkey(), - UserSeeds::MagicScheduleCommit, - )], - Some(&player.pubkey()), - &[&player], - chain_blockhash, - ), - &[&player] - ), - validator - ); - - expect!(ctx.wait_for_delta_slot_ephem(10), validator); - - let ephem_blockhash = - expect!(ctx.try_get_latest_blockhash_ephem(), validator); - let task_id = 17; - let crank_signer = crank_signer_pda(&player.pubkey()); - let mut crank_ix = schedule_commit_cpi_instruction( - crank_signer, - magicblock_program::ID, - MAGIC_CONTEXT_PUBKEY, - None, - &[player.pubkey()], - &[committee], - ScheduleCommitType::CommitFinalize, - ); - crank_ix.accounts[0].is_writable = false; - - expect!( - ctx.send_transaction_ephem_with_preflight( - &mut Transaction::new_signed_with_payer( - &[InstructionUtils::schedule_task_instruction( - &player.pubkey(), - ScheduleTaskArgs { - task_id, - execution_interval_millis: 10, - iterations: 1, - instructions: vec![crank_ix], - }, - )], - Some(&player.pubkey()), - &[&player], - ephem_blockhash, - ), - &[&player] - ), - validator - ); - - wait_for_committed_count( - &ctx, - &committee, - 1, - Duration::from_secs(15), - &mut validator, - ); - - cleanup(&mut validator); -} diff --git a/test-integration/test-task-scheduler/tests/test_schedule_task.rs b/test-integration/test-task-scheduler/tests/test_schedule_task.rs new file mode 100644 index 000000000..fcbec6303 --- /dev/null +++ b/test-integration/test-task-scheduler/tests/test_schedule_task.rs @@ -0,0 +1,103 @@ +use std::time::Duration; + +use integration_test_tools::{expect, validator::cleanup}; +use magicblock_task_scheduler::crank_pubkey; +use solana_sdk::{ + native_token::LAMPORTS_PER_SOL, signature::Keypair, signer::Signer, +}; +use test_task_scheduler::{ + cancel_task, schedule_noop_task, setup_validator, wait_for_hydra_crank, + wait_for_hydra_crank_closed, +}; + +/// Scheduling a task makes the task scheduler create the hydra crank that will +/// run it, and cancelling the task makes it close that crank again. +#[test] +fn test_schedule_task_creates_and_cancels_hydra_crank() { + let (_temp_dir, mut validator, ctx, _sponsor) = setup_validator(); + + let payer = Keypair::new(); + expect!( + ctx.airdrop_chain(&payer.pubkey(), 10 * LAMPORTS_PER_SOL), + validator + ); + + let task_id = 1; + schedule_noop_task(&ctx, &mut validator, &payer, task_id, 100, 3); + + // The crank lives at a PDA derived from (authority, task_id), so the test + // can locate it without asking the scheduler. + let crank_pda = crank_pubkey(&payer.pubkey(), task_id); + wait_for_hydra_crank( + &ctx, + &crank_pda, + Duration::from_secs(10), + &mut validator, + ); + + cancel_task(&ctx, &mut validator, &payer, task_id); + + wait_for_hydra_crank_closed( + &ctx, + &crank_pda, + Duration::from_secs(10), + &mut validator, + ); + + cleanup(&mut validator); +} + +/// Each (authority, task_id) pair gets its own crank, so one authority +/// cancelling its task leaves another authority's task with the same id alone. +#[test] +fn test_tasks_are_namespaced_per_authority() { + let (_temp_dir, mut validator, ctx, _sponsor) = setup_validator(); + + let payer = Keypair::new(); + let other = Keypair::new(); + for keypair in [&payer, &other] { + expect!( + ctx.airdrop_chain(&keypair.pubkey(), 10 * LAMPORTS_PER_SOL), + validator + ); + } + + // Deliberately the same task id for both authorities. + let task_id = 7; + schedule_noop_task(&ctx, &mut validator, &payer, task_id, 100, 3); + schedule_noop_task(&ctx, &mut validator, &other, task_id, 100, 3); + + let payer_crank = crank_pubkey(&payer.pubkey(), task_id); + let other_crank = crank_pubkey(&other.pubkey(), task_id); + assert_ne!( + payer_crank, other_crank, + "same task id under different authorities must not share a crank" + ); + + for crank in [&payer_crank, &other_crank] { + wait_for_hydra_crank( + &ctx, + crank, + Duration::from_secs(10), + &mut validator, + ); + } + + cancel_task(&ctx, &mut validator, &payer, task_id); + wait_for_hydra_crank_closed( + &ctx, + &payer_crank, + Duration::from_secs(10), + &mut validator, + ); + + // The other authority's crank is untouched by that cancellation. + wait_for_hydra_crank( + &ctx, + &other_crank, + Duration::from_secs(10), + &mut validator, + ); + + cleanup(&mut validator); +} diff --git a/test-integration/test-task-scheduler/tests/test_undrained_sponsor.rs b/test-integration/test-task-scheduler/tests/test_undrained_sponsor.rs new file mode 100644 index 000000000..390a53d87 --- /dev/null +++ b/test-integration/test-task-scheduler/tests/test_undrained_sponsor.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +use cleanass::assert; +use hydra_api::state::{crank_account_size, region_len_for}; +use integration_test_tools::{expect, validator::cleanup}; +use magicblock_program::{ + ephemeral::rent_for, instruction_utils::InstructionUtils, +}; +use magicblock_task_scheduler::crank_pubkey; +use solana_sdk::{signature::Keypair, signer::Signer}; +use test_task_scheduler::{ + cancel_task, schedule_noop_task, setup_validator, wait_for_hydra_crank, + wait_for_hydra_crank_closed, +}; + +/// Hydra cranks are sponsored by the validator identity. Scheduling moves +/// lamports out of it to fund a crank, and cancelling must return them, so a +/// validator that schedules and cancels tasks does not bleed itself dry over +/// time. +#[test] +fn test_sponsor_is_refunded_when_a_task_is_cancelled() { + let (_temp_dir, mut validator, ctx, sponsor) = setup_validator(); + + let sponsor_before = + expect!(ctx.fetch_ephem_account_balance(&sponsor), validator); + + let payer = Keypair::new(); + + let task_id = 1; + let iterations = 3; + schedule_noop_task(&ctx, &mut validator, &payer, task_id, 100, iterations); + + let crank_pda = crank_pubkey(&payer.pubkey(), task_id); + wait_for_hydra_crank( + &ctx, + &crank_pda, + Duration::from_secs(10), + &mut validator, + ); + + let sponsor_while_scheduled = + expect!(ctx.fetch_ephem_account_balance(&sponsor), validator); + + // The scheduler must pre-fund the crank with its rent + let ix = InstructionUtils::noop_instruction(0); + let min_funding = expect!( + rent_for(crank_account_size(region_len_for( + ix.accounts.len(), + ix.data.len() + )) as u32), + validator + ); + assert!( + sponsor_while_scheduled == sponsor_before - min_funding, + cleanup(&mut validator), + "sponsor did not pre-fund the crank: {sponsor_while_scheduled} != {sponsor_before} - {min_funding}" + ); + + cancel_task(&ctx, &mut validator, &payer, task_id); + wait_for_hydra_crank_closed( + &ctx, + &crank_pda, + Duration::from_secs(10), + &mut validator, + ); + + expect!(ctx.wait_for_next_slot_ephem(), validator); + + // Cancelling drains the crank back to the sponsor and refunds its rent, so + // the sponsor ends up whole again apart from transaction fees. + let sponsor_after = + expect!(ctx.fetch_ephem_account_balance(&sponsor), validator); + assert!( + sponsor_after == sponsor_while_scheduled + min_funding, + cleanup(&mut validator), + "cancelling did not refund the crank budget: {sponsor_after} <= {sponsor_while_scheduled}" + ); + assert!( + sponsor_after == sponsor_before, + cleanup(&mut validator), + "sponsor was drained across a schedule/cancel cycle: {sponsor_after} vs {sponsor_before}" + ); + + cleanup(&mut validator); +}