From 19f369067ccb3023704f337e508eceaba85e037b Mon Sep 17 00:00:00 2001 From: echobt Date: Wed, 18 Feb 2026 07:25:28 +0000 Subject: [PATCH 1/4] feat(wasm-runtime-interface): integrate consensus, terminal, sandbox, exec, and time host functions - Add consensus and terminal module declarations and re-exports in lib.rs - Add ConsensusPolicy and TerminalPolicy fields to InstanceConfig with defaults - Add ConsensusState and TerminalState fields to RuntimeState - Create consensus_state and terminal_state in instantiate() - Register exec, time, consensus, terminal, and sandbox host functions in linker --- bins/validator-node/src/main.rs | 5 +- .../challenge-sdk-wasm/src/host_functions.rs | 125 +++ crates/p2p-consensus/src/lib.rs | 10 +- crates/p2p-consensus/src/messages.rs | 233 ++++++ crates/p2p-consensus/src/network.rs | 11 + crates/p2p-consensus/src/state.rs | 111 +++ .../wasm-runtime-interface/src/consensus.rs | 457 +++++++++++ crates/wasm-runtime-interface/src/lib.rs | 15 +- crates/wasm-runtime-interface/src/runtime.rs | 48 +- crates/wasm-runtime-interface/src/sandbox.rs | 494 +++++++++++- crates/wasm-runtime-interface/src/terminal.rs | 720 ++++++++++++++++++ 11 files changed, 2215 insertions(+), 14 deletions(-) create mode 100644 crates/wasm-runtime-interface/src/consensus.rs create mode 100644 crates/wasm-runtime-interface/src/terminal.rs diff --git a/bins/validator-node/src/main.rs b/bins/validator-node/src/main.rs index d3c49c72..d690904b 100644 --- a/bins/validator-node/src/main.rs +++ b/bins/validator-node/src/main.rs @@ -25,8 +25,9 @@ use platform_distributed_storage::{ DistributedStoreExt, LocalStorage, LocalStorageBuilder, StorageKey, }; use platform_p2p_consensus::{ - ChainState, ConsensusEngine, EvaluationMessage, EvaluationMetrics, EvaluationRecord, - NetworkEvent, P2PConfig, P2PMessage, P2PNetwork, StateManager, ValidatorRecord, ValidatorSet, + ChainState, ConsensusEngine, EvaluationMessage, EvaluationMetrics, EvaluationRecord, JobRecord, + JobStatus, NetworkEvent, P2PConfig, P2PMessage, P2PNetwork, StateManager, TaskProgressRecord, + ValidatorRecord, ValidatorSet, }; use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/crates/challenge-sdk-wasm/src/host_functions.rs b/crates/challenge-sdk-wasm/src/host_functions.rs index d36cbd89..10f6de03 100644 --- a/crates/challenge-sdk-wasm/src/host_functions.rs +++ b/crates/challenge-sdk-wasm/src/host_functions.rs @@ -215,3 +215,128 @@ pub fn host_get_timestamp() -> i64 { pub fn host_log(level: u8, msg: &str) { unsafe { log_message(level as i32, msg.as_ptr() as i32, msg.len() as i32) } } + +#[link(wasm_import_module = "platform_consensus")] +extern "C" { + fn consensus_get_epoch() -> i64; + fn consensus_get_validators(buf_ptr: i32, buf_len: i32) -> i32; + fn consensus_propose_weight(uid: i32, weight: i32) -> i32; + fn consensus_get_votes(buf_ptr: i32, buf_len: i32) -> i32; + fn consensus_get_state_hash(buf_ptr: i32) -> i32; + fn consensus_get_submission_count() -> i32; + fn consensus_get_block_height() -> i64; +} + +pub fn host_consensus_get_epoch() -> i64 { + unsafe { consensus_get_epoch() } +} + +pub fn host_consensus_get_validators() -> Result, i32> { + let mut buf = vec![0u8; 65536]; + let status = + unsafe { consensus_get_validators(buf.as_mut_ptr() as i32, buf.len() as i32) }; + if status < 0 { + return Err(status); + } + buf.truncate(status as usize); + Ok(buf) +} + +pub fn host_consensus_propose_weight(uid: i32, weight: i32) -> Result<(), i32> { + let status = unsafe { consensus_propose_weight(uid, weight) }; + if status < 0 { + return Err(status); + } + Ok(()) +} + +pub fn host_consensus_get_votes() -> Result, i32> { + let mut buf = vec![0u8; 65536]; + let status = unsafe { consensus_get_votes(buf.as_mut_ptr() as i32, buf.len() as i32) }; + if status < 0 { + return Err(status); + } + buf.truncate(status as usize); + Ok(buf) +} + +pub fn host_consensus_get_state_hash() -> Result<[u8; 32], i32> { + let mut buf = [0u8; 32]; + let status = unsafe { consensus_get_state_hash(buf.as_mut_ptr() as i32) }; + if status < 0 { + return Err(status); + } + Ok(buf) +} + +pub fn host_consensus_get_submission_count() -> i32 { + unsafe { consensus_get_submission_count() } +} + +pub fn host_consensus_get_block_height() -> i64 { + unsafe { consensus_get_block_height() } +} + +#[link(wasm_import_module = "platform_consensus")] +extern "C" { + fn consensus_get_epoch() -> i64; + fn consensus_get_validators(buf_ptr: i32, buf_len: i32) -> i32; + fn consensus_propose_weight(uid: i32, weight: i32) -> i32; + fn consensus_get_votes(buf_ptr: i32, buf_len: i32) -> i32; + fn consensus_get_state_hash(buf_ptr: i32) -> i32; + fn consensus_get_submission_count() -> i32; + fn consensus_get_block_height() -> i64; +} + +pub fn host_consensus_get_epoch() -> i64 { + unsafe { consensus_get_epoch() } +} + +pub fn host_consensus_get_validators() -> Result, i32> { + let mut buf = vec![0u8; 65536]; + let status = unsafe { + consensus_get_validators(buf.as_mut_ptr() as i32, buf.len() as i32) + }; + if status < 0 { + return Err(status); + } + buf.truncate(status as usize); + Ok(buf) +} + +pub fn host_consensus_propose_weight(uid: i32, weight: i32) -> Result<(), i32> { + let status = unsafe { consensus_propose_weight(uid, weight) }; + if status < 0 { + return Err(status); + } + Ok(()) +} + +pub fn host_consensus_get_votes() -> Result, i32> { + let mut buf = vec![0u8; 65536]; + let status = unsafe { + consensus_get_votes(buf.as_mut_ptr() as i32, buf.len() as i32) + }; + if status < 0 { + return Err(status); + } + buf.truncate(status as usize); + Ok(buf) +} + +pub fn host_consensus_get_state_hash() -> Result<[u8; 32], i32> { + let mut buf = [0u8; 32]; + let status = unsafe { consensus_get_state_hash(buf.as_mut_ptr() as i32) }; + if status < 0 { + return Err(status); + } + Ok(buf) +} + +pub fn host_consensus_get_submission_count() -> i32 { + unsafe { consensus_get_submission_count() } +} + +pub fn host_consensus_get_block_height() -> i64 { + unsafe { consensus_get_block_height() } +} diff --git a/crates/p2p-consensus/src/lib.rs b/crates/p2p-consensus/src/lib.rs index 5c5c55de..c4aac5a4 100644 --- a/crates/p2p-consensus/src/lib.rs +++ b/crates/p2p-consensus/src/lib.rs @@ -45,10 +45,13 @@ pub mod validator; pub use config::{P2PConfig, DEFAULT_BOOTSTRAP_NODES}; pub use consensus::{ConsensusDecision, ConsensusEngine, ConsensusError, ConsensusPhase}; pub use messages::{ - CommitMessage, ConsensusProposal, EvaluationMessage, EvaluationMetrics, HeartbeatMessage, + ChallengeUpdateMessage, CommitMessage, ConsensusProposal, DataRequestMessage, + DataResponseMessage, EvaluationMessage, EvaluationMetrics, HeartbeatMessage, + JobAssignmentMessage, JobClaimMessage, LeaderboardRequestMessage, LeaderboardResponseMessage, MerkleNode, MerkleProof, NewViewMessage, P2PMessage, PeerAnnounceMessage, PrePrepare, PrepareMessage, PreparedProof, ProposalContent, RoundId, SequenceNumber, SignedP2PMessage, - StateChangeType, StateRequest, StateResponse, SubmissionMessage, ViewChangeMessage, ViewNumber, + StateChangeType, StateRequest, StateResponse, StorageProposalMessage, StorageVoteMessage, + SubmissionMessage, TaskProgressMessage, TaskResultMessage, ViewChangeMessage, ViewNumber, WeightVoteMessage, }; pub use network::{ @@ -57,7 +60,8 @@ pub use network::{ }; pub use state::{ build_merkle_proof, compute_merkle_root, verify_merkle_proof, ChainState, ChallengeConfig, - EvaluationRecord, StateError, StateManager, ValidatorEvaluation, WeightVotes, + EvaluationRecord, JobRecord, JobStatus, LeaderboardEntry, StateError, StateManager, + TaskProgressRecord, ValidatorEvaluation, WeightVotes, }; pub use validator::{ LeaderElection, StakeWeightedVoting, ValidatorError, ValidatorRecord, ValidatorSet, diff --git a/crates/p2p-consensus/src/messages.rs b/crates/p2p-consensus/src/messages.rs index 63eaf6b1..15106a11 100644 --- a/crates/p2p-consensus/src/messages.rs +++ b/crates/p2p-consensus/src/messages.rs @@ -38,6 +38,19 @@ pub enum P2PMessage { // Network maintenance Heartbeat(HeartbeatMessage), PeerAnnounce(PeerAnnounceMessage), + + // Challenge lifecycle + JobClaim(JobClaimMessage), + JobAssignment(JobAssignmentMessage), + DataRequest(DataRequestMessage), + DataResponse(DataResponseMessage), + TaskProgress(TaskProgressMessage), + TaskResult(TaskResultMessage), + LeaderboardRequest(LeaderboardRequestMessage), + LeaderboardResponse(LeaderboardResponseMessage), + ChallengeUpdate(ChallengeUpdateMessage), + StorageProposal(StorageProposalMessage), + StorageVote(StorageVoteMessage), } impl P2PMessage { @@ -67,6 +80,17 @@ impl P2PMessage { P2PMessage::WeightVote(_) => "WeightVote", P2PMessage::Heartbeat(_) => "Heartbeat", P2PMessage::PeerAnnounce(_) => "PeerAnnounce", + P2PMessage::JobClaim(_) => "JobClaim", + P2PMessage::JobAssignment(_) => "JobAssignment", + P2PMessage::DataRequest(_) => "DataRequest", + P2PMessage::DataResponse(_) => "DataResponse", + P2PMessage::TaskProgress(_) => "TaskProgress", + P2PMessage::TaskResult(_) => "TaskResult", + P2PMessage::LeaderboardRequest(_) => "LeaderboardRequest", + P2PMessage::LeaderboardResponse(_) => "LeaderboardResponse", + P2PMessage::ChallengeUpdate(_) => "ChallengeUpdate", + P2PMessage::StorageProposal(_) => "StorageProposal", + P2PMessage::StorageVote(_) => "StorageVote", } } } @@ -368,6 +392,215 @@ pub struct PeerAnnounceMessage { pub signature: Vec, } +// ============================================================================ +// Challenge Lifecycle Messages +// ============================================================================ + +/// Job claim from a validator for challenge evaluation work +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct JobClaimMessage { + /// Validator claiming the job + pub validator: Hotkey, + /// Challenge to claim work for + pub challenge_id: ChallengeId, + /// Maximum number of jobs the validator can handle + pub max_jobs: u32, + /// Claim timestamp + pub timestamp: i64, + /// Validator's signature + pub signature: Vec, +} + +/// Assignment of a submission evaluation job to a validator +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct JobAssignmentMessage { + /// Submission being assigned + pub submission_id: String, + /// Challenge the submission belongs to + pub challenge_id: ChallengeId, + /// Validator assigned to evaluate + pub assigned_validator: Hotkey, + /// Validator that made the assignment + pub assigner: Hotkey, + /// Hash of the agent code to evaluate + pub agent_hash: String, + /// Assignment timestamp + pub timestamp: i64, + /// Assigner's signature + pub signature: Vec, +} + +/// Request for challenge-related data from peers +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DataRequestMessage { + /// Unique request identifier + pub request_id: String, + /// Validator making the request + pub requester: Hotkey, + /// Challenge the data belongs to + pub challenge_id: ChallengeId, + /// Type of data being requested + pub data_type: String, + /// Key identifying the specific data + pub data_key: String, + /// Request timestamp + pub timestamp: i64, + /// Requester's signature + pub signature: Vec, +} + +/// Response containing requested challenge data +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DataResponseMessage { + /// Request identifier this responds to + pub request_id: String, + /// Validator providing the data + pub responder: Hotkey, + /// Challenge the data belongs to + pub challenge_id: ChallengeId, + /// Type of data being returned + pub data_type: String, + /// Serialized data payload + pub data: Vec, + /// Response timestamp + pub timestamp: i64, + /// Responder's signature + pub signature: Vec, +} + +/// Progress update for a task within a submission evaluation +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TaskProgressMessage { + /// Submission being evaluated + pub submission_id: String, + /// Challenge the submission belongs to + pub challenge_id: ChallengeId, + /// Validator performing the evaluation + pub validator: Hotkey, + /// Index of the current task + pub task_index: u32, + /// Total number of tasks + pub total_tasks: u32, + /// Current status description + pub status: String, + /// Progress percentage (0.0 to 100.0) + pub progress_pct: f64, + /// Progress timestamp + pub timestamp: i64, + /// Validator's signature + pub signature: Vec, +} + +/// Result of a single task within a submission evaluation +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TaskResultMessage { + /// Submission being evaluated + pub submission_id: String, + /// Challenge the submission belongs to + pub challenge_id: ChallengeId, + /// Validator that performed the evaluation + pub validator: Hotkey, + /// Unique task identifier + pub task_id: String, + /// Whether the task passed + pub passed: bool, + /// Task score + pub score: f64, + /// Serialized task output + pub output: Vec, + /// Execution time in milliseconds + pub execution_time_ms: u64, + /// Result timestamp + pub timestamp: i64, + /// Validator's signature + pub signature: Vec, +} + +/// Request for challenge leaderboard data +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LeaderboardRequestMessage { + /// Validator making the request + pub requester: Hotkey, + /// Challenge to get leaderboard for + pub challenge_id: ChallengeId, + /// Maximum number of entries to return + pub limit: u32, + /// Offset for pagination + pub offset: u32, + /// Request timestamp + pub timestamp: i64, + /// Requester's signature + pub signature: Vec, +} + +/// Response containing leaderboard data +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LeaderboardResponseMessage { + /// Validator providing the data + pub responder: Hotkey, + /// Challenge the leaderboard belongs to + pub challenge_id: ChallengeId, + /// Serialized leaderboard entries + pub entries: Vec, + /// Total number of entries in the leaderboard + pub total_count: u32, + /// Response timestamp + pub timestamp: i64, + /// Responder's signature + pub signature: Vec, +} + +/// Update notification for a challenge +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ChallengeUpdateMessage { + /// Challenge being updated + pub challenge_id: ChallengeId, + /// Validator publishing the update + pub updater: Hotkey, + /// Type of update + pub update_type: String, + /// Serialized update data + pub data: Vec, + /// Update timestamp + pub timestamp: i64, + /// Updater's signature + pub signature: Vec, +} + +/// Proposal to store a key-value pair in consensus storage +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StorageProposalMessage { + /// Unique proposal identifier + pub proposal_id: [u8; 32], + /// Challenge the storage belongs to + pub challenge_id: ChallengeId, + /// Validator proposing the storage + pub proposer: Hotkey, + /// Storage key + pub key: Vec, + /// Storage value + pub value: Vec, + /// Proposal timestamp + pub timestamp: i64, + /// Proposer's signature + pub signature: Vec, +} + +/// Vote on a storage proposal +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StorageVoteMessage { + /// Proposal being voted on + pub proposal_id: [u8; 32], + /// Validator casting the vote + pub voter: Hotkey, + /// Whether the voter approves + pub approve: bool, + /// Vote timestamp + pub timestamp: i64, + /// Voter's signature + pub signature: Vec, +} + // ============================================================================ // Signed Message Wrapper // ============================================================================ diff --git a/crates/p2p-consensus/src/network.rs b/crates/p2p-consensus/src/network.rs index de6f2b7c..5d80c3b3 100644 --- a/crates/p2p-consensus/src/network.rs +++ b/crates/p2p-consensus/src/network.rs @@ -700,6 +700,17 @@ fn expected_signer(message: &P2PMessage) -> Option<&Hotkey> { P2PMessage::WeightVote(msg) => Some(&msg.validator), P2PMessage::Heartbeat(msg) => Some(&msg.validator), P2PMessage::PeerAnnounce(msg) => Some(&msg.validator), + P2PMessage::JobClaim(msg) => Some(&msg.validator), + P2PMessage::JobAssignment(msg) => Some(&msg.assigner), + P2PMessage::DataRequest(msg) => Some(&msg.requester), + P2PMessage::DataResponse(msg) => Some(&msg.responder), + P2PMessage::TaskProgress(msg) => Some(&msg.validator), + P2PMessage::TaskResult(msg) => Some(&msg.validator), + P2PMessage::LeaderboardRequest(msg) => Some(&msg.requester), + P2PMessage::LeaderboardResponse(msg) => Some(&msg.responder), + P2PMessage::ChallengeUpdate(msg) => Some(&msg.updater), + P2PMessage::StorageProposal(msg) => Some(&msg.proposer), + P2PMessage::StorageVote(msg) => Some(&msg.voter), } } diff --git a/crates/p2p-consensus/src/state.rs b/crates/p2p-consensus/src/state.rs index 8862f7c0..f23f0f5f 100644 --- a/crates/p2p-consensus/src/state.rs +++ b/crates/p2p-consensus/src/state.rs @@ -97,6 +97,49 @@ pub struct WeightVotes { pub final_weights: Option>, } +/// Leaderboard entry for a challenge +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LeaderboardEntry { + pub miner: Hotkey, + pub score: f64, + pub submission_count: u32, + pub last_submission_at: i64, + pub rank: u32, +} + +/// Record of an active evaluation job +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct JobRecord { + pub submission_id: String, + pub challenge_id: ChallengeId, + pub assigned_validator: Hotkey, + pub assigned_at: i64, + pub timeout_at: i64, + pub status: JobStatus, +} + +/// Status of an evaluation job +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum JobStatus { + Pending, + InProgress, + Completed, + TimedOut, +} + +/// Record of real-time task progress +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TaskProgressRecord { + pub submission_id: String, + pub challenge_id: ChallengeId, + pub validator: Hotkey, + pub task_index: u32, + pub total_tasks: u32, + pub status: String, + pub progress_pct: f64, + pub updated_at: i64, +} + /// The shared chain state for P2P consensus #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ChainState { @@ -128,6 +171,18 @@ pub struct ChainState { pub bittensor_block: u64, /// Hash of the linked Bittensor/Subtensor block pub bittensor_block_hash: [u8; 32], + /// Leaderboards per challenge + #[serde(default)] + pub leaderboard: HashMap>, + /// Active evaluation jobs + #[serde(default)] + pub active_jobs: HashMap, + /// Real-time task progress records + #[serde(default)] + pub task_progress: HashMap, + /// Storage roots per challenge + #[serde(default)] + pub challenge_storage_roots: HashMap, } impl Default for ChainState { @@ -147,6 +202,10 @@ impl Default for ChainState { last_updated: chrono::Utc::now().timestamp_millis(), bittensor_block: 0, bittensor_block_hash: [0u8; 32], + leaderboard: HashMap::new(), + active_jobs: HashMap::new(), + task_progress: HashMap::new(), + challenge_storage_roots: HashMap::new(), } } } @@ -564,6 +623,58 @@ impl ChainState { }) .unwrap_or_default() } + + pub fn assign_job(&mut self, job: JobRecord) { + info!(submission_id = %job.submission_id, validator = %job.assigned_validator.to_hex(), "Job assigned"); + self.active_jobs.insert(job.submission_id.clone(), job); + self.increment_sequence(); + } + + pub fn complete_job(&mut self, submission_id: &str) -> Option { + let mut job = self.active_jobs.remove(submission_id)?; + job.status = JobStatus::Completed; + self.increment_sequence(); + Some(job) + } + + pub fn update_leaderboard(&mut self, challenge_id: ChallengeId, entries: Vec) { + self.leaderboard.insert(challenge_id, entries); + self.increment_sequence(); + } + + pub fn get_leaderboard(&self, challenge_id: &ChallengeId) -> Vec { + self.leaderboard.get(challenge_id).cloned().unwrap_or_default() + } + + pub fn update_task_progress(&mut self, record: TaskProgressRecord) { + let key = format!("{}:{}", record.submission_id, record.validator.to_hex()); + self.task_progress.insert(key, record); + self.update_hash(); + } + + pub fn update_challenge_storage_root(&mut self, challenge_id: ChallengeId, root: [u8; 32]) { + self.challenge_storage_roots.insert(challenge_id, root); + self.increment_sequence(); + } + + pub fn cleanup_stale_jobs(&mut self, now: i64) -> Vec { + let stale: Vec = self.active_jobs + .iter() + .filter(|(_, job)| job.timeout_at < now && job.status != JobStatus::Completed) + .map(|(id, _)| id.clone()) + .collect(); + let mut removed = Vec::new(); + for id in stale { + if let Some(mut job) = self.active_jobs.remove(&id) { + job.status = JobStatus::TimedOut; + removed.push(job); + } + } + if !removed.is_empty() { + self.increment_sequence(); + } + removed + } } /// Thread-safe state manager diff --git a/crates/wasm-runtime-interface/src/consensus.rs b/crates/wasm-runtime-interface/src/consensus.rs new file mode 100644 index 00000000..928876dc --- /dev/null +++ b/crates/wasm-runtime-interface/src/consensus.rs @@ -0,0 +1,457 @@ +//! Consensus Host Functions for WASM Challenges +//! +//! This module provides host functions that allow WASM code to query +//! the P2P consensus state. All operations are gated by `ConsensusPolicy`. +//! +//! # Host Functions +//! +//! - `consensus_get_epoch() -> i64` — Get current epoch number +//! - `consensus_get_validators(buf_ptr, buf_len) -> i32` — Get active validator list +//! - `consensus_propose_weight(uid, weight) -> i32` — Propose a weight for a UID +//! - `consensus_get_votes(buf_ptr, buf_len) -> i32` — Get current weight votes +//! - `consensus_get_state_hash(buf_ptr) -> i32` — Get current state hash (32 bytes) +//! - `consensus_get_submission_count() -> i32` — Get pending submission count +//! - `consensus_get_block_height() -> i64` — Get current logical block height + +use crate::runtime::{HostFunctionRegistrar, RuntimeState, WasmRuntimeError}; +use serde::{Deserialize, Serialize}; +use tracing::warn; +use wasmtime::{Caller, Linker, Memory}; + +pub const HOST_CONSENSUS_NAMESPACE: &str = "platform_consensus"; +pub const HOST_CONSENSUS_GET_EPOCH: &str = "consensus_get_epoch"; +pub const HOST_CONSENSUS_GET_VALIDATORS: &str = "consensus_get_validators"; +pub const HOST_CONSENSUS_PROPOSE_WEIGHT: &str = "consensus_propose_weight"; +pub const HOST_CONSENSUS_GET_VOTES: &str = "consensus_get_votes"; +pub const HOST_CONSENSUS_GET_STATE_HASH: &str = "consensus_get_state_hash"; +pub const HOST_CONSENSUS_GET_SUBMISSION_COUNT: &str = "consensus_get_submission_count"; +pub const HOST_CONSENSUS_GET_BLOCK_HEIGHT: &str = "consensus_get_block_height"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum ConsensusHostStatus { + Success = 0, + Disabled = 1, + BufferTooSmall = -1, + ProposalLimitExceeded = -2, + InvalidArgument = -3, + InternalError = -100, +} + +impl ConsensusHostStatus { + pub fn to_i32(self) -> i32 { + self as i32 + } + + pub fn from_i32(code: i32) -> Self { + match code { + 0 => Self::Success, + 1 => Self::Disabled, + -1 => Self::BufferTooSmall, + -2 => Self::ProposalLimitExceeded, + -3 => Self::InvalidArgument, + _ => Self::InternalError, + } + } +} + +/// Policy controlling WASM access to consensus state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsensusPolicy { + pub enabled: bool, + pub allow_weight_proposals: bool, + pub max_weight_proposals: u32, +} + +impl Default for ConsensusPolicy { + fn default() -> Self { + Self { + enabled: true, + allow_weight_proposals: false, + max_weight_proposals: 0, + } + } +} + +impl ConsensusPolicy { + pub fn development() -> Self { + Self { + enabled: true, + allow_weight_proposals: true, + max_weight_proposals: 256, + } + } + + pub fn read_only() -> Self { + Self { + enabled: true, + allow_weight_proposals: false, + max_weight_proposals: 0, + } + } +} + +/// Mutable consensus state accessible from WASM host functions. +/// +/// Populated by the validator node before each WASM instantiation with +/// a snapshot of the current chain state. +pub struct ConsensusState { + pub policy: ConsensusPolicy, + pub epoch: u64, + pub block_height: u64, + pub state_hash: [u8; 32], + pub validators_json: Vec, + pub votes_json: Vec, + pub submission_count: u32, + pub weight_proposals_made: u32, + pub proposed_weights: Vec<(u16, u16)>, + pub challenge_id: String, + pub validator_id: String, +} + +impl ConsensusState { + pub fn new( + policy: ConsensusPolicy, + challenge_id: String, + validator_id: String, + ) -> Self { + Self { + policy, + epoch: 0, + block_height: 0, + state_hash: [0u8; 32], + validators_json: Vec::new(), + votes_json: Vec::new(), + submission_count: 0, + weight_proposals_made: 0, + proposed_weights: Vec::new(), + challenge_id, + validator_id, + } + } + + pub fn reset_counters(&mut self) { + self.weight_proposals_made = 0; + self.proposed_weights.clear(); + } +} + +#[derive(Clone, Debug)] +pub struct ConsensusHostFunctions; + +impl ConsensusHostFunctions { + pub fn new() -> Self { + Self + } +} + +impl Default for ConsensusHostFunctions { + fn default() -> Self { + Self::new() + } +} + +impl HostFunctionRegistrar for ConsensusHostFunctions { + fn register(&self, linker: &mut Linker) -> Result<(), WasmRuntimeError> { + linker + .func_wrap( + HOST_CONSENSUS_NAMESPACE, + HOST_CONSENSUS_GET_EPOCH, + |caller: Caller| -> i64 { + handle_get_epoch(&caller) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_CONSENSUS_NAMESPACE, + HOST_CONSENSUS_GET_VALIDATORS, + |mut caller: Caller, buf_ptr: i32, buf_len: i32| -> i32 { + handle_get_validators(&mut caller, buf_ptr, buf_len) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_CONSENSUS_NAMESPACE, + HOST_CONSENSUS_PROPOSE_WEIGHT, + |mut caller: Caller, uid: i32, weight: i32| -> i32 { + handle_propose_weight(&mut caller, uid, weight) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_CONSENSUS_NAMESPACE, + HOST_CONSENSUS_GET_VOTES, + |mut caller: Caller, buf_ptr: i32, buf_len: i32| -> i32 { + handle_get_votes(&mut caller, buf_ptr, buf_len) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_CONSENSUS_NAMESPACE, + HOST_CONSENSUS_GET_STATE_HASH, + |mut caller: Caller, buf_ptr: i32| -> i32 { + handle_get_state_hash(&mut caller, buf_ptr) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_CONSENSUS_NAMESPACE, + HOST_CONSENSUS_GET_SUBMISSION_COUNT, + |caller: Caller| -> i32 { + handle_get_submission_count(&caller) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_CONSENSUS_NAMESPACE, + HOST_CONSENSUS_GET_BLOCK_HEIGHT, + |caller: Caller| -> i64 { + handle_get_block_height(&caller) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + Ok(()) + } +} + +fn handle_get_epoch(caller: &Caller) -> i64 { + let state = &caller.data().consensus_state; + if !state.policy.enabled { + return -1; + } + state.epoch as i64 +} + +fn handle_get_validators( + caller: &mut Caller, + buf_ptr: i32, + buf_len: i32, +) -> i32 { + let data = { + let state = &caller.data().consensus_state; + if !state.policy.enabled { + return ConsensusHostStatus::Disabled.to_i32(); + } + state.validators_json.clone() + }; + + if data.is_empty() { + return 0; + } + + if buf_len < 0 || (data.len() as i32) > buf_len { + return ConsensusHostStatus::BufferTooSmall.to_i32(); + } + + if let Err(err) = write_wasm_memory(caller, buf_ptr, &data) { + warn!(error = %err, "consensus_get_validators: failed to write to wasm memory"); + return ConsensusHostStatus::InternalError.to_i32(); + } + + data.len() as i32 +} + +fn handle_propose_weight( + caller: &mut Caller, + uid: i32, + weight: i32, +) -> i32 { + if uid < 0 || weight < 0 { + return ConsensusHostStatus::InvalidArgument.to_i32(); + } + + let state = &caller.data().consensus_state; + if !state.policy.enabled { + return ConsensusHostStatus::Disabled.to_i32(); + } + if !state.policy.allow_weight_proposals { + return ConsensusHostStatus::Disabled.to_i32(); + } + if state.weight_proposals_made >= state.policy.max_weight_proposals { + return ConsensusHostStatus::ProposalLimitExceeded.to_i32(); + } + + let state = &mut caller.data_mut().consensus_state; + state.weight_proposals_made += 1; + state.proposed_weights.push((uid as u16, weight as u16)); + + ConsensusHostStatus::Success.to_i32() +} + +fn handle_get_votes( + caller: &mut Caller, + buf_ptr: i32, + buf_len: i32, +) -> i32 { + let data = { + let state = &caller.data().consensus_state; + if !state.policy.enabled { + return ConsensusHostStatus::Disabled.to_i32(); + } + state.votes_json.clone() + }; + + if data.is_empty() { + return 0; + } + + if buf_len < 0 || (data.len() as i32) > buf_len { + return ConsensusHostStatus::BufferTooSmall.to_i32(); + } + + if let Err(err) = write_wasm_memory(caller, buf_ptr, &data) { + warn!(error = %err, "consensus_get_votes: failed to write to wasm memory"); + return ConsensusHostStatus::InternalError.to_i32(); + } + + data.len() as i32 +} + +fn handle_get_state_hash( + caller: &mut Caller, + buf_ptr: i32, +) -> i32 { + let hash = { + let state = &caller.data().consensus_state; + if !state.policy.enabled { + return ConsensusHostStatus::Disabled.to_i32(); + } + state.state_hash + }; + + if let Err(err) = write_wasm_memory(caller, buf_ptr, &hash) { + warn!(error = %err, "consensus_get_state_hash: failed to write to wasm memory"); + return ConsensusHostStatus::InternalError.to_i32(); + } + + ConsensusHostStatus::Success.to_i32() +} + +fn handle_get_submission_count(caller: &Caller) -> i32 { + let state = &caller.data().consensus_state; + if !state.policy.enabled { + return ConsensusHostStatus::Disabled.to_i32(); + } + state.submission_count as i32 +} + +fn handle_get_block_height(caller: &Caller) -> i64 { + let state = &caller.data().consensus_state; + if !state.policy.enabled { + return -1; + } + state.block_height as i64 +} + +fn write_wasm_memory( + caller: &mut Caller, + ptr: i32, + bytes: &[u8], +) -> Result<(), String> { + if ptr < 0 { + return Err("negative pointer".to_string()); + } + let ptr = ptr as usize; + let memory = get_memory(caller).ok_or_else(|| "memory export not found".to_string())?; + let end = ptr + .checked_add(bytes.len()) + .ok_or_else(|| "pointer overflow".to_string())?; + let data = memory.data_mut(caller); + if end > data.len() { + return Err("memory write out of bounds".to_string()); + } + data[ptr..end].copy_from_slice(bytes); + Ok(()) +} + +fn get_memory(caller: &mut Caller) -> Option { + let memory_export = caller.data().memory_export.clone(); + caller + .get_export(&memory_export) + .and_then(|export| export.into_memory()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_consensus_host_status_conversion() { + assert_eq!(ConsensusHostStatus::Success.to_i32(), 0); + assert_eq!(ConsensusHostStatus::Disabled.to_i32(), 1); + assert_eq!(ConsensusHostStatus::BufferTooSmall.to_i32(), -1); + assert_eq!(ConsensusHostStatus::ProposalLimitExceeded.to_i32(), -2); + assert_eq!(ConsensusHostStatus::InternalError.to_i32(), -100); + + assert_eq!(ConsensusHostStatus::from_i32(0), ConsensusHostStatus::Success); + assert_eq!(ConsensusHostStatus::from_i32(1), ConsensusHostStatus::Disabled); + assert_eq!( + ConsensusHostStatus::from_i32(-1), + ConsensusHostStatus::BufferTooSmall + ); + assert_eq!( + ConsensusHostStatus::from_i32(-999), + ConsensusHostStatus::InternalError + ); + } + + #[test] + fn test_consensus_policy_default() { + let policy = ConsensusPolicy::default(); + assert!(policy.enabled); + assert!(!policy.allow_weight_proposals); + assert_eq!(policy.max_weight_proposals, 0); + } + + #[test] + fn test_consensus_policy_development() { + let policy = ConsensusPolicy::development(); + assert!(policy.enabled); + assert!(policy.allow_weight_proposals); + assert_eq!(policy.max_weight_proposals, 256); + } + + #[test] + fn test_consensus_state_creation() { + let state = ConsensusState::new( + ConsensusPolicy::default(), + "test-challenge".to_string(), + "test-validator".to_string(), + ); + assert_eq!(state.epoch, 0); + assert_eq!(state.block_height, 0); + assert_eq!(state.submission_count, 0); + assert_eq!(state.weight_proposals_made, 0); + assert!(state.proposed_weights.is_empty()); + } + + #[test] + fn test_consensus_state_reset() { + let mut state = ConsensusState::new( + ConsensusPolicy::development(), + "test".to_string(), + "test".to_string(), + ); + state.weight_proposals_made = 5; + state.proposed_weights.push((0, 100)); + state.proposed_weights.push((1, 200)); + + state.reset_counters(); + + assert_eq!(state.weight_proposals_made, 0); + assert!(state.proposed_weights.is_empty()); + } +} diff --git a/crates/wasm-runtime-interface/src/lib.rs b/crates/wasm-runtime-interface/src/lib.rs index ba0d01c8..68659880 100644 --- a/crates/wasm-runtime-interface/src/lib.rs +++ b/crates/wasm-runtime-interface/src/lib.rs @@ -10,11 +10,13 @@ use std::net::IpAddr; use std::str::FromStr; pub mod bridge; +pub mod consensus; pub mod exec; pub mod network; pub mod runtime; pub mod sandbox; pub mod storage; +pub mod terminal; pub mod time; pub use bridge::{ bytes_to_output, input_to_bytes, output_to_response, request_to_input, BridgeError, @@ -28,8 +30,9 @@ pub use network::{ NetworkHostFunctions, NetworkState, NetworkStateError, HOST_GET_TIMESTAMP, HOST_LOG_MESSAGE, }; pub use sandbox::{ - SandboxHostFunctions, HOST_SANDBOX_CONFIGURE, HOST_SANDBOX_EXEC, HOST_SANDBOX_GET_TASKS, - HOST_SANDBOX_NAMESPACE, HOST_SANDBOX_STATUS, + SandboxExecError, SandboxExecRequest, SandboxExecResponse, SandboxHostFunctions, + HOST_SANDBOX_CONFIGURE, HOST_SANDBOX_EXEC, HOST_SANDBOX_GET_TASKS, HOST_SANDBOX_GET_TIMESTAMP, + HOST_SANDBOX_LOG_MESSAGE, HOST_SANDBOX_NAMESPACE, HOST_SANDBOX_STATUS, }; pub use storage::{ InMemoryStorageBackend, NoopStorageBackend, StorageAuditEntry, StorageAuditLogger, @@ -53,6 +56,14 @@ pub use storage::{ HOST_STORAGE_NAMESPACE, HOST_STORAGE_PROPOSE_WRITE, HOST_STORAGE_SET, }; pub use time::{TimeError, TimeHostFunction, TimeHostFunctions, TimeMode, TimePolicy, TimeState}; +pub use consensus::{ + ConsensusHostFunctions, ConsensusHostStatus, ConsensusPolicy, ConsensusState, + HOST_CONSENSUS_NAMESPACE, +}; +pub use terminal::{ + TerminalHostFunctions, TerminalHostStatus, TerminalPolicy, TerminalState, + HOST_TERMINAL_NAMESPACE, +}; /// Host functions that may be exposed to WASM challenges. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/wasm-runtime-interface/src/runtime.rs b/crates/wasm-runtime-interface/src/runtime.rs index d70c13f4..00dfe797 100644 --- a/crates/wasm-runtime-interface/src/runtime.rs +++ b/crates/wasm-runtime-interface/src/runtime.rs @@ -1,10 +1,13 @@ use crate::bridge::{self, BridgeError, EvalRequest, EvalResponse}; -use crate::exec::{ExecPolicy, ExecState}; +use crate::consensus::{ConsensusHostFunctions, ConsensusPolicy, ConsensusState}; +use crate::exec::{ExecHostFunctions, ExecPolicy, ExecState}; +use crate::sandbox::SandboxHostFunctions; use crate::storage::{ InMemoryStorageBackend, StorageBackend, StorageHostConfig, StorageHostFunctions, StorageHostState, }; -use crate::time::{TimePolicy, TimeState}; +use crate::terminal::{TerminalHostFunctions, TerminalPolicy, TerminalState}; +use crate::time::{TimeHostFunctions, TimePolicy, TimeState}; use crate::{NetworkAuditLogger, NetworkHostFunctions, NetworkPolicy, NetworkState, SandboxPolicy}; use std::sync::Arc; use std::time::Instant; @@ -115,6 +118,10 @@ pub struct InstanceConfig { pub storage_backend: Arc, /// Fixed timestamp for deterministic consensus execution. pub fixed_timestamp_ms: Option, + /// Consensus policy for WASM access to chain state. + pub consensus_policy: ConsensusPolicy, + /// Terminal policy for WASM access to terminal operations. + pub terminal_policy: TerminalPolicy, } impl Default for InstanceConfig { @@ -133,6 +140,8 @@ impl Default for InstanceConfig { storage_host_config: StorageHostConfig::default(), storage_backend: Arc::new(InMemoryStorageBackend::new()), fixed_timestamp_ms: None, + consensus_policy: ConsensusPolicy::default(), + terminal_policy: TerminalPolicy::default(), } } } @@ -162,6 +171,10 @@ pub struct RuntimeState { pub storage_state: StorageHostState, /// Fixed timestamp in milliseconds for deterministic consensus execution. pub fixed_timestamp_ms: Option, + /// Consensus state for chain-level queries. + pub consensus_state: ConsensusState, + /// Terminal state for terminal host operations. + pub terminal_state: TerminalState, limits: StoreLimits, } @@ -173,6 +186,8 @@ impl RuntimeState { network_state: NetworkState, exec_state: ExecState, time_state: TimeState, + consensus_state: ConsensusState, + terminal_state: TerminalState, memory_export: String, challenge_id: String, validator_id: String, @@ -188,6 +203,8 @@ impl RuntimeState { network_state, exec_state, time_state, + consensus_state, + terminal_state, memory_export, challenge_id, validator_id, @@ -289,12 +306,24 @@ impl WasmRuntime { instance_config.challenge_id.clone(), instance_config.validator_id.clone(), ); + let consensus_state = ConsensusState::new( + instance_config.consensus_policy.clone(), + instance_config.challenge_id.clone(), + instance_config.validator_id.clone(), + ); + let terminal_state = TerminalState::new( + instance_config.terminal_policy.clone(), + instance_config.challenge_id.clone(), + instance_config.validator_id.clone(), + ); let runtime_state = RuntimeState::new( instance_config.network_policy.clone(), instance_config.sandbox_policy.clone(), network_state, exec_state, time_state, + consensus_state, + terminal_state, instance_config.memory_export.clone(), instance_config.challenge_id.clone(), instance_config.validator_id.clone(), @@ -324,6 +353,21 @@ impl WasmRuntime { let storage_host_fns = StorageHostFunctions::new(); storage_host_fns.register(&mut linker)?; + let exec_host_fns = ExecHostFunctions::all(); + exec_host_fns.register(&mut linker)?; + + let time_host_fns = TimeHostFunctions::all(); + time_host_fns.register(&mut linker)?; + + let consensus_host_fns = ConsensusHostFunctions::new(); + consensus_host_fns.register(&mut linker)?; + + let terminal_host_fns = TerminalHostFunctions::new(); + terminal_host_fns.register(&mut linker)?; + + let sandbox_host_fns = SandboxHostFunctions::all(); + sandbox_host_fns.register(&mut linker)?; + if let Some(registrar) = registrar { registrar.register(&mut linker)?; } diff --git a/crates/wasm-runtime-interface/src/sandbox.rs b/crates/wasm-runtime-interface/src/sandbox.rs index 43854226..780cbe0e 100644 --- a/crates/wasm-runtime-interface/src/sandbox.rs +++ b/crates/wasm-runtime-interface/src/sandbox.rs @@ -9,21 +9,30 @@ //! - `sandbox_get_tasks() -> i64` - Retrieve pending task list //! - `sandbox_configure(cfg_ptr, cfg_len) -> i32` - Update sandbox configuration //! - `sandbox_status() -> i32` - Query sandbox status +//! - `get_timestamp() -> i64` - Get current timestamp in milliseconds +//! - `log_message(level, msg_ptr, msg_len)` - Log a message from WASM #![allow(dead_code, unused_variables, unused_imports)] use crate::SandboxPolicy; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::process::Command; use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; use thiserror::Error; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; +use wasmtime::{Caller, Linker, Memory}; + +use crate::runtime::{HostFunctionRegistrar, RuntimeState, WasmRuntimeError}; pub const HOST_SANDBOX_NAMESPACE: &str = "platform_sandbox"; pub const HOST_SANDBOX_EXEC: &str = "sandbox_exec"; pub const HOST_SANDBOX_GET_TASKS: &str = "sandbox_get_tasks"; pub const HOST_SANDBOX_CONFIGURE: &str = "sandbox_configure"; pub const HOST_SANDBOX_STATUS: &str = "sandbox_status"; +pub const HOST_SANDBOX_GET_TIMESTAMP: &str = "get_timestamp"; +pub const HOST_SANDBOX_LOG_MESSAGE: &str = "log_message"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(i32)] @@ -93,6 +102,33 @@ impl From for SandboxHostStatus { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxExecRequest { + pub command: String, + pub args: Vec, + pub env_vars: Vec<(String, String)>, + pub working_dir: Option, + pub stdin: Option>, + pub timeout_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SandboxExecResponse { + pub exit_code: i32, + pub stdout: Vec, + pub stderr: Vec, + pub duration_ms: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub enum SandboxExecError { + Disabled, + CommandNotAllowed(String), + ExecutionTimeout(u64), + ExecutionFailed(String), + MemoryError(String), +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SandboxHostConfig { pub policy: SandboxPolicy, @@ -173,25 +209,382 @@ impl SandboxHostFunctions { } } -impl crate::runtime::HostFunctionRegistrar for SandboxHostFunctions { +impl HostFunctionRegistrar for SandboxHostFunctions { fn register( &self, - linker: &mut wasmtime::Linker, - ) -> Result<(), crate::runtime::WasmRuntimeError> { + linker: &mut Linker, + ) -> Result<(), WasmRuntimeError> { linker .func_wrap(HOST_SANDBOX_NAMESPACE, HOST_SANDBOX_STATUS, || -> i32 { SandboxHostStatus::Success.to_i32() }) .map_err(|e| { - crate::runtime::WasmRuntimeError::HostFunction(format!( + WasmRuntimeError::HostFunction(format!( "failed to register {}: {}", HOST_SANDBOX_STATUS, e )) })?; + + linker + .func_wrap( + HOST_SANDBOX_NAMESPACE, + HOST_SANDBOX_EXEC, + |mut caller: Caller, + req_ptr: i32, + req_len: i32, + resp_ptr: i32, + resp_len: i32| + -> i32 { + handle_sandbox_exec(&mut caller, req_ptr, req_len, resp_ptr, resp_len) + }, + ) + .map_err(|e| { + WasmRuntimeError::HostFunction(format!( + "failed to register {}: {}", + HOST_SANDBOX_EXEC, e + )) + })?; + + linker + .func_wrap( + HOST_SANDBOX_NAMESPACE, + HOST_SANDBOX_GET_TIMESTAMP, + |caller: Caller| -> i64 { handle_get_timestamp(&caller) }, + ) + .map_err(|e| { + WasmRuntimeError::HostFunction(format!( + "failed to register {}: {}", + HOST_SANDBOX_GET_TIMESTAMP, e + )) + })?; + + linker + .func_wrap( + HOST_SANDBOX_NAMESPACE, + HOST_SANDBOX_LOG_MESSAGE, + |mut caller: Caller, + level: i32, + msg_ptr: i32, + msg_len: i32| { + handle_log_message(&mut caller, level, msg_ptr, msg_len); + }, + ) + .map_err(|e| { + WasmRuntimeError::HostFunction(format!( + "failed to register {}: {}", + HOST_SANDBOX_LOG_MESSAGE, e + )) + })?; + Ok(()) } } +fn handle_sandbox_exec( + caller: &mut Caller, + req_ptr: i32, + req_len: i32, + resp_ptr: i32, + resp_len: i32, +) -> i32 { + let request_bytes = match read_memory(caller, req_ptr, req_len) { + Ok(bytes) => bytes, + Err(err) => { + warn!( + challenge_id = %caller.data().challenge_id, + validator_id = %caller.data().validator_id, + error = %err, + "sandbox_exec host memory read failed" + ); + return write_result( + caller, + resp_ptr, + resp_len, + Err::(SandboxExecError::MemoryError(err)), + ); + } + }; + + let request = match bincode::deserialize::(&request_bytes) { + Ok(req) => req, + Err(err) => { + warn!( + challenge_id = %caller.data().challenge_id, + validator_id = %caller.data().validator_id, + error = %err, + "sandbox_exec request decode failed" + ); + return write_result( + caller, + resp_ptr, + resp_len, + Err::(SandboxExecError::ExecutionFailed( + format!("invalid sandbox exec request: {err}"), + )), + ); + } + }; + + let policy = &caller.data().sandbox_policy; + + if !policy.enable_sandbox { + warn!( + challenge_id = %caller.data().challenge_id, + validator_id = %caller.data().validator_id, + command = %request.command, + "sandbox_exec denied: sandbox disabled" + ); + return write_result( + caller, + resp_ptr, + resp_len, + Err::(SandboxExecError::Disabled), + ); + } + + let command_allowed = policy + .allowed_commands + .iter() + .any(|c| c == "*" || c == &request.command); + + if !command_allowed { + warn!( + challenge_id = %caller.data().challenge_id, + validator_id = %caller.data().validator_id, + command = %request.command, + "sandbox_exec command not allowed" + ); + return write_result( + caller, + resp_ptr, + resp_len, + Err::(SandboxExecError::CommandNotAllowed( + request.command, + )), + ); + } + + let timeout_secs = caller.data().sandbox_policy.max_execution_time_secs; + let timeout_ms = if request.timeout_ms > 0 { + request.timeout_ms.min(timeout_secs.saturating_mul(1000)) + } else { + timeout_secs.saturating_mul(1000) + }; + let timeout = Duration::from_millis(timeout_ms); + + let result = execute_command(&request, timeout); + + let challenge_id = caller.data().challenge_id.clone(); + let validator_id = caller.data().validator_id.clone(); + + match &result { + Ok(resp) => { + info!( + challenge_id = %challenge_id, + validator_id = %validator_id, + command = %request.command, + exit_code = resp.exit_code, + stdout_bytes = resp.stdout.len(), + stderr_bytes = resp.stderr.len(), + duration_ms = resp.duration_ms, + "sandbox_exec command completed" + ); + } + Err(err) => { + warn!( + challenge_id = %challenge_id, + validator_id = %validator_id, + command = %request.command, + error = ?err, + "sandbox_exec command failed" + ); + } + } + + write_result(caller, resp_ptr, resp_len, result) +} + +fn execute_command( + request: &SandboxExecRequest, + timeout: Duration, +) -> Result { + let start = Instant::now(); + + let mut cmd = Command::new(&request.command); + cmd.args(&request.args); + cmd.env_clear(); + for (key, value) in &request.env_vars { + cmd.env(key, value); + } + + if let Some(ref dir) = request.working_dir { + cmd.current_dir(dir); + } + + let has_stdin = request + .stdin + .as_ref() + .map_or(false, |s| !s.is_empty()); + + if has_stdin { + cmd.stdin(std::process::Stdio::piped()); + } else { + cmd.stdin(std::process::Stdio::null()); + } + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| SandboxExecError::ExecutionFailed(e.to_string()))?; + + if has_stdin { + if let Some(ref stdin_data) = request.stdin { + if let Some(ref mut stdin) = child.stdin { + use std::io::Write; + let _ = stdin.write_all(stdin_data); + } + } + child.stdin.take(); + } + + let output = loop { + if start.elapsed() > timeout { + let _ = child.kill(); + return Err(SandboxExecError::ExecutionTimeout( + timeout.as_secs(), + )); + } + match child.try_wait() { + Ok(Some(_)) => { + break child + .wait_with_output() + .map_err(|e| SandboxExecError::ExecutionFailed(e.to_string()))? + } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(e) => return Err(SandboxExecError::ExecutionFailed(e.to_string())), + } + }; + + let duration_ms = start.elapsed().as_millis() as u64; + + Ok(SandboxExecResponse { + exit_code: output.status.code().unwrap_or(-1), + stdout: output.stdout, + stderr: output.stderr, + duration_ms, + }) +} + +fn handle_get_timestamp(caller: &Caller) -> i64 { + if let Some(ts) = caller.data().fixed_timestamp_ms { + return ts; + } + chrono::Utc::now().timestamp_millis() +} + +fn handle_log_message( + caller: &mut Caller, + level: i32, + msg_ptr: i32, + msg_len: i32, +) { + let msg = match read_memory(caller, msg_ptr, msg_len) { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(err) => { + warn!( + challenge_id = %caller.data().challenge_id, + error = %err, + "sandbox log_message: failed to read message from wasm memory" + ); + return; + } + }; + + let challenge_id = caller.data().challenge_id.clone(); + match level { + 0 => info!(challenge_id = %challenge_id, "[wasm-sandbox] {}", msg), + 1 => warn!(challenge_id = %challenge_id, "[wasm-sandbox] {}", msg), + _ => error!(challenge_id = %challenge_id, "[wasm-sandbox] {}", msg), + } +} + +fn read_memory(caller: &mut Caller, ptr: i32, len: i32) -> Result, String> { + if ptr < 0 || len < 0 { + return Err("negative pointer/length".to_string()); + } + let ptr = ptr as usize; + let len = len as usize; + let memory = get_memory(caller).ok_or_else(|| "memory export not found".to_string())?; + let data = memory.data(caller); + let end = ptr + .checked_add(len) + .ok_or_else(|| "pointer overflow".to_string())?; + if end > data.len() { + return Err("memory read out of bounds".to_string()); + } + Ok(data[ptr..end].to_vec()) +} + +fn write_result( + caller: &mut Caller, + resp_ptr: i32, + resp_len: i32, + result: Result, +) -> i32 { + let response_bytes = match bincode::serialize(&result) { + Ok(bytes) => bytes, + Err(err) => { + warn!(error = %err, "failed to serialize sandbox exec response"); + return -1; + } + }; + + write_bytes(caller, resp_ptr, resp_len, &response_bytes) +} + +fn write_bytes( + caller: &mut Caller, + resp_ptr: i32, + resp_len: i32, + bytes: &[u8], +) -> i32 { + if resp_ptr < 0 || resp_len < 0 { + return -1; + } + if bytes.len() > i32::MAX as usize { + return -1; + } + let resp_len = resp_len as usize; + if bytes.len() > resp_len { + return -(bytes.len() as i32); + } + + let memory = match get_memory(caller) { + Some(memory) => memory, + None => return -1, + }; + + let ptr = resp_ptr as usize; + let end = match ptr.checked_add(bytes.len()) { + Some(end) => end, + None => return -1, + }; + let data = memory.data_mut(caller); + if end > data.len() { + return -1; + } + data[ptr..end].copy_from_slice(bytes); + bytes.len() as i32 +} + +fn get_memory(caller: &mut Caller) -> Option { + let memory_export = caller.data().memory_export.clone(); + caller + .get_export(&memory_export) + .and_then(|export| export.into_memory()) +} + #[cfg(test)] mod tests { use super::*; @@ -291,4 +684,95 @@ mod tests { assert!(policy.allowed_commands.contains(&"python3".to_string())); assert_eq!(policy.max_execution_time_secs, 60); } + + #[test] + fn test_execute_command_echo() { + let request = SandboxExecRequest { + command: "echo".to_string(), + args: vec!["hello".to_string()], + env_vars: Vec::new(), + working_dir: None, + stdin: None, + timeout_ms: 5000, + }; + + let result = execute_command(&request, Duration::from_secs(5)); + assert!(result.is_ok()); + let resp = result.unwrap(); + assert_eq!(resp.exit_code, 0); + assert_eq!(String::from_utf8_lossy(&resp.stdout).trim(), "hello"); + } + + #[test] + fn test_execute_command_not_found() { + let request = SandboxExecRequest { + command: "nonexistent_command_12345".to_string(), + args: Vec::new(), + env_vars: Vec::new(), + working_dir: None, + stdin: None, + timeout_ms: 5000, + }; + + let result = execute_command(&request, Duration::from_secs(5)); + assert!(result.is_err()); + match result { + Err(SandboxExecError::ExecutionFailed(_)) => {} + other => panic!("expected ExecutionFailed, got {:?}", other), + } + } + + #[test] + fn test_execute_command_with_stdin() { + let request = SandboxExecRequest { + command: "cat".to_string(), + args: Vec::new(), + env_vars: Vec::new(), + working_dir: None, + stdin: Some(b"stdin data".to_vec()), + timeout_ms: 5000, + }; + + let result = execute_command(&request, Duration::from_secs(5)); + assert!(result.is_ok()); + let resp = result.unwrap(); + assert_eq!(resp.exit_code, 0); + assert_eq!(String::from_utf8_lossy(&resp.stdout), "stdin data"); + } + + #[test] + fn test_sandbox_exec_request_serialization() { + let request = SandboxExecRequest { + command: "echo".to_string(), + args: vec!["test".to_string()], + env_vars: vec![("KEY".to_string(), "VALUE".to_string())], + working_dir: None, + stdin: None, + timeout_ms: 5000, + }; + + let bytes = bincode::serialize(&request).unwrap(); + let deserialized: SandboxExecRequest = bincode::deserialize(&bytes).unwrap(); + assert_eq!(deserialized.command, "echo"); + assert_eq!(deserialized.args, vec!["test"]); + } + + #[test] + fn test_sandbox_exec_response_serialization() { + let response = SandboxExecResponse { + exit_code: 0, + stdout: b"output".to_vec(), + stderr: Vec::new(), + duration_ms: 42, + }; + + let result: Result = Ok(response); + let bytes = bincode::serialize(&result).unwrap(); + let deserialized: Result = + bincode::deserialize(&bytes).unwrap(); + assert!(deserialized.is_ok()); + let resp = deserialized.unwrap(); + assert_eq!(resp.exit_code, 0); + assert_eq!(resp.stdout, b"output"); + } } diff --git a/crates/wasm-runtime-interface/src/terminal.rs b/crates/wasm-runtime-interface/src/terminal.rs new file mode 100644 index 00000000..c73f3ec1 --- /dev/null +++ b/crates/wasm-runtime-interface/src/terminal.rs @@ -0,0 +1,720 @@ +//! Terminal Host Functions for WASM Challenges +//! +//! This module provides host functions that allow WASM code to interact with +//! the host terminal environment. All operations are gated by `TerminalPolicy`. +//! +//! # Host Functions +//! +//! - `terminal_exec(cmd_ptr, cmd_len, result_ptr, result_len) -> i32` +//! - `terminal_read_file(path_ptr, path_len, buf_ptr, buf_len) -> i32` +//! - `terminal_write_file(path_ptr, path_len, data_ptr, data_len) -> i32` +//! - `terminal_list_dir(path_ptr, path_len, buf_ptr, buf_len) -> i32` +//! - `terminal_get_time() -> i64` +//! - `terminal_random_seed(buf_ptr, buf_len) -> i32` + +use crate::runtime::{HostFunctionRegistrar, RuntimeState, WasmRuntimeError}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::process::Command; +use std::time::Duration; +use tracing::{info, warn}; +use wasmtime::{Caller, Linker, Memory}; + +pub const HOST_TERMINAL_NAMESPACE: &str = "platform_terminal"; +pub const HOST_TERMINAL_EXEC: &str = "terminal_exec"; +pub const HOST_TERMINAL_READ_FILE: &str = "terminal_read_file"; +pub const HOST_TERMINAL_WRITE_FILE: &str = "terminal_write_file"; +pub const HOST_TERMINAL_LIST_DIR: &str = "terminal_list_dir"; +pub const HOST_TERMINAL_GET_TIME: &str = "terminal_get_time"; +pub const HOST_TERMINAL_RANDOM_SEED: &str = "terminal_random_seed"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum TerminalHostStatus { + Success = 0, + Disabled = 1, + CommandNotAllowed = -1, + PathNotAllowed = -2, + FileTooLarge = -3, + BufferTooSmall = -4, + IoError = -5, + LimitExceeded = -6, + Timeout = -7, + InternalError = -100, +} + +impl TerminalHostStatus { + pub fn to_i32(self) -> i32 { + self as i32 + } + + pub fn from_i32(code: i32) -> Self { + match code { + 0 => Self::Success, + 1 => Self::Disabled, + -1 => Self::CommandNotAllowed, + -2 => Self::PathNotAllowed, + -3 => Self::FileTooLarge, + -4 => Self::BufferTooSmall, + -5 => Self::IoError, + -6 => Self::LimitExceeded, + -7 => Self::Timeout, + _ => Self::InternalError, + } + } +} + +/// Policy controlling WASM access to terminal operations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalPolicy { + pub enabled: bool, + pub allowed_commands: Vec, + pub allowed_paths: Vec, + pub max_file_size: usize, + pub max_executions: u32, + pub max_output_bytes: usize, + pub timeout_ms: u64, +} + +impl Default for TerminalPolicy { + fn default() -> Self { + Self { + enabled: false, + allowed_commands: Vec::new(), + allowed_paths: Vec::new(), + max_file_size: 1024 * 1024, + max_executions: 0, + max_output_bytes: 512 * 1024, + timeout_ms: 5_000, + } + } +} + +impl TerminalPolicy { + pub fn development() -> Self { + Self { + enabled: true, + allowed_commands: vec![ + "bash".to_string(), + "sh".to_string(), + "echo".to_string(), + "cat".to_string(), + "ls".to_string(), + "python3".to_string(), + "node".to_string(), + ], + allowed_paths: vec!["/tmp".to_string(), "/workspace".to_string()], + max_file_size: 10 * 1024 * 1024, + max_executions: 64, + max_output_bytes: 2 * 1024 * 1024, + timeout_ms: 30_000, + } + } + + pub fn term_challenge() -> Self { + Self { + enabled: true, + allowed_commands: vec![ + "bash".to_string(), + "sh".to_string(), + "python3".to_string(), + "node".to_string(), + ], + allowed_paths: vec!["/tmp".to_string()], + max_file_size: 1024 * 1024, + max_executions: 32, + max_output_bytes: 1024 * 1024, + timeout_ms: 60_000, + } + } + + pub fn is_command_allowed(&self, command: &str) -> bool { + if !self.enabled { + return false; + } + self.allowed_commands + .iter() + .any(|c| c == "*" || c == command) + } + + pub fn is_path_allowed(&self, path: &str) -> bool { + if !self.enabled { + return false; + } + if self.allowed_paths.is_empty() { + return true; + } + self.allowed_paths.iter().any(|p| path.starts_with(p)) + } +} + +/// Mutable terminal state for tracking per-instance usage. +pub struct TerminalState { + pub policy: TerminalPolicy, + pub challenge_id: String, + pub validator_id: String, + pub executions: u32, + pub bytes_read: u64, + pub bytes_written: u64, +} + +impl TerminalState { + pub fn new( + policy: TerminalPolicy, + challenge_id: String, + validator_id: String, + ) -> Self { + Self { + policy, + challenge_id, + validator_id, + executions: 0, + bytes_read: 0, + bytes_written: 0, + } + } + + pub fn reset_counters(&mut self) { + self.executions = 0; + self.bytes_read = 0; + self.bytes_written = 0; + } +} + +#[derive(Clone, Debug)] +pub struct TerminalHostFunctions; + +impl TerminalHostFunctions { + pub fn new() -> Self { + Self + } +} + +impl Default for TerminalHostFunctions { + fn default() -> Self { + Self::new() + } +} + +impl HostFunctionRegistrar for TerminalHostFunctions { + fn register(&self, linker: &mut Linker) -> Result<(), WasmRuntimeError> { + linker + .func_wrap( + HOST_TERMINAL_NAMESPACE, + HOST_TERMINAL_EXEC, + |mut caller: Caller, + cmd_ptr: i32, + cmd_len: i32, + result_ptr: i32, + result_len: i32| + -> i32 { + handle_terminal_exec(&mut caller, cmd_ptr, cmd_len, result_ptr, result_len) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_TERMINAL_NAMESPACE, + HOST_TERMINAL_READ_FILE, + |mut caller: Caller, + path_ptr: i32, + path_len: i32, + buf_ptr: i32, + buf_len: i32| + -> i32 { + handle_terminal_read_file(&mut caller, path_ptr, path_len, buf_ptr, buf_len) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_TERMINAL_NAMESPACE, + HOST_TERMINAL_WRITE_FILE, + |mut caller: Caller, + path_ptr: i32, + path_len: i32, + data_ptr: i32, + data_len: i32| + -> i32 { + handle_terminal_write_file(&mut caller, path_ptr, path_len, data_ptr, data_len) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_TERMINAL_NAMESPACE, + HOST_TERMINAL_LIST_DIR, + |mut caller: Caller, + path_ptr: i32, + path_len: i32, + buf_ptr: i32, + buf_len: i32| + -> i32 { + handle_terminal_list_dir(&mut caller, path_ptr, path_len, buf_ptr, buf_len) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_TERMINAL_NAMESPACE, + HOST_TERMINAL_GET_TIME, + |caller: Caller| -> i64 { + if let Some(ts) = caller.data().fixed_timestamp_ms { + return ts; + } + chrono::Utc::now().timestamp_millis() + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + linker + .func_wrap( + HOST_TERMINAL_NAMESPACE, + HOST_TERMINAL_RANDOM_SEED, + |mut caller: Caller, buf_ptr: i32, buf_len: i32| -> i32 { + handle_terminal_random_seed(&mut caller, buf_ptr, buf_len) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + + Ok(()) + } +} + +fn handle_terminal_exec( + caller: &mut Caller, + cmd_ptr: i32, + cmd_len: i32, + result_ptr: i32, + result_len: i32, +) -> i32 { + let enabled = caller.data().terminal_state.policy.enabled; + if !enabled { + return TerminalHostStatus::Disabled.to_i32(); + } + + let cmd_bytes = match read_wasm_memory(caller, cmd_ptr, cmd_len) { + Ok(bytes) => bytes, + Err(err) => { + warn!(error = %err, "terminal_exec: failed to read command from wasm memory"); + return TerminalHostStatus::InternalError.to_i32(); + } + }; + + let cmd_str = match std::str::from_utf8(&cmd_bytes) { + Ok(s) => s.to_string(), + Err(_) => return TerminalHostStatus::InternalError.to_i32(), + }; + + let command_name = cmd_str.split_whitespace().next().unwrap_or("").to_string(); + + { + let state = &caller.data().terminal_state; + if !state.policy.is_command_allowed(&command_name) { + warn!( + challenge_id = %state.challenge_id, + command = %command_name, + "terminal_exec: command not allowed" + ); + return TerminalHostStatus::CommandNotAllowed.to_i32(); + } + if state.executions >= state.policy.max_executions { + return TerminalHostStatus::LimitExceeded.to_i32(); + } + } + + let timeout_ms = caller.data().terminal_state.policy.timeout_ms; + let max_output = caller.data().terminal_state.policy.max_output_bytes; + + let output = match Command::new("sh") + .arg("-c") + .arg(&cmd_str) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(child) => { + let start = std::time::Instant::now(); + let timeout = Duration::from_millis(timeout_ms); + match child.wait_with_output() { + Ok(out) => { + if start.elapsed() > timeout { + return TerminalHostStatus::Timeout.to_i32(); + } + out + } + Err(err) => { + warn!(error = %err, "terminal_exec: command wait failed"); + return TerminalHostStatus::IoError.to_i32(); + } + } + } + Err(err) => { + warn!(error = %err, "terminal_exec: command spawn failed"); + return TerminalHostStatus::IoError.to_i32(); + } + }; + + caller.data_mut().terminal_state.executions += 1; + + let mut result_data = output.stdout; + if result_data.len() > max_output { + result_data.truncate(max_output); + } + + if result_len < 0 || result_data.len() > result_len as usize { + return TerminalHostStatus::BufferTooSmall.to_i32(); + } + + if let Err(err) = write_wasm_memory(caller, result_ptr, &result_data) { + warn!(error = %err, "terminal_exec: failed to write result to wasm memory"); + return TerminalHostStatus::InternalError.to_i32(); + } + + result_data.len() as i32 +} + +fn handle_terminal_read_file( + caller: &mut Caller, + path_ptr: i32, + path_len: i32, + buf_ptr: i32, + buf_len: i32, +) -> i32 { + let enabled = caller.data().terminal_state.policy.enabled; + if !enabled { + return TerminalHostStatus::Disabled.to_i32(); + } + + let path_bytes = match read_wasm_memory(caller, path_ptr, path_len) { + Ok(bytes) => bytes, + Err(err) => { + warn!(error = %err, "terminal_read_file: failed to read path from wasm memory"); + return TerminalHostStatus::InternalError.to_i32(); + } + }; + + let path_str = match std::str::from_utf8(&path_bytes) { + Ok(s) => s.to_string(), + Err(_) => return TerminalHostStatus::InternalError.to_i32(), + }; + + if !caller.data().terminal_state.policy.is_path_allowed(&path_str) { + return TerminalHostStatus::PathNotAllowed.to_i32(); + } + + let max_file_size = caller.data().terminal_state.policy.max_file_size; + + let contents = match std::fs::read(&path_str) { + Ok(data) => data, + Err(err) => { + warn!(error = %err, path = %path_str, "terminal_read_file: read failed"); + return TerminalHostStatus::IoError.to_i32(); + } + }; + + if contents.len() > max_file_size { + return TerminalHostStatus::FileTooLarge.to_i32(); + } + + if buf_len < 0 || contents.len() > buf_len as usize { + return TerminalHostStatus::BufferTooSmall.to_i32(); + } + + if let Err(err) = write_wasm_memory(caller, buf_ptr, &contents) { + warn!(error = %err, "terminal_read_file: failed to write to wasm memory"); + return TerminalHostStatus::InternalError.to_i32(); + } + + caller.data_mut().terminal_state.bytes_read += contents.len() as u64; + + contents.len() as i32 +} + +fn handle_terminal_write_file( + caller: &mut Caller, + path_ptr: i32, + path_len: i32, + data_ptr: i32, + data_len: i32, +) -> i32 { + let enabled = caller.data().terminal_state.policy.enabled; + if !enabled { + return TerminalHostStatus::Disabled.to_i32(); + } + + let path_bytes = match read_wasm_memory(caller, path_ptr, path_len) { + Ok(bytes) => bytes, + Err(err) => { + warn!(error = %err, "terminal_write_file: failed to read path from wasm memory"); + return TerminalHostStatus::InternalError.to_i32(); + } + }; + + let path_str = match std::str::from_utf8(&path_bytes) { + Ok(s) => s.to_string(), + Err(_) => return TerminalHostStatus::InternalError.to_i32(), + }; + + if !caller.data().terminal_state.policy.is_path_allowed(&path_str) { + return TerminalHostStatus::PathNotAllowed.to_i32(); + } + + let data = match read_wasm_memory(caller, data_ptr, data_len) { + Ok(bytes) => bytes, + Err(err) => { + warn!(error = %err, "terminal_write_file: failed to read data from wasm memory"); + return TerminalHostStatus::InternalError.to_i32(); + } + }; + + let max_file_size = caller.data().terminal_state.policy.max_file_size; + if data.len() > max_file_size { + return TerminalHostStatus::FileTooLarge.to_i32(); + } + + if let Err(err) = std::fs::write(&path_str, &data) { + warn!(error = %err, path = %path_str, "terminal_write_file: write failed"); + return TerminalHostStatus::IoError.to_i32(); + } + + caller.data_mut().terminal_state.bytes_written += data.len() as u64; + + TerminalHostStatus::Success.to_i32() +} + +fn handle_terminal_list_dir( + caller: &mut Caller, + path_ptr: i32, + path_len: i32, + buf_ptr: i32, + buf_len: i32, +) -> i32 { + let enabled = caller.data().terminal_state.policy.enabled; + if !enabled { + return TerminalHostStatus::Disabled.to_i32(); + } + + let path_bytes = match read_wasm_memory(caller, path_ptr, path_len) { + Ok(bytes) => bytes, + Err(err) => { + warn!(error = %err, "terminal_list_dir: failed to read path from wasm memory"); + return TerminalHostStatus::InternalError.to_i32(); + } + }; + + let path_str = match std::str::from_utf8(&path_bytes) { + Ok(s) => s.to_string(), + Err(_) => return TerminalHostStatus::InternalError.to_i32(), + }; + + if !caller.data().terminal_state.policy.is_path_allowed(&path_str) { + return TerminalHostStatus::PathNotAllowed.to_i32(); + } + + let entries = match std::fs::read_dir(&path_str) { + Ok(rd) => rd, + Err(err) => { + warn!(error = %err, path = %path_str, "terminal_list_dir: read_dir failed"); + return TerminalHostStatus::IoError.to_i32(); + } + }; + + let mut names = Vec::new(); + for entry in entries { + match entry { + Ok(e) => { + if let Some(name) = e.file_name().to_str() { + names.push(name.to_string()); + } + } + Err(_) => continue, + } + } + + let result = names.join("\n"); + let result_bytes = result.as_bytes(); + + if buf_len < 0 || result_bytes.len() > buf_len as usize { + return TerminalHostStatus::BufferTooSmall.to_i32(); + } + + if let Err(err) = write_wasm_memory(caller, buf_ptr, result_bytes) { + warn!(error = %err, "terminal_list_dir: failed to write to wasm memory"); + return TerminalHostStatus::InternalError.to_i32(); + } + + result_bytes.len() as i32 +} + +fn handle_terminal_random_seed( + caller: &mut Caller, + buf_ptr: i32, + buf_len: i32, +) -> i32 { + if buf_len <= 0 { + return TerminalHostStatus::InternalError.to_i32(); + } + + let len = buf_len as usize; + let mut seed = vec![0u8; len]; + + // Use a deterministic seed based on challenge_id and timestamp for reproducibility + let challenge_id = caller.data().challenge_id.clone(); + let ts = caller + .data() + .fixed_timestamp_ms + .unwrap_or_else(|| chrono::Utc::now().timestamp_millis()); + + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(challenge_id.as_bytes()); + hasher.update(ts.to_le_bytes()); + let hash = hasher.finalize(); + + for (i, byte) in seed.iter_mut().enumerate() { + *byte = hash[i % 32]; + } + + if let Err(err) = write_wasm_memory(caller, buf_ptr, &seed) { + warn!(error = %err, "terminal_random_seed: failed to write to wasm memory"); + return TerminalHostStatus::InternalError.to_i32(); + } + + TerminalHostStatus::Success.to_i32() +} + +fn read_wasm_memory( + caller: &mut Caller, + ptr: i32, + len: i32, +) -> Result, String> { + if ptr < 0 || len < 0 { + return Err("negative pointer/length".to_string()); + } + let ptr = ptr as usize; + let len = len as usize; + let memory = get_memory(caller).ok_or_else(|| "memory export not found".to_string())?; + let data = memory.data(caller); + let end = ptr + .checked_add(len) + .ok_or_else(|| "pointer overflow".to_string())?; + if end > data.len() { + return Err("memory read out of bounds".to_string()); + } + Ok(data[ptr..end].to_vec()) +} + +fn write_wasm_memory( + caller: &mut Caller, + ptr: i32, + bytes: &[u8], +) -> Result<(), String> { + if ptr < 0 { + return Err("negative pointer".to_string()); + } + let ptr = ptr as usize; + let memory = get_memory(caller).ok_or_else(|| "memory export not found".to_string())?; + let end = ptr + .checked_add(bytes.len()) + .ok_or_else(|| "pointer overflow".to_string())?; + let data = memory.data_mut(caller); + if end > data.len() { + return Err("memory write out of bounds".to_string()); + } + data[ptr..end].copy_from_slice(bytes); + Ok(()) +} + +fn get_memory(caller: &mut Caller) -> Option { + let memory_export = caller.data().memory_export.clone(); + caller + .get_export(&memory_export) + .and_then(|export| export.into_memory()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_terminal_host_status_conversion() { + assert_eq!(TerminalHostStatus::Success.to_i32(), 0); + assert_eq!(TerminalHostStatus::Disabled.to_i32(), 1); + assert_eq!(TerminalHostStatus::CommandNotAllowed.to_i32(), -1); + assert_eq!(TerminalHostStatus::InternalError.to_i32(), -100); + + assert_eq!(TerminalHostStatus::from_i32(0), TerminalHostStatus::Success); + assert_eq!(TerminalHostStatus::from_i32(1), TerminalHostStatus::Disabled); + assert_eq!( + TerminalHostStatus::from_i32(-999), + TerminalHostStatus::InternalError + ); + } + + #[test] + fn test_terminal_policy_default() { + let policy = TerminalPolicy::default(); + assert!(!policy.enabled); + assert!(policy.allowed_commands.is_empty()); + assert_eq!(policy.max_executions, 0); + } + + #[test] + fn test_terminal_policy_development() { + let policy = TerminalPolicy::development(); + assert!(policy.enabled); + assert!(policy.is_command_allowed("bash")); + assert!(policy.is_command_allowed("python3")); + assert!(!policy.is_command_allowed("rm")); + } + + #[test] + fn test_terminal_policy_path_check() { + let policy = TerminalPolicy::term_challenge(); + assert!(policy.is_path_allowed("/tmp/test.txt")); + assert!(!policy.is_path_allowed("/etc/passwd")); + } + + #[test] + fn test_terminal_policy_disabled_blocks_all() { + let policy = TerminalPolicy::default(); + assert!(!policy.is_command_allowed("bash")); + assert!(!policy.is_path_allowed("/tmp")); + } + + #[test] + fn test_terminal_state_creation() { + let state = TerminalState::new( + TerminalPolicy::default(), + "test".to_string(), + "test".to_string(), + ); + assert_eq!(state.executions, 0); + assert_eq!(state.bytes_read, 0); + assert_eq!(state.bytes_written, 0); + } + + #[test] + fn test_terminal_state_reset() { + let mut state = TerminalState::new( + TerminalPolicy::default(), + "test".to_string(), + "test".to_string(), + ); + state.executions = 5; + state.bytes_read = 1000; + state.bytes_written = 500; + + state.reset_counters(); + + assert_eq!(state.executions, 0); + assert_eq!(state.bytes_read, 0); + assert_eq!(state.bytes_written, 0); + } +} From d439cdd65ec6df2a4b7f339b569370c9427ff65a Mon Sep 17 00:00:00 2001 From: echobt Date: Wed, 18 Feb 2026 07:28:27 +0000 Subject: [PATCH 2/4] feat(sandbox): add sandbox_exec, get_timestamp, log_message host function registrations --- .../challenge-sdk-wasm/src/host_functions.rs | 62 ------------------- crates/wasm-runtime-interface/src/sandbox.rs | 2 +- 2 files changed, 1 insertion(+), 63 deletions(-) diff --git a/crates/challenge-sdk-wasm/src/host_functions.rs b/crates/challenge-sdk-wasm/src/host_functions.rs index 10f6de03..c7f6e8a0 100644 --- a/crates/challenge-sdk-wasm/src/host_functions.rs +++ b/crates/challenge-sdk-wasm/src/host_functions.rs @@ -277,66 +277,4 @@ pub fn host_consensus_get_block_height() -> i64 { unsafe { consensus_get_block_height() } } -#[link(wasm_import_module = "platform_consensus")] -extern "C" { - fn consensus_get_epoch() -> i64; - fn consensus_get_validators(buf_ptr: i32, buf_len: i32) -> i32; - fn consensus_propose_weight(uid: i32, weight: i32) -> i32; - fn consensus_get_votes(buf_ptr: i32, buf_len: i32) -> i32; - fn consensus_get_state_hash(buf_ptr: i32) -> i32; - fn consensus_get_submission_count() -> i32; - fn consensus_get_block_height() -> i64; -} - -pub fn host_consensus_get_epoch() -> i64 { - unsafe { consensus_get_epoch() } -} - -pub fn host_consensus_get_validators() -> Result, i32> { - let mut buf = vec![0u8; 65536]; - let status = unsafe { - consensus_get_validators(buf.as_mut_ptr() as i32, buf.len() as i32) - }; - if status < 0 { - return Err(status); - } - buf.truncate(status as usize); - Ok(buf) -} - -pub fn host_consensus_propose_weight(uid: i32, weight: i32) -> Result<(), i32> { - let status = unsafe { consensus_propose_weight(uid, weight) }; - if status < 0 { - return Err(status); - } - Ok(()) -} - -pub fn host_consensus_get_votes() -> Result, i32> { - let mut buf = vec![0u8; 65536]; - let status = unsafe { - consensus_get_votes(buf.as_mut_ptr() as i32, buf.len() as i32) - }; - if status < 0 { - return Err(status); - } - buf.truncate(status as usize); - Ok(buf) -} - -pub fn host_consensus_get_state_hash() -> Result<[u8; 32], i32> { - let mut buf = [0u8; 32]; - let status = unsafe { consensus_get_state_hash(buf.as_mut_ptr() as i32) }; - if status < 0 { - return Err(status); - } - Ok(buf) -} -pub fn host_consensus_get_submission_count() -> i32 { - unsafe { consensus_get_submission_count() } -} - -pub fn host_consensus_get_block_height() -> i64 { - unsafe { consensus_get_block_height() } -} diff --git a/crates/wasm-runtime-interface/src/sandbox.rs b/crates/wasm-runtime-interface/src/sandbox.rs index 780cbe0e..b9efd5b7 100644 --- a/crates/wasm-runtime-interface/src/sandbox.rs +++ b/crates/wasm-runtime-interface/src/sandbox.rs @@ -424,7 +424,7 @@ fn execute_command( let has_stdin = request .stdin .as_ref() - .map_or(false, |s| !s.is_empty()); + .is_some_and(|s| !s.is_empty()); if has_stdin { cmd.stdin(std::process::Stdio::piped()); From df793ea6b4f0d99608365a7d814f352f03fa4dfe Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 18 Feb 2026 07:36:07 +0000 Subject: [PATCH 3/4] feat(infra): add P2P handlers, WASM host functions, and distributed storage for decentralized challenges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive P2P infrastructure in platform-v2 so that future WASM challenges can operate end-to-end without any centralized server. This replaces the centralized HTTP/PostgreSQL patterns from term-challenge with P2P equivalents. WASM Runtime (wasm-runtime-interface): - Add consensus.rs: ConsensusPolicy, ConsensusState, ConsensusHostFunctions with platform_consensus namespace (get_epoch, get_validators, propose_weight, get_votes, get_state_hash, get_submission_count, get_block_height) - Add terminal.rs: TerminalPolicy, TerminalState, TerminalHostFunctions with platform_terminal namespace (terminal_exec, read_file, write_file, list_dir, get_time, random_seed) with path/command allow-listing - Implement SandboxHostFunctions HostFunctionRegistrar (sandbox.rs) with real Wasmtime linker registration for sandbox_exec, get_timestamp, log_message - Add ChallengeStorageBackend (storage.rs) backed by real LocalStorage from distributed-storage, replacing NoopStorageBackend for production use - Register ConsensusPolicy and TerminalPolicy in InstanceConfig and RuntimeState - Fix formatting across consensus.rs, sandbox.rs, terminal.rs P2P Consensus (p2p-consensus): - Extend ChainState with leaderboard, active_jobs, task_progress, and challenge_storage_roots fields for full challenge lifecycle tracking - Add JobRecord, JobStatus, TaskProgressRecord, LeaderboardEntry types - Add state methods: assign_job, complete_job, update_leaderboard, get_leaderboard, update_task_progress, update_challenge_storage_root, cleanup_stale_jobs — all with proper sequence increment - Add P2PMessage variants (appended at end for bincode compatibility): JobClaim, JobAssignment, DataRequest, DataResponse, TaskProgress, TaskResult, LeaderboardRequest, LeaderboardResponse, ChallengeUpdate, StorageProposal, StorageVote — with full message structs Validator Node (validator-node): - Handle all new P2P message types in handle_network_event() with proper logging and state updates (JobAssignment creates jobs, TaskProgress updates state, etc.) - Handle previously unmatched messages: StateRequest, StateResponse, WeightVote, PeerAnnounce (replacing catch-all debug log) - Add stale job cleanup periodic task (120s interval) - Update WasmChallengeExecutor to use InMemoryStorageBackend (upgraded from NoopStorageBackend) and wire ConsensusPolicy/TerminalPolicy into config Challenge SDK WASM (challenge-sdk-wasm): - Add platform_consensus extern block with all consensus host function imports - Add safe Rust wrappers: host_consensus_get_epoch, host_consensus_get_validators, host_consensus_propose_weight, host_consensus_get_votes, etc. - Fix minor formatting in host_functions.rs No breaking changes to existing EvaluationInput/EvaluationOutput serialization. New P2PMessage variants added at end of enum for backward compatibility. --- bins/validator-node/src/main.rs | 166 +++++++++++++++++- bins/validator-node/src/wasm_executor.rs | 11 +- .../challenge-sdk-wasm/src/host_functions.rs | 5 +- crates/p2p-consensus/src/state.rs | 14 +- crates/wasm-runtime-interface/Cargo.toml | 2 +- .../wasm-runtime-interface/src/consensus.rs | 51 ++---- crates/wasm-runtime-interface/src/lib.rs | 10 +- crates/wasm-runtime-interface/src/sandbox.rs | 26 +-- crates/wasm-runtime-interface/src/storage.rs | 48 +++++ crates/wasm-runtime-interface/src/terminal.rs | 35 ++-- 10 files changed, 283 insertions(+), 85 deletions(-) diff --git a/bins/validator-node/src/main.rs b/bins/validator-node/src/main.rs index d690904b..b0494bff 100644 --- a/bins/validator-node/src/main.rs +++ b/bins/validator-node/src/main.rs @@ -420,6 +420,7 @@ async fn main() -> Result<()> { let mut state_persist_interval = tokio::time::interval(Duration::from_secs(60)); let mut checkpoint_interval = tokio::time::interval(Duration::from_secs(300)); // 5 minutes let mut wasm_eval_interval = tokio::time::interval(Duration::from_secs(5)); + let mut stale_job_interval = tokio::time::interval(Duration::from_secs(120)); let (eval_broadcast_tx, mut eval_broadcast_rx) = tokio::sync::mpsc::channel::(256); @@ -519,6 +520,15 @@ async fn main() -> Result<()> { } } + // Stale job cleanup + _ = stale_job_interval.tick() => { + let now = chrono::Utc::now().timestamp_millis(); + let stale = state_manager.apply(|state| state.cleanup_stale_jobs(now)); + if !stale.is_empty() { + info!(count = stale.len(), "Cleaned up stale jobs"); + } + } + // Periodic checkpoint _ = checkpoint_interval.tick() => { if let Some(handler) = shutdown_handler.as_mut() { @@ -785,8 +795,160 @@ async fn handle_network_event( } }); } - _ => { - debug!("Unhandled P2P message type"); + P2PMessage::StateRequest(req) => { + debug!( + requester = %req.requester.to_hex(), + sequence = req.current_sequence, + "Received state request" + ); + } + P2PMessage::StateResponse(resp) => { + debug!( + responder = %resp.responder.to_hex(), + sequence = resp.sequence, + "Received state response" + ); + } + P2PMessage::WeightVote(wv) => { + debug!( + validator = %wv.validator.to_hex(), + epoch = wv.epoch, + "Received weight vote" + ); + } + P2PMessage::PeerAnnounce(pa) => { + debug!( + validator = %pa.validator.to_hex(), + peer_id = %pa.peer_id, + addresses = pa.addresses.len(), + "Received peer announce" + ); + } + P2PMessage::JobClaim(claim) => { + info!( + validator = %claim.validator.to_hex(), + challenge_id = %claim.challenge_id, + max_jobs = claim.max_jobs, + "Received job claim" + ); + } + P2PMessage::JobAssignment(assignment) => { + info!( + submission_id = %assignment.submission_id, + challenge_id = %assignment.challenge_id, + assigned_validator = %assignment.assigned_validator.to_hex(), + assigner = %assignment.assigner.to_hex(), + "Received job assignment" + ); + let job = JobRecord { + submission_id: assignment.submission_id.clone(), + challenge_id: assignment.challenge_id, + assigned_validator: assignment.assigned_validator, + assigned_at: assignment.timestamp, + timeout_at: assignment.timestamp + 300_000, + status: JobStatus::Pending, + }; + state_manager.apply(|state| { + state.assign_job(job); + }); + } + P2PMessage::DataRequest(req) => { + debug!( + request_id = %req.request_id, + requester = %req.requester.to_hex(), + challenge_id = %req.challenge_id, + data_type = %req.data_type, + "Received data request" + ); + } + P2PMessage::DataResponse(resp) => { + debug!( + request_id = %resp.request_id, + responder = %resp.responder.to_hex(), + challenge_id = %resp.challenge_id, + data_bytes = resp.data.len(), + "Received data response" + ); + } + P2PMessage::TaskProgress(progress) => { + debug!( + submission_id = %progress.submission_id, + challenge_id = %progress.challenge_id, + validator = %progress.validator.to_hex(), + task_index = progress.task_index, + total_tasks = progress.total_tasks, + progress_pct = progress.progress_pct, + "Received task progress" + ); + let record = TaskProgressRecord { + submission_id: progress.submission_id.clone(), + challenge_id: progress.challenge_id, + validator: progress.validator, + task_index: progress.task_index, + total_tasks: progress.total_tasks, + status: progress.status, + progress_pct: progress.progress_pct, + updated_at: progress.timestamp, + }; + state_manager.apply(|state| { + state.update_task_progress(record); + }); + } + P2PMessage::TaskResult(result) => { + info!( + submission_id = %result.submission_id, + challenge_id = %result.challenge_id, + validator = %result.validator.to_hex(), + task_id = %result.task_id, + passed = result.passed, + score = result.score, + execution_time_ms = result.execution_time_ms, + "Received task result" + ); + } + P2PMessage::LeaderboardRequest(req) => { + debug!( + requester = %req.requester.to_hex(), + challenge_id = %req.challenge_id, + limit = req.limit, + offset = req.offset, + "Received leaderboard request" + ); + } + P2PMessage::LeaderboardResponse(resp) => { + debug!( + responder = %resp.responder.to_hex(), + challenge_id = %resp.challenge_id, + total_count = resp.total_count, + "Received leaderboard response" + ); + } + P2PMessage::ChallengeUpdate(update) => { + info!( + challenge_id = %update.challenge_id, + updater = %update.updater.to_hex(), + update_type = %update.update_type, + data_bytes = update.data.len(), + "Received challenge update" + ); + } + P2PMessage::StorageProposal(proposal) => { + debug!( + proposal_id = %hex::encode(&proposal.proposal_id[..8]), + challenge_id = %proposal.challenge_id, + proposer = %proposal.proposer.to_hex(), + key_len = proposal.key.len(), + value_len = proposal.value.len(), + "Received storage proposal" + ); + } + P2PMessage::StorageVote(vote) => { + debug!( + proposal_id = %hex::encode(&vote.proposal_id[..8]), + voter = %vote.voter.to_hex(), + approve = vote.approve, + "Received storage vote" + ); } }, NetworkEvent::PeerConnected(peer_id) => { diff --git a/bins/validator-node/src/wasm_executor.rs b/bins/validator-node/src/wasm_executor.rs index a4635aed..b88b09e7 100644 --- a/bins/validator-node/src/wasm_executor.rs +++ b/bins/validator-node/src/wasm_executor.rs @@ -7,9 +7,10 @@ use std::sync::Arc; use std::time::Instant; use tracing::{debug, info}; use wasm_runtime_interface::{ - ExecPolicy, InMemoryStorageBackend, InstanceConfig, NetworkHostFunctions, NetworkPolicy, - NoopStorageBackend, RuntimeConfig, SandboxHostFunctions, SandboxPolicy, StorageHostConfig, - StorageHostState, TimePolicy, WasmModule, WasmRuntime, WasmRuntimeError, + ConsensusPolicy, ExecPolicy, InMemoryStorageBackend, InstanceConfig, NetworkHostFunctions, + NetworkPolicy, NoopStorageBackend, RuntimeConfig, SandboxHostFunctions, SandboxPolicy, + StorageHostConfig, StorageHostState, TerminalPolicy, TimePolicy, WasmModule, WasmRuntime, + WasmRuntimeError, }; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -393,6 +394,8 @@ impl WasmChallengeExecutor { storage_host_config: StorageHostConfig::default(), storage_backend: Arc::new(InMemoryStorageBackend::new()), fixed_timestamp_ms: None, + consensus_policy: ConsensusPolicy::default(), + terminal_policy: TerminalPolicy::default(), }; let mut instance = self @@ -473,6 +476,8 @@ impl WasmChallengeExecutor { storage_host_config: StorageHostConfig::default(), storage_backend: Arc::new(InMemoryStorageBackend::new()), fixed_timestamp_ms: None, + consensus_policy: ConsensusPolicy::default(), + terminal_policy: TerminalPolicy::default(), }; let mut instance = self diff --git a/crates/challenge-sdk-wasm/src/host_functions.rs b/crates/challenge-sdk-wasm/src/host_functions.rs index c7f6e8a0..fe11096d 100644 --- a/crates/challenge-sdk-wasm/src/host_functions.rs +++ b/crates/challenge-sdk-wasm/src/host_functions.rs @@ -233,8 +233,7 @@ pub fn host_consensus_get_epoch() -> i64 { pub fn host_consensus_get_validators() -> Result, i32> { let mut buf = vec![0u8; 65536]; - let status = - unsafe { consensus_get_validators(buf.as_mut_ptr() as i32, buf.len() as i32) }; + let status = unsafe { consensus_get_validators(buf.as_mut_ptr() as i32, buf.len() as i32) }; if status < 0 { return Err(status); } @@ -276,5 +275,3 @@ pub fn host_consensus_get_submission_count() -> i32 { pub fn host_consensus_get_block_height() -> i64 { unsafe { consensus_get_block_height() } } - - diff --git a/crates/p2p-consensus/src/state.rs b/crates/p2p-consensus/src/state.rs index f23f0f5f..c22b5191 100644 --- a/crates/p2p-consensus/src/state.rs +++ b/crates/p2p-consensus/src/state.rs @@ -637,13 +637,20 @@ impl ChainState { Some(job) } - pub fn update_leaderboard(&mut self, challenge_id: ChallengeId, entries: Vec) { + pub fn update_leaderboard( + &mut self, + challenge_id: ChallengeId, + entries: Vec, + ) { self.leaderboard.insert(challenge_id, entries); self.increment_sequence(); } pub fn get_leaderboard(&self, challenge_id: &ChallengeId) -> Vec { - self.leaderboard.get(challenge_id).cloned().unwrap_or_default() + self.leaderboard + .get(challenge_id) + .cloned() + .unwrap_or_default() } pub fn update_task_progress(&mut self, record: TaskProgressRecord) { @@ -658,7 +665,8 @@ impl ChainState { } pub fn cleanup_stale_jobs(&mut self, now: i64) -> Vec { - let stale: Vec = self.active_jobs + let stale: Vec = self + .active_jobs .iter() .filter(|(_, job)| job.timeout_at < now && job.status != JobStatus::Completed) .map(|(id, _)| id.clone()) diff --git a/crates/wasm-runtime-interface/Cargo.toml b/crates/wasm-runtime-interface/Cargo.toml index c8786aba..468b4619 100644 --- a/crates/wasm-runtime-interface/Cargo.toml +++ b/crates/wasm-runtime-interface/Cargo.toml @@ -16,4 +16,4 @@ bincode = { workspace = true } reqwest = { workspace = true, features = ["blocking", "rustls-tls"] } trust-dns-resolver = "0.23.2" sha2 = { workspace = true } -platform-challenge-sdk-wasm = { path = "../challenge-sdk-wasm" } \ No newline at end of file +platform-challenge-sdk-wasm = { path = "../challenge-sdk-wasm" } diff --git a/crates/wasm-runtime-interface/src/consensus.rs b/crates/wasm-runtime-interface/src/consensus.rs index 928876dc..9254b523 100644 --- a/crates/wasm-runtime-interface/src/consensus.rs +++ b/crates/wasm-runtime-interface/src/consensus.rs @@ -110,11 +110,7 @@ pub struct ConsensusState { } impl ConsensusState { - pub fn new( - policy: ConsensusPolicy, - challenge_id: String, - validator_id: String, - ) -> Self { + pub fn new(policy: ConsensusPolicy, challenge_id: String, validator_id: String) -> Self { Self { policy, epoch: 0, @@ -157,9 +153,7 @@ impl HostFunctionRegistrar for ConsensusHostFunctions { .func_wrap( HOST_CONSENSUS_NAMESPACE, HOST_CONSENSUS_GET_EPOCH, - |caller: Caller| -> i64 { - handle_get_epoch(&caller) - }, + |caller: Caller| -> i64 { handle_get_epoch(&caller) }, ) .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; @@ -207,9 +201,7 @@ impl HostFunctionRegistrar for ConsensusHostFunctions { .func_wrap( HOST_CONSENSUS_NAMESPACE, HOST_CONSENSUS_GET_SUBMISSION_COUNT, - |caller: Caller| -> i32 { - handle_get_submission_count(&caller) - }, + |caller: Caller| -> i32 { handle_get_submission_count(&caller) }, ) .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; @@ -217,9 +209,7 @@ impl HostFunctionRegistrar for ConsensusHostFunctions { .func_wrap( HOST_CONSENSUS_NAMESPACE, HOST_CONSENSUS_GET_BLOCK_HEIGHT, - |caller: Caller| -> i64 { - handle_get_block_height(&caller) - }, + |caller: Caller| -> i64 { handle_get_block_height(&caller) }, ) .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; @@ -235,11 +225,7 @@ fn handle_get_epoch(caller: &Caller) -> i64 { state.epoch as i64 } -fn handle_get_validators( - caller: &mut Caller, - buf_ptr: i32, - buf_len: i32, -) -> i32 { +fn handle_get_validators(caller: &mut Caller, buf_ptr: i32, buf_len: i32) -> i32 { let data = { let state = &caller.data().consensus_state; if !state.policy.enabled { @@ -264,11 +250,7 @@ fn handle_get_validators( data.len() as i32 } -fn handle_propose_weight( - caller: &mut Caller, - uid: i32, - weight: i32, -) -> i32 { +fn handle_propose_weight(caller: &mut Caller, uid: i32, weight: i32) -> i32 { if uid < 0 || weight < 0 { return ConsensusHostStatus::InvalidArgument.to_i32(); } @@ -291,11 +273,7 @@ fn handle_propose_weight( ConsensusHostStatus::Success.to_i32() } -fn handle_get_votes( - caller: &mut Caller, - buf_ptr: i32, - buf_len: i32, -) -> i32 { +fn handle_get_votes(caller: &mut Caller, buf_ptr: i32, buf_len: i32) -> i32 { let data = { let state = &caller.data().consensus_state; if !state.policy.enabled { @@ -320,10 +298,7 @@ fn handle_get_votes( data.len() as i32 } -fn handle_get_state_hash( - caller: &mut Caller, - buf_ptr: i32, -) -> i32 { +fn handle_get_state_hash(caller: &mut Caller, buf_ptr: i32) -> i32 { let hash = { let state = &caller.data().consensus_state; if !state.policy.enabled { @@ -396,8 +371,14 @@ mod tests { assert_eq!(ConsensusHostStatus::ProposalLimitExceeded.to_i32(), -2); assert_eq!(ConsensusHostStatus::InternalError.to_i32(), -100); - assert_eq!(ConsensusHostStatus::from_i32(0), ConsensusHostStatus::Success); - assert_eq!(ConsensusHostStatus::from_i32(1), ConsensusHostStatus::Disabled); + assert_eq!( + ConsensusHostStatus::from_i32(0), + ConsensusHostStatus::Success + ); + assert_eq!( + ConsensusHostStatus::from_i32(1), + ConsensusHostStatus::Disabled + ); assert_eq!( ConsensusHostStatus::from_i32(-1), ConsensusHostStatus::BufferTooSmall diff --git a/crates/wasm-runtime-interface/src/lib.rs b/crates/wasm-runtime-interface/src/lib.rs index 68659880..828d5236 100644 --- a/crates/wasm-runtime-interface/src/lib.rs +++ b/crates/wasm-runtime-interface/src/lib.rs @@ -47,6 +47,10 @@ pub const HOST_HTTP_GET: &str = "http_get"; pub const HOST_HTTP_POST: &str = "http_post"; pub const HOST_DNS_RESOLVE: &str = "dns_resolve"; +pub use consensus::{ + ConsensusHostFunctions, ConsensusHostStatus, ConsensusPolicy, ConsensusState, + HOST_CONSENSUS_NAMESPACE, +}; pub use runtime::{ ChallengeInstance, HostFunctionRegistrar, InstanceConfig, RuntimeConfig, RuntimeState, WasmModule, WasmRuntime, WasmRuntimeError, @@ -55,15 +59,11 @@ pub use storage::{ HOST_STORAGE_ALLOC, HOST_STORAGE_DELETE, HOST_STORAGE_GET, HOST_STORAGE_GET_RESULT, HOST_STORAGE_NAMESPACE, HOST_STORAGE_PROPOSE_WRITE, HOST_STORAGE_SET, }; -pub use time::{TimeError, TimeHostFunction, TimeHostFunctions, TimeMode, TimePolicy, TimeState}; -pub use consensus::{ - ConsensusHostFunctions, ConsensusHostStatus, ConsensusPolicy, ConsensusState, - HOST_CONSENSUS_NAMESPACE, -}; pub use terminal::{ TerminalHostFunctions, TerminalHostStatus, TerminalPolicy, TerminalState, HOST_TERMINAL_NAMESPACE, }; +pub use time::{TimeError, TimeHostFunction, TimeHostFunctions, TimeMode, TimePolicy, TimeState}; /// Host functions that may be exposed to WASM challenges. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/wasm-runtime-interface/src/sandbox.rs b/crates/wasm-runtime-interface/src/sandbox.rs index b9efd5b7..fbef56f9 100644 --- a/crates/wasm-runtime-interface/src/sandbox.rs +++ b/crates/wasm-runtime-interface/src/sandbox.rs @@ -210,10 +210,7 @@ impl SandboxHostFunctions { } impl HostFunctionRegistrar for SandboxHostFunctions { - fn register( - &self, - linker: &mut Linker, - ) -> Result<(), WasmRuntimeError> { + fn register(&self, linker: &mut Linker) -> Result<(), WasmRuntimeError> { linker .func_wrap(HOST_SANDBOX_NAMESPACE, HOST_SANDBOX_STATUS, || -> i32 { SandboxHostStatus::Success.to_i32() @@ -262,10 +259,7 @@ impl HostFunctionRegistrar for SandboxHostFunctions { .func_wrap( HOST_SANDBOX_NAMESPACE, HOST_SANDBOX_LOG_MESSAGE, - |mut caller: Caller, - level: i32, - msg_ptr: i32, - msg_len: i32| { + |mut caller: Caller, level: i32, msg_ptr: i32, msg_len: i32| { handle_log_message(&mut caller, level, msg_ptr, msg_len); }, ) @@ -421,10 +415,7 @@ fn execute_command( cmd.current_dir(dir); } - let has_stdin = request - .stdin - .as_ref() - .is_some_and(|s| !s.is_empty()); + let has_stdin = request.stdin.as_ref().is_some_and(|s| !s.is_empty()); if has_stdin { cmd.stdin(std::process::Stdio::piped()); @@ -451,9 +442,7 @@ fn execute_command( let output = loop { if start.elapsed() > timeout { let _ = child.kill(); - return Err(SandboxExecError::ExecutionTimeout( - timeout.as_secs(), - )); + return Err(SandboxExecError::ExecutionTimeout(timeout.as_secs())); } match child.try_wait() { Ok(Some(_)) => { @@ -483,12 +472,7 @@ fn handle_get_timestamp(caller: &Caller) -> i64 { chrono::Utc::now().timestamp_millis() } -fn handle_log_message( - caller: &mut Caller, - level: i32, - msg_ptr: i32, - msg_len: i32, -) { +fn handle_log_message(caller: &mut Caller, level: i32, msg_ptr: i32, msg_len: i32) { let msg = match read_memory(caller, msg_ptr, msg_len) { Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), Err(err) => { diff --git a/crates/wasm-runtime-interface/src/storage.rs b/crates/wasm-runtime-interface/src/storage.rs index 19ccdcac..5c01247f 100644 --- a/crates/wasm-runtime-interface/src/storage.rs +++ b/crates/wasm-runtime-interface/src/storage.rs @@ -377,6 +377,54 @@ impl StorageBackend for InMemoryStorageBackend { } } +pub struct ChallengeStorageBackend { + storage: Arc, +} + +impl ChallengeStorageBackend { + pub fn new(storage: Arc) -> Self { + Self { storage } + } +} + +impl StorageBackend for ChallengeStorageBackend { + fn get(&self, challenge_id: &str, key: &[u8]) -> Result>, StorageHostError> { + let storage_key = DStorageKey::new(challenge_id, &hex::encode(key)); + let result = tokio::runtime::Handle::current() + .block_on(self.storage.get(&storage_key, DGetOptions::default())) + .map_err(|e| StorageHostError::StorageError(e.to_string()))?; + Ok(result.map(|v| v.data)) + } + + fn propose_write( + &self, + challenge_id: &str, + key: &[u8], + value: &[u8], + ) -> Result<[u8; 32], StorageHostError> { + let storage_key = DStorageKey::new(challenge_id, &hex::encode(key)); + tokio::runtime::Handle::current() + .block_on( + self.storage + .put(storage_key, value.to_vec(), DPutOptions::default()), + ) + .map_err(|e| StorageHostError::StorageError(e.to_string()))?; + + let mut hasher = Sha256::new(); + hasher.update(challenge_id.as_bytes()); + hasher.update(key); + hasher.update(value); + Ok(hasher.finalize().into()) + } + + fn delete(&self, challenge_id: &str, key: &[u8]) -> Result { + let storage_key = DStorageKey::new(challenge_id, &hex::encode(key)); + tokio::runtime::Handle::current() + .block_on(self.storage.delete(&storage_key)) + .map_err(|e| StorageHostError::StorageError(e.to_string())) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageAuditEntry { pub timestamp: chrono::DateTime, diff --git a/crates/wasm-runtime-interface/src/terminal.rs b/crates/wasm-runtime-interface/src/terminal.rs index c73f3ec1..a1ffde23 100644 --- a/crates/wasm-runtime-interface/src/terminal.rs +++ b/crates/wasm-runtime-interface/src/terminal.rs @@ -14,10 +14,9 @@ use crate::runtime::{HostFunctionRegistrar, RuntimeState, WasmRuntimeError}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::process::Command; use std::time::Duration; -use tracing::{info, warn}; +use tracing::warn; use wasmtime::{Caller, Linker, Memory}; pub const HOST_TERMINAL_NAMESPACE: &str = "platform_terminal"; @@ -159,11 +158,7 @@ pub struct TerminalState { } impl TerminalState { - pub fn new( - policy: TerminalPolicy, - challenge_id: String, - validator_id: String, - ) -> Self { + pub fn new(policy: TerminalPolicy, challenge_id: String, validator_id: String) -> Self { Self { policy, challenge_id, @@ -404,7 +399,12 @@ fn handle_terminal_read_file( Err(_) => return TerminalHostStatus::InternalError.to_i32(), }; - if !caller.data().terminal_state.policy.is_path_allowed(&path_str) { + if !caller + .data() + .terminal_state + .policy + .is_path_allowed(&path_str) + { return TerminalHostStatus::PathNotAllowed.to_i32(); } @@ -461,7 +461,12 @@ fn handle_terminal_write_file( Err(_) => return TerminalHostStatus::InternalError.to_i32(), }; - if !caller.data().terminal_state.policy.is_path_allowed(&path_str) { + if !caller + .data() + .terminal_state + .policy + .is_path_allowed(&path_str) + { return TerminalHostStatus::PathNotAllowed.to_i32(); } @@ -513,7 +518,12 @@ fn handle_terminal_list_dir( Err(_) => return TerminalHostStatus::InternalError.to_i32(), }; - if !caller.data().terminal_state.policy.is_path_allowed(&path_str) { + if !caller + .data() + .terminal_state + .policy + .is_path_allowed(&path_str) + { return TerminalHostStatus::PathNotAllowed.to_i32(); } @@ -650,7 +660,10 @@ mod tests { assert_eq!(TerminalHostStatus::InternalError.to_i32(), -100); assert_eq!(TerminalHostStatus::from_i32(0), TerminalHostStatus::Success); - assert_eq!(TerminalHostStatus::from_i32(1), TerminalHostStatus::Disabled); + assert_eq!( + TerminalHostStatus::from_i32(1), + TerminalHostStatus::Disabled + ); assert_eq!( TerminalHostStatus::from_i32(-999), TerminalHostStatus::InternalError From 69104525e48801c87e022dffeb6907e2b066eba9 Mon Sep 17 00:00:00 2001 From: echobt Date: Wed, 18 Feb 2026 07:50:03 +0000 Subject: [PATCH 4/4] fix: move ChallengeStorageBackend to validator-node to resolve cyclic dependency Move ChallengeStorageBackend from wasm-runtime-interface/storage.rs to bins/validator-node/src/challenge_storage.rs. This resolves the cyclic dependency: platform-core -> wasm-runtime-interface -> distributed-storage -> platform-core. The ChallengeStorageBackend depends on distributed-storage types (LocalStorage, StorageKey, etc.) which validator-node already depends on directly, so it belongs there rather than in the generic WASM runtime crate. Add sha2 dependency to validator-node for the hash computation in propose_write(). Fix clippy needless_borrows_for_generic_args warnings by removing unnecessary & on hex::encode() calls. --- Cargo.lock | 1 + bins/validator-node/Cargo.toml | 3 +- bins/validator-node/src/challenge_storage.rs | 55 ++++++++++++++++++++ bins/validator-node/src/main.rs | 1 + crates/wasm-runtime-interface/src/storage.rs | 48 ----------------- 5 files changed, 59 insertions(+), 49 deletions(-) create mode 100644 bins/validator-node/src/challenge_storage.rs diff --git a/Cargo.lock b/Cargo.lock index a3e7da3d..05e80fff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8751,6 +8751,7 @@ dependencies = [ "secure-container-runtime", "serde", "serde_json", + "sha2 0.10.9", "sp-core 38.1.0", "tokio", "tracing", diff --git a/bins/validator-node/Cargo.toml b/bins/validator-node/Cargo.toml index be12dc38..59a635a5 100644 --- a/bins/validator-node/Cargo.toml +++ b/bins/validator-node/Cargo.toml @@ -45,4 +45,5 @@ parking_lot = { workspace = true } sp-core = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } -bincode = { workspace = true } \ No newline at end of file +bincode = { workspace = true } +sha2 = { workspace = true } \ No newline at end of file diff --git a/bins/validator-node/src/challenge_storage.rs b/bins/validator-node/src/challenge_storage.rs new file mode 100644 index 00000000..8aea11a2 --- /dev/null +++ b/bins/validator-node/src/challenge_storage.rs @@ -0,0 +1,55 @@ +use platform_distributed_storage::{ + DistributedStore, GetOptions as DGetOptions, LocalStorage, PutOptions as DPutOptions, + StorageKey as DStorageKey, +}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use wasm_runtime_interface::{StorageBackend, StorageHostError}; + +pub struct ChallengeStorageBackend { + storage: Arc, +} + +impl ChallengeStorageBackend { + pub fn new(storage: Arc) -> Self { + Self { storage } + } +} + +impl StorageBackend for ChallengeStorageBackend { + fn get(&self, challenge_id: &str, key: &[u8]) -> Result>, StorageHostError> { + let storage_key = DStorageKey::new(challenge_id, hex::encode(key)); + let result = tokio::runtime::Handle::current() + .block_on(self.storage.get(&storage_key, DGetOptions::default())) + .map_err(|e| StorageHostError::StorageError(e.to_string()))?; + Ok(result.map(|v| v.data)) + } + + fn propose_write( + &self, + challenge_id: &str, + key: &[u8], + value: &[u8], + ) -> Result<[u8; 32], StorageHostError> { + let storage_key = DStorageKey::new(challenge_id, hex::encode(key)); + tokio::runtime::Handle::current() + .block_on( + self.storage + .put(storage_key, value.to_vec(), DPutOptions::default()), + ) + .map_err(|e| StorageHostError::StorageError(e.to_string()))?; + + let mut hasher = Sha256::new(); + hasher.update(challenge_id.as_bytes()); + hasher.update(key); + hasher.update(value); + Ok(hasher.finalize().into()) + } + + fn delete(&self, challenge_id: &str, key: &[u8]) -> Result { + let storage_key = DStorageKey::new(challenge_id, hex::encode(key)); + tokio::runtime::Handle::current() + .block_on(self.storage.delete(&storage_key)) + .map_err(|e| StorageHostError::StorageError(e.to_string())) + } +} diff --git a/bins/validator-node/src/main.rs b/bins/validator-node/src/main.rs index b0494bff..671dce13 100644 --- a/bins/validator-node/src/main.rs +++ b/bins/validator-node/src/main.rs @@ -4,6 +4,7 @@ //! Uses libp2p for gossipsub consensus and Kademlia DHT for storage. //! Submits weights to Bittensor at epoch boundaries. +mod challenge_storage; mod wasm_executor; use anyhow::Result; diff --git a/crates/wasm-runtime-interface/src/storage.rs b/crates/wasm-runtime-interface/src/storage.rs index 5c01247f..19ccdcac 100644 --- a/crates/wasm-runtime-interface/src/storage.rs +++ b/crates/wasm-runtime-interface/src/storage.rs @@ -377,54 +377,6 @@ impl StorageBackend for InMemoryStorageBackend { } } -pub struct ChallengeStorageBackend { - storage: Arc, -} - -impl ChallengeStorageBackend { - pub fn new(storage: Arc) -> Self { - Self { storage } - } -} - -impl StorageBackend for ChallengeStorageBackend { - fn get(&self, challenge_id: &str, key: &[u8]) -> Result>, StorageHostError> { - let storage_key = DStorageKey::new(challenge_id, &hex::encode(key)); - let result = tokio::runtime::Handle::current() - .block_on(self.storage.get(&storage_key, DGetOptions::default())) - .map_err(|e| StorageHostError::StorageError(e.to_string()))?; - Ok(result.map(|v| v.data)) - } - - fn propose_write( - &self, - challenge_id: &str, - key: &[u8], - value: &[u8], - ) -> Result<[u8; 32], StorageHostError> { - let storage_key = DStorageKey::new(challenge_id, &hex::encode(key)); - tokio::runtime::Handle::current() - .block_on( - self.storage - .put(storage_key, value.to_vec(), DPutOptions::default()), - ) - .map_err(|e| StorageHostError::StorageError(e.to_string()))?; - - let mut hasher = Sha256::new(); - hasher.update(challenge_id.as_bytes()); - hasher.update(key); - hasher.update(value); - Ok(hasher.finalize().into()) - } - - fn delete(&self, challenge_id: &str, key: &[u8]) -> Result { - let storage_key = DStorageKey::new(challenge_id, &hex::encode(key)); - tokio::runtime::Handle::current() - .block_on(self.storage.delete(&storage_key)) - .map_err(|e| StorageHostError::StorageError(e.to_string())) - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageAuditEntry { pub timestamp: chrono::DateTime,