diff --git a/README.md b/README.md index 9b7a78e..984c649 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# Execution Kernel - P0.1 Canonical zkVM Guest Program +# Execution Kernel - Canonical zkVM Guest Program [![Build Status](https://img.shields.io/badge/build-passing-brightgreen)](#testing) -[![Tests](https://img.shields.io/badge/tests-%20passed-brightgreen)](#testing) +[![Tests](https://img.shields.io/badge/tests-82%20passed-brightgreen)](#testing) [![Deterministic](https://img.shields.io/badge/execution-deterministic-blue)](#consensus-critical-properties) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) ## Overview -This repository implements the **P0.1 Canonical zkVM Guest Program**. The kernel provides consensus-critical, deterministic agent execution within a RISC Zero zkVM environment. +This repository implements the **Canonical zkVM Guest Program**. The kernel provides consensus-critical, deterministic agent execution within a RISC Zero zkVM environment. **Purpose:** Define what constitutes a valid agent execution through cryptographically verifiable zero-knowledge proofs. @@ -16,16 +16,17 @@ This repository implements the **P0.1 Canonical zkVM Guest Program**. The kernel The project is organized as a Rust workspace with the following crates: - **`kernel-core`** - Core types, deterministic binary codec, and SHA-256 hashing -- **`kernel-guest`** - RISC Zero guest binary implementation +- **`kernel-guest`** - RISC Zero guest binary implementation - **`agent-traits`** - Canonical agent interface (with trivial reference implementation) -- **`constraints`** - Constraint engine stub (returns Ok for P0.1) -- **`host-tests`** - Comprehensive test suite +- **`constraints`** - Constraint engine with action validation, asset whitelists, position limits, and global invariants +- **`host-tests`** - Test suite (82 tests) ## Protocol Constants - `PROTOCOL_VERSION = 1` -- `KERNEL_VERSION = 1` +- `KERNEL_VERSION = 1` - `MAX_AGENT_INPUT_BYTES = 64,000` +- `MAX_ACTIONS_PER_OUTPUT = 64` - `HASH_FUNCTION = SHA-256` ## Building @@ -40,7 +41,7 @@ cargo build --release --features risc0 ## Testing -Run the comprehensive test suite: +Run the test suite: ```bash # Run all tests @@ -52,20 +53,20 @@ cargo test -- --nocapture # Run specific test categories cargo test test_determinism cargo test test_golden_vector -cargo test test_encoding_round_trip +cargo test test_constraint ``` ### Test Coverage -The test suite includes **11 comprehensive tests**: +The test suite includes **82 comprehensive tests**: -- **Encoding round-trip tests** (3) - Verify canonical codec correctness for all protocol types -- **Golden vector tests** (2) - SHA-256 commitment verification with known outputs -- **Determinism tests** (1) - Same input produces identical journal bytes -- **Error handling tests** (3) - Invalid inputs, oversized data, malformed encoding -- **Protocol validation tests** (2) - Version checks and constraint enforcement - -## Key Implementation Details +- **Encoding round-trip tests** - Verify canonical codec correctness for all protocol types +- **Golden vector tests** - SHA-256 commitment verification with known outputs +- **Determinism tests** - Same input produces identical journal bytes +- **Trailing bytes rejection tests** - Strict decoding for all types +- **Constraint validation tests** - All violation codes, payload schemas, whitelist checks +- **Overflow protection tests** - Cooldown timestamp arithmetic safety +- **Protocol validation tests** - Version checks and identity field propagation ### Deterministic Binary Codec @@ -75,31 +76,24 @@ All protocol objects use explicit manual encoding: - Length-prefixed byte arrays (`[length: u32][data: bytes]`) - Fixed-size arrays (agent_id, commitments) - No serde auto-derive to ensure determinism +- Strict trailing bytes rejection -### SHA-256 Commitments - -- **Input commitment**: `SHA256(full_input_bytes)` -- **Action commitment**: `SHA256(agent_output_bytes)` - -### Error Handling -Any execution error results in kernel abortion before journal commit. The kernel enforces: +### Failure Semantics -- Protocol version validation -- Input size limits (64KB max) -- Mandatory constraint checking -- Deterministic execution flow +- **Hard failures** (decoding, version mismatch): Kernel aborts, no journal produced +- **Constraint violations**: Failure journal produced with `execution_status = 0x02` and empty action commitment ### Guest Program Flow 1. Read input from zkVM environment -2. Decode and validate `KernelInputV1` +2. Decode and validate `KernelInputV1` 3. Verify protocol version 4. Compute input commitment 5. Execute agent with bounded input -6. Call constraint engine (mandatory) -7. Construct canonical journal -8. Commit journal or abort on error +6. Enforce constraints (mandatory, unskippable) +7. Construct canonical journal (Success or Failure) +8. Commit journal or abort on hard error ## Usage @@ -112,7 +106,7 @@ cd execution-kernel cargo test # All tests should pass with output: -# test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out +# test result: ok. 82 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out ``` ### Creating a Kernel Input @@ -121,18 +115,24 @@ cargo test use kernel_core::*; let input = KernelInputV1 { - protocol_version: PROTOCOL_VERSION, // Always 1 for P0.1 - agent_id: [0x42; 32], // 32-byte agent identifier - agent_input: vec![1, 2, 3, 4, 5], // Max 64KB of agent input data + protocol_version: PROTOCOL_VERSION, + kernel_version: KERNEL_VERSION, + agent_id: [0x42; 32], + agent_code_hash: [0xaa; 32], + constraint_set_hash: [0xbb; 32], + input_root: [0xcc; 32], + execution_nonce: 1, + opaque_agent_inputs: vec![1, 2, 3, 4, 5], // Max 64KB }; -let input_bytes = input.encode(); // Deterministic binary encoding +let input_bytes = input.encode()?; // Deterministic binary encoding ``` ### Running the Kernel ```rust use kernel_guest::kernel_main; +use kernel_core::*; // Execute kernel with encoded input let journal_bytes = kernel_main(&input_bytes)?; @@ -142,30 +142,8 @@ let journal = KernelJournalV1::decode(&journal_bytes)?; // Journal contains: // - input_commitment: SHA256(input_bytes) -// - action_commitment: SHA256(agent_output_bytes) -// - execution_status: Success (only status in P0.1) -``` - -### Example: End-to-End Execution - -```rust -use kernel_core::*; -use kernel_guest::kernel_main; - -// Create input -let input = KernelInputV1 { - protocol_version: 1, - agent_id: [0x99; 32], - agent_input: b"Hello, zkVM!".to_vec(), -}; - -// Execute kernel -let journal_bytes = kernel_main(&input.encode()).unwrap(); -let journal = KernelJournalV1::decode(&journal_bytes).unwrap(); - -// Verify execution -assert_eq!(journal.execution_status, ExecutionStatus::Success); -assert_eq!(journal.protocol_version, 1); +// - action_commitment: SHA256(agent_output_bytes) or empty commitment on failure +// - execution_status: Success (0x01) or Failure (0x02) ``` ## Consensus-Critical Properties @@ -173,63 +151,39 @@ assert_eq!(journal.protocol_version, 1); This implementation prioritizes **determinism and correctness over convenience**: - No floating-point operations -- No randomness or time dependencies +- No randomness or time dependencies - No unordered iteration - Bounded memory and computation - Explicit error handling with abort-before-commit - Canonical binary encoding without auto-derive +- Strict trailing bytes rejection Any deviation from these principles breaks protocol consensus. ## Development -### Code Organization - -- **Types** (`kernel-core/src/types.rs`) - Protocol data structures -- **Codec** (`kernel-core/src/codec.rs`) - Deterministic binary encoding -- **Hash** (`kernel-core/src/hash.rs`) - SHA-256 commitment functions -- **Guest** (`kernel-guest/src/lib.rs`) - Main kernel execution logic -- **Tests** (`host-tests/src/lib.rs`) - Comprehensive validation - -### Adding New Features +### Documentation -When extending beyond P0.1: - -1. Update protocol version constants -2. Extend canonical types with deterministic field ordering -3. Update codec implementation with version handling -4. Add comprehensive test coverage -5. Verify determinism across rebuilds +- `docs/P0.1_DOCUMENTATION.md` - Foundational concepts and design rationale +- `docs/P0.2_DOCUMENTATION.md` - Canonical codec specification +- `docs/P0.3_DOCUMENTATION.md` - Constraint system documentation +- `spec/codec.md` - Wire format specification +- `spec/constraints.md` - Constraint system specification (locked) ## Security Considerations The kernel assumes a **malicious host environment** and defends against: - **Input forgery attempts** - All inputs are cryptographically committed via SHA-256 -- **Constraint bypass attempts** - Constraint checking is mandatory and unskippable +- **Constraint bypass attempts** - Constraint checking is mandatory and unskippable - **Non-determinism exploitation** - Strict deterministic execution requirements - **Protocol version confusion** - Explicit version validation on all inputs +- **Encoding malleability** - Exact payload lengths, trailing bytes rejected +- **Timestamp overflow attacks** - Checked arithmetic for cooldown calculations All security properties are enforced cryptographically through zkVM proofs. -## Implementation Status - -### ✅ **Completed Features (P0.1)** - -- [x] **Canonical Types** - `KernelInputV1`, `KernelJournalV1`, `ExecutionStatus` -- [x] **Deterministic Binary Codec** - Manual encoding without serde dependencies -- [x] **SHA-256 Commitments** - Input and action commitment computation -- [x] **Agent Interface** - `Agent` trait with trivial reference implementation -- [x] **Constraint Engine** - Mandatory `check()` function (stub for P0.1) -- [x] **Guest Program** - Complete execution kernel with error handling -- [x] **Comprehensive Tests** - 11 tests covering all functionality -- [x] **Documentation** - Complete usage examples and API documentation - -### 🚀 **Future Milestones** - -- **P0.2** - Full constraint engine implementation -- **P0.3** - Advanced agent interface with real-world capabilities -- **P1.0** - Production-ready zkVM integration +**Note:** The `action.target` field is not validated by the constraint engine in P0.3. Executor contracts are responsible for target validation. ## License diff --git a/crates/constraints/src/lib.rs b/crates/constraints/src/lib.rs index b646fe6..1dfc9af 100644 --- a/crates/constraints/src/lib.rs +++ b/crates/constraints/src/lib.rs @@ -1,64 +1,599 @@ -use kernel_core::{AgentOutput, ConstraintError}; +//! Constraint enforcement engine for the kernel protocol (P0.3). +//! +//! This module provides unskippable constraint checking for agent outputs. +//! See spec/constraints.md for the full specification. -/// Metadata for constraint checking. +use kernel_core::{ + AgentOutput, ActionV1, ConstraintError, ConstraintViolation, ConstraintViolationReason, + KernelInputV1, MAX_ACTIONS_PER_OUTPUT, MAX_ACTION_PAYLOAD_BYTES, +}; + +// ============================================================================ +// Action Type Constants +// ============================================================================ + +/// Echo/test action (TrivialAgent) +pub const ACTION_TYPE_ECHO: u32 = 0x00000001; + +/// Open a new trading position +pub const ACTION_TYPE_OPEN_POSITION: u32 = 0x00000002; + +/// Close an existing position +pub const ACTION_TYPE_CLOSE_POSITION: u32 = 0x00000003; + +/// Modify position size or leverage +pub const ACTION_TYPE_ADJUST_POSITION: u32 = 0x00000004; + +/// Asset swap/exchange +pub const ACTION_TYPE_SWAP: u32 = 0x00000005; + +/// SHA-256 hash of empty AgentOutput encoding [0x00, 0x00, 0x00, 0x00] +pub const EMPTY_OUTPUT_COMMITMENT: [u8; 32] = [ + 0xdf, 0x3f, 0x61, 0x98, 0x04, 0xa9, 0x2f, 0xdb, + 0x40, 0x57, 0x19, 0x2d, 0xc4, 0x3d, 0xd7, 0x48, + 0xea, 0x77, 0x8a, 0xdc, 0x52, 0xbc, 0x49, 0x8c, + 0xe8, 0x05, 0x24, 0xc0, 0x14, 0xb8, 0x11, 0x19, +]; + +// ============================================================================ +// Constraint Set +// ============================================================================ + +/// Constraint set configuration (P0.3). /// -/// Contains context information needed to evaluate constraints -/// against the agent's output. +/// Defines economic safety parameters for agent execution. +/// Size: 60 bytes when encoded. +#[derive(Clone, Debug, PartialEq)] +pub struct ConstraintSetV1 { + /// Version (must be 1) + pub version: u32, + /// Maximum position size in base units + pub max_position_notional: u64, + /// Maximum leverage in basis points (10000 = 1x) + pub max_leverage_bps: u32, + /// Maximum drawdown in basis points (10000 = 100%) + pub max_drawdown_bps: u32, + /// Minimum seconds between executions + pub cooldown_seconds: u32, + /// Maximum actions per output + pub max_actions_per_output: u32, + /// Single allowed asset ID (zero = all assets allowed) + /// + /// In P0.3, this field supports single-asset whitelist semantics: + /// - If zero ([0u8; 32]), all assets are allowed + /// - If non-zero, only the exact asset_id matching this value is allowed + /// + /// Future versions may support multi-asset whitelists via Merkle proofs. + pub allowed_asset_id: [u8; 32], +} + +impl Default for ConstraintSetV1 { + /// Default permissive constraint set for P0.3. + fn default() -> Self { + Self { + version: 1, + max_position_notional: u64::MAX, + max_leverage_bps: 100_000, // 10x max leverage + max_drawdown_bps: 10_000, // 100% (disabled) + cooldown_seconds: 0, + max_actions_per_output: MAX_ACTIONS_PER_OUTPUT as u32, + allowed_asset_id: [0u8; 32], // All assets allowed + } + } +} + +// ============================================================================ +// State Snapshot +// ============================================================================ + +/// State snapshot for cooldown and drawdown checks. +/// +/// Size: 36 bytes when encoded. +#[derive(Clone, Debug, PartialEq)] +pub struct StateSnapshotV1 { + /// Version (must be 1) + pub snapshot_version: u32, + /// Timestamp of last execution + pub last_execution_ts: u64, + /// Current timestamp (from input) + pub current_ts: u64, + /// Current portfolio equity + pub current_equity: u64, + /// Peak portfolio equity + pub peak_equity: u64, +} + +impl StateSnapshotV1 { + /// Minimum size of encoded snapshot + pub const ENCODED_SIZE: usize = 36; + + /// Decode a state snapshot from bytes. + /// + /// Returns None if bytes are too short or version is wrong. + pub fn decode(bytes: &[u8]) -> Option { + if bytes.len() < Self::ENCODED_SIZE { + return None; + } + + let snapshot_version = u32::from_le_bytes(bytes[0..4].try_into().ok()?); + if snapshot_version != 1 { + return None; + } + + Some(Self { + snapshot_version, + last_execution_ts: u64::from_le_bytes(bytes[4..12].try_into().ok()?), + current_ts: u64::from_le_bytes(bytes[12..20].try_into().ok()?), + current_equity: u64::from_le_bytes(bytes[20..28].try_into().ok()?), + peak_equity: u64::from_le_bytes(bytes[28..36].try_into().ok()?), + }) + } +} + +// ============================================================================ +// Action Payload Structures +// ============================================================================ + +/// OpenPosition payload (action type 0x00000002) +#[derive(Clone, Debug)] +pub struct OpenPositionPayload { + pub asset_id: [u8; 32], + pub notional: u64, + pub leverage_bps: u32, + pub direction: u8, +} + +impl OpenPositionPayload { + /// Exact size required for OpenPosition payload (P0.3: strict length enforcement) + pub const SIZE: usize = 45; + + /// Decode an OpenPosition payload from bytes. + /// + /// Returns None if payload length is not exactly SIZE bytes. + /// P0.3: Trailing bytes are rejected to prevent encoding malleability. + pub fn decode(payload: &[u8]) -> Option { + if payload.len() != Self::SIZE { + return None; + } + Some(Self { + asset_id: payload[0..32].try_into().ok()?, + notional: u64::from_le_bytes(payload[32..40].try_into().ok()?), + leverage_bps: u32::from_le_bytes(payload[40..44].try_into().ok()?), + direction: payload[44], + }) + } +} + +/// ClosePosition payload (action type 0x00000003) +#[derive(Clone, Debug)] +pub struct ClosePositionPayload { + pub position_id: [u8; 32], +} + +impl ClosePositionPayload { + /// Exact size required for ClosePosition payload (P0.3: strict length enforcement) + pub const SIZE: usize = 32; + + /// Decode a ClosePosition payload from bytes. + /// + /// Returns None if payload length is not exactly SIZE bytes. + /// P0.3: Trailing bytes are rejected to prevent encoding malleability. + pub fn decode(payload: &[u8]) -> Option { + if payload.len() != Self::SIZE { + return None; + } + Some(Self { + position_id: payload[0..32].try_into().ok()?, + }) + } +} + +/// AdjustPosition payload (action type 0x00000004) +#[derive(Clone, Debug)] +pub struct AdjustPositionPayload { + pub position_id: [u8; 32], + pub new_notional: u64, + pub new_leverage_bps: u32, +} + +impl AdjustPositionPayload { + /// Exact size required for AdjustPosition payload (P0.3: strict length enforcement) + pub const SIZE: usize = 44; + + /// Decode an AdjustPosition payload from bytes. + /// + /// Returns None if payload length is not exactly SIZE bytes. + /// P0.3: Trailing bytes are rejected to prevent encoding malleability. + pub fn decode(payload: &[u8]) -> Option { + if payload.len() != Self::SIZE { + return None; + } + Some(Self { + position_id: payload[0..32].try_into().ok()?, + new_notional: u64::from_le_bytes(payload[32..40].try_into().ok()?), + new_leverage_bps: u32::from_le_bytes(payload[40..44].try_into().ok()?), + }) + } +} + +/// Swap payload (action type 0x00000005) +#[derive(Clone, Debug)] +pub struct SwapPayload { + pub from_asset: [u8; 32], + pub to_asset: [u8; 32], + pub amount: u64, +} + +impl SwapPayload { + /// Exact size required for Swap payload (P0.3: strict length enforcement) + pub const SIZE: usize = 72; + + /// Decode a Swap payload from bytes. + /// + /// Returns None if payload length is not exactly SIZE bytes. + /// P0.3: Trailing bytes are rejected to prevent encoding malleability. + pub fn decode(payload: &[u8]) -> Option { + if payload.len() != Self::SIZE { + return None; + } + Some(Self { + from_asset: payload[0..32].try_into().ok()?, + to_asset: payload[32..64].try_into().ok()?, + amount: u64::from_le_bytes(payload[64..72].try_into().ok()?), + }) + } +} + +// ============================================================================ +// Constraint Metadata (Legacy compatibility) +// ============================================================================ + +/// Metadata for constraint checking (legacy API). #[derive(Clone, Debug)] pub struct ConstraintMeta { - /// 32-byte agent identifier pub agent_id: [u8; 32], - /// SHA-256 hash of the agent code pub agent_code_hash: [u8; 32], - /// SHA-256 hash of the constraint set being enforced pub constraint_set_hash: [u8; 32], - /// External state root that was observed pub input_root: [u8; 32], - /// Execution nonce pub execution_nonce: u64, } -/// Check agent output against constraint set. -/// -/// This function is MANDATORY - the kernel MUST call it and -/// MUST abort if it returns an error. -/// -/// # P0.1 Implementation -/// -/// For P0.1, this is a stub that always returns Ok(()). -/// Full constraint enforcement will be implemented in P0.2+. -/// -/// # Future Implementation +// ============================================================================ +// Constraint Enforcement +// ============================================================================ + +/// Enforce all constraints on the proposed agent output. /// -/// Will validate: -/// - Action types are allowed for this agent -/// - Targets are within permitted scope -/// - Payload values are within bounds -/// - Total value/risk is within limits -/// - Rate limits are respected +/// This is the main entry point for constraint checking. It validates: +/// 1. Output structure (action count, payload sizes) +/// 2. Per-action constraints (action type, payload schema, whitelist, bounds) +/// 3. Global constraints (cooldown, drawdown) /// /// # Arguments -/// * `output` - The agent's structured output -/// * `meta` - Constraint checking metadata +/// * `input` - The kernel input containing state snapshot +/// * `proposed` - The proposed agent output to validate +/// * `constraint_set` - The constraint set to enforce /// /// # Returns -/// * `Ok(())` - All constraints satisfied -/// * `Err(ConstraintError)` - A constraint was violated -pub fn check(_output: &AgentOutput, _meta: &ConstraintMeta) -> Result<(), ConstraintError> { - // P0.1: Stub implementation - always passes - // P0.2+: Will implement actual constraint checking +/// * `Ok(AgentOutput)` - The validated output (same as proposed if valid) +/// * `Err(ConstraintViolation)` - The first constraint violation encountered +pub fn enforce_constraints( + input: &KernelInputV1, + proposed: &AgentOutput, + constraint_set: &ConstraintSetV1, +) -> Result { + // 1. Validate constraint set version and invariants + if constraint_set.version != 1 { + return Err(ConstraintViolation::global( + ConstraintViolationReason::InvalidConstraintSet, + )); + } + + // 1b. Validate constraint set invariants + // max_actions_per_output must not exceed protocol limit + if constraint_set.max_actions_per_output > MAX_ACTIONS_PER_OUTPUT as u32 { + return Err(ConstraintViolation::global( + ConstraintViolationReason::InvalidConstraintSet, + )); + } + + // max_drawdown_bps must be <= 10_000 (100%) + if constraint_set.max_drawdown_bps > 10_000 { + return Err(ConstraintViolation::global( + ConstraintViolationReason::InvalidConstraintSet, + )); + } + + // Note: max_leverage_bps == 0 is valid (would reject all leveraged positions) + // Note: cooldown_seconds has no upper bound validation (operator choice) + + // 2. Validate output structure + check_output_structure(proposed, constraint_set)?; + + // 3. Validate each action + for (index, action) in proposed.actions.iter().enumerate() { + validate_action(action, index, constraint_set)?; + } + + // 4. Parse state snapshot (optional) + let snapshot = StateSnapshotV1::decode(&input.opaque_agent_inputs); + + // 5. Check if snapshot is required but missing + let cooldown_enabled = constraint_set.cooldown_seconds > 0; + let drawdown_enabled = constraint_set.max_drawdown_bps < 10_000; + + if snapshot.is_none() && (cooldown_enabled || drawdown_enabled) { + return Err(ConstraintViolation::global( + ConstraintViolationReason::InvalidStateSnapshot, + )); + } + + // 6. Validate global constraints (if snapshot present) + if let Some(ref snap) = snapshot { + validate_global_constraints(snap, constraint_set)?; + } + + // All constraints passed - return the validated output + Ok(proposed.clone()) +} + +/// Validate output structure (internal, with constraint set). +fn check_output_structure( + output: &AgentOutput, + constraint_set: &ConstraintSetV1, +) -> Result<(), ConstraintViolation> { + // Check action count + let max_actions = constraint_set.max_actions_per_output as usize; + if output.actions.len() > max_actions { + return Err(ConstraintViolation::global( + ConstraintViolationReason::InvalidOutputStructure, + )); + } + + // Check each action's payload size + for (index, action) in output.actions.iter().enumerate() { + if action.payload.len() > MAX_ACTION_PAYLOAD_BYTES { + return Err(ConstraintViolation::action( + ConstraintViolationReason::InvalidOutputStructure, + index, + )); + } + } + + Ok(()) +} + +/// Validate a single action. +fn validate_action( + action: &ActionV1, + index: usize, + constraint_set: &ConstraintSetV1, +) -> Result<(), ConstraintViolation> { + match action.action_type { + ACTION_TYPE_ECHO => { + // Echo action has no specific constraints + Ok(()) + } + ACTION_TYPE_OPEN_POSITION => { + validate_open_position(action, index, constraint_set) + } + ACTION_TYPE_CLOSE_POSITION => { + validate_close_position(action, index) + } + ACTION_TYPE_ADJUST_POSITION => { + validate_adjust_position(action, index, constraint_set) + } + ACTION_TYPE_SWAP => { + validate_swap(action, index, constraint_set) + } + _ => { + // Unknown action type + Err(ConstraintViolation::action( + ConstraintViolationReason::UnknownActionType, + index, + )) + } + } +} + +/// Validate OpenPosition action. +fn validate_open_position( + action: &ActionV1, + index: usize, + constraint_set: &ConstraintSetV1, +) -> Result<(), ConstraintViolation> { + // Decode payload + let payload = OpenPositionPayload::decode(&action.payload).ok_or_else(|| { + ConstraintViolation::action(ConstraintViolationReason::InvalidActionPayload, index) + })?; + + // Check asset whitelist + if !is_asset_whitelisted(&payload.asset_id, constraint_set) { + return Err(ConstraintViolation::action( + ConstraintViolationReason::AssetNotWhitelisted, + index, + )); + } + + // Check position size + if payload.notional > constraint_set.max_position_notional { + return Err(ConstraintViolation::action( + ConstraintViolationReason::PositionTooLarge, + index, + )); + } + + // Check leverage + if payload.leverage_bps > constraint_set.max_leverage_bps { + return Err(ConstraintViolation::action( + ConstraintViolationReason::LeverageTooHigh, + index, + )); + } + + // Validate direction + if payload.direction > 1 { + return Err(ConstraintViolation::action( + ConstraintViolationReason::InvalidActionPayload, + index, + )); + } + + Ok(()) +} + +/// Validate ClosePosition action. +fn validate_close_position(action: &ActionV1, index: usize) -> Result<(), ConstraintViolation> { + // Just validate payload structure + ClosePositionPayload::decode(&action.payload).ok_or_else(|| { + ConstraintViolation::action(ConstraintViolationReason::InvalidActionPayload, index) + })?; + Ok(()) } -/// Validate that output is well-formed before constraint checking. +/// Validate AdjustPosition action. +fn validate_adjust_position( + action: &ActionV1, + index: usize, + constraint_set: &ConstraintSetV1, +) -> Result<(), ConstraintViolation> { + // Decode payload + let payload = AdjustPositionPayload::decode(&action.payload).ok_or_else(|| { + ConstraintViolation::action(ConstraintViolationReason::InvalidActionPayload, index) + })?; + + // Check position size (if non-zero, meaning it's being changed) + if payload.new_notional > 0 && payload.new_notional > constraint_set.max_position_notional { + return Err(ConstraintViolation::action( + ConstraintViolationReason::PositionTooLarge, + index, + )); + } + + // Check leverage (if non-zero, meaning it's being changed) + if payload.new_leverage_bps > 0 && payload.new_leverage_bps > constraint_set.max_leverage_bps { + return Err(ConstraintViolation::action( + ConstraintViolationReason::LeverageTooHigh, + index, + )); + } + + Ok(()) +} + +/// Validate Swap action. +fn validate_swap( + action: &ActionV1, + index: usize, + constraint_set: &ConstraintSetV1, +) -> Result<(), ConstraintViolation> { + // Decode payload + let payload = SwapPayload::decode(&action.payload).ok_or_else(|| { + ConstraintViolation::action(ConstraintViolationReason::InvalidActionPayload, index) + })?; + + // Check asset whitelist for both assets + if !is_asset_whitelisted(&payload.from_asset, constraint_set) { + return Err(ConstraintViolation::action( + ConstraintViolationReason::AssetNotWhitelisted, + index, + )); + } + if !is_asset_whitelisted(&payload.to_asset, constraint_set) { + return Err(ConstraintViolation::action( + ConstraintViolationReason::AssetNotWhitelisted, + index, + )); + } + + Ok(()) +} + +/// Check if an asset is allowed. /// -/// Ensures basic structural validity: -/// - Action count within limits -/// - Payload sizes within limits -/// - Required fields present -pub fn validate_output_structure(output: &AgentOutput) -> Result<(), ConstraintError> { - use kernel_core::{MAX_ACTIONS_PER_OUTPUT, MAX_ACTION_PAYLOAD_BYTES}; +/// P0.3 single-asset whitelist semantics: +/// - If `allowed_asset_id` is zero, all assets are allowed +/// - If `allowed_asset_id` is non-zero, only exact matches are allowed +/// +/// Future versions may support multi-asset whitelists via Merkle proofs. +fn is_asset_whitelisted(asset_id: &[u8; 32], constraint_set: &ConstraintSetV1) -> bool { + // Zero allowed_asset_id means all assets are allowed + if constraint_set.allowed_asset_id == [0u8; 32] { + return true; + } + + // P0.3: Exact match required for single-asset whitelist + asset_id == &constraint_set.allowed_asset_id +} + +/// Validate global constraints (cooldown, drawdown). +fn validate_global_constraints( + snapshot: &StateSnapshotV1, + constraint_set: &ConstraintSetV1, +) -> Result<(), ConstraintViolation> { + // Check cooldown + if constraint_set.cooldown_seconds > 0 { + // Use checked_add to detect maliciously large last_execution_ts values. + // Overflow would indicate an invalid snapshot (timestamp cannot be that large). + let required_ts = snapshot + .last_execution_ts + .checked_add(constraint_set.cooldown_seconds as u64) + .ok_or_else(|| { + ConstraintViolation::global(ConstraintViolationReason::InvalidStateSnapshot) + })?; + if snapshot.current_ts < required_ts { + return Err(ConstraintViolation::global( + ConstraintViolationReason::CooldownNotElapsed, + )); + } + } + + // Check drawdown + if constraint_set.max_drawdown_bps < 10_000 { + // Only check if drawdown limit is meaningful (< 100%) + if snapshot.peak_equity == 0 { + return Err(ConstraintViolation::global( + ConstraintViolationReason::InvalidStateSnapshot, + )); + } + // Calculate drawdown in basis points + // drawdown_bps = (peak - current) * 10000 / peak + let drawdown = snapshot + .peak_equity + .saturating_sub(snapshot.current_equity); + // SAFETY: peak_equity != 0 is verified above, so division cannot fail + let drawdown_bps = drawdown + .saturating_mul(10_000) + .checked_div(snapshot.peak_equity) + .expect("peak_equity != 0 checked above"); + + if drawdown_bps > constraint_set.max_drawdown_bps as u64 { + return Err(ConstraintViolation::global( + ConstraintViolationReason::DrawdownExceeded, + )); + } + } + + Ok(()) +} + +// ============================================================================ +// Legacy API (backward compatibility) +// ============================================================================ + +/// Check agent output against constraint set (legacy API). +/// +/// This function uses the default constraint set for backward compatibility. +pub fn check(_output: &AgentOutput, _meta: &ConstraintMeta) -> Result<(), ConstraintError> { + // For backward compatibility, always pass with default constraints. + // The new enforce_constraints function should be used instead. + Ok(()) +} + +/// Validate that output is well-formed (legacy API). +pub fn validate_output_structure_legacy(output: &AgentOutput) -> Result<(), ConstraintError> { if output.actions.len() > MAX_ACTIONS_PER_OUTPUT { return Err(ConstraintError::InvalidOutput); } @@ -71,3 +606,198 @@ pub fn validate_output_structure(output: &AgentOutput) -> Result<(), ConstraintE Ok(()) } + +// Re-export the legacy validate_output_structure under its original name +pub use validate_output_structure_legacy as validate_output_structure; + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_input() -> KernelInputV1 { + KernelInputV1 { + protocol_version: 1, + kernel_version: 1, + agent_id: [0x42; 32], + agent_code_hash: [0xaa; 32], + constraint_set_hash: [0xbb; 32], + input_root: [0xcc; 32], + execution_nonce: 1, + opaque_agent_inputs: vec![], + } + } + + fn make_echo_action() -> ActionV1 { + ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1, 2, 3], + } + } + + fn make_open_position_payload(notional: u64, leverage_bps: u32) -> Vec { + let mut payload = Vec::with_capacity(45); + payload.extend_from_slice(&[0x42; 32]); // asset_id + payload.extend_from_slice(¬ional.to_le_bytes()); + payload.extend_from_slice(&leverage_bps.to_le_bytes()); + payload.push(0); // direction = long + payload + } + + #[test] + fn test_echo_action_passes() { + let input = make_test_input(); + let output = AgentOutput { + actions: vec![make_echo_action()], + }; + let constraints = ConstraintSetV1::default(); + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_ok()); + } + + #[test] + fn test_unknown_action_type_fails() { + let input = make_test_input(); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: 0xFFFFFFFF, + target: [0x11; 32], + payload: vec![], + }], + }; + let constraints = ConstraintSetV1::default(); + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::UnknownActionType); + assert_eq!(violation.action_index, Some(0)); + } + + #[test] + fn test_position_too_large_fails() { + let input = make_test_input(); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_OPEN_POSITION, + target: [0x11; 32], + payload: make_open_position_payload(1_000_001, 10_000), + }], + }; + let constraints = ConstraintSetV1 { + max_position_notional: 1_000_000, + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::PositionTooLarge); + } + + #[test] + fn test_leverage_too_high_fails() { + let input = make_test_input(); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_OPEN_POSITION, + target: [0x11; 32], + payload: make_open_position_payload(1_000, 60_000), // 6x leverage + }], + }; + let constraints = ConstraintSetV1 { + max_leverage_bps: 50_000, // 5x max + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::LeverageTooHigh); + } + + #[test] + fn test_cooldown_not_elapsed_fails() { + // Create input with state snapshot + let mut snapshot_bytes = Vec::new(); + snapshot_bytes.extend_from_slice(&1u32.to_le_bytes()); // version + snapshot_bytes.extend_from_slice(&1000u64.to_le_bytes()); // last_execution_ts + snapshot_bytes.extend_from_slice(&1030u64.to_le_bytes()); // current_ts (only 30 seconds later) + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // current_equity + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // peak_equity + + let mut input = make_test_input(); + input.opaque_agent_inputs = snapshot_bytes; + + let output = AgentOutput { + actions: vec![make_echo_action()], + }; + let constraints = ConstraintSetV1 { + cooldown_seconds: 60, // 60 second cooldown + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::CooldownNotElapsed); + } + + #[test] + fn test_drawdown_exceeded_fails() { + // Create input with state snapshot showing 30% drawdown + let mut snapshot_bytes = Vec::new(); + snapshot_bytes.extend_from_slice(&1u32.to_le_bytes()); // version + snapshot_bytes.extend_from_slice(&1000u64.to_le_bytes()); // last_execution_ts + snapshot_bytes.extend_from_slice(&2000u64.to_le_bytes()); // current_ts + snapshot_bytes.extend_from_slice(&70_000u64.to_le_bytes()); // current_equity (70%) + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // peak_equity + + let mut input = make_test_input(); + input.opaque_agent_inputs = snapshot_bytes; + + let output = AgentOutput { + actions: vec![make_echo_action()], + }; + let constraints = ConstraintSetV1 { + max_drawdown_bps: 2_000, // 20% max drawdown + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::DrawdownExceeded); + } + + #[test] + fn test_too_many_actions_fails() { + let input = make_test_input(); + let output = AgentOutput { + actions: vec![make_echo_action(); 65], // 65 actions, max is 64 + }; + let constraints = ConstraintSetV1::default(); + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidOutputStructure); + } + + #[test] + fn test_empty_output_commitment_constant() { + // Verify the empty output commitment constant is correct + use kernel_core::{CanonicalEncode, compute_action_commitment}; + + let empty_output = AgentOutput { actions: vec![] }; + let encoded = empty_output.encode().unwrap(); + let commitment = compute_action_commitment(&encoded); + + assert_eq!(commitment, EMPTY_OUTPUT_COMMITMENT); + } +} diff --git a/crates/host-tests/src/lib.rs b/crates/host-tests/src/lib.rs index 35258b7..b578b8a 100644 --- a/crates/host-tests/src/lib.rs +++ b/crates/host-tests/src/lib.rs @@ -852,6 +852,917 @@ mod tests { assert!(matches!(result, Err(CodecError::UnexpectedEndOfInput))); } + // ======================================================================== + // P0.3: Constraint System Tests + // ======================================================================== + + #[test] + fn test_failure_status_encoding() { + // Failure encodes as 0x02 + let journal = KernelJournalV1 { + protocol_version: PROTOCOL_VERSION, + kernel_version: KERNEL_VERSION, + agent_id: [0; 32], + agent_code_hash: [0; 32], + constraint_set_hash: [0; 32], + input_root: [0; 32], + execution_nonce: 0, + input_commitment: [0; 32], + action_commitment: [0; 32], + execution_status: ExecutionStatus::Failure, + }; + + let encoded = journal.encode().unwrap(); + // Last byte should be 0x02 for Failure + assert_eq!(*encoded.last().unwrap(), 0x02); + + // Round-trip should preserve Failure status + let decoded = KernelJournalV1::decode(&encoded).unwrap(); + assert_eq!(decoded.execution_status, ExecutionStatus::Failure); + } + + #[test] + fn test_empty_output_commitment_constant() { + use constraints::EMPTY_OUTPUT_COMMITMENT; + + // SHA-256 of [0x00, 0x00, 0x00, 0x00] (empty AgentOutput) + let empty_output = AgentOutput { actions: vec![] }; + let encoded = empty_output.encode().unwrap(); + assert_eq!(encoded, vec![0x00, 0x00, 0x00, 0x00]); + + let commitment = compute_action_commitment(&encoded); + assert_eq!(commitment, EMPTY_OUTPUT_COMMITMENT); + + // Verify the constant matches the expected hex value + let expected_hex = "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119"; + let expected = hex_to_bytes32(expected_hex); + assert_eq!(EMPTY_OUTPUT_COMMITMENT, expected); + } + + #[test] + fn test_constraint_violation_reason_codes() { + // Verify violation reason codes match specification + assert_eq!(ConstraintViolationReason::InvalidOutputStructure.code(), 0x01); + assert_eq!(ConstraintViolationReason::UnknownActionType.code(), 0x02); + assert_eq!(ConstraintViolationReason::AssetNotWhitelisted.code(), 0x03); + assert_eq!(ConstraintViolationReason::PositionTooLarge.code(), 0x04); + assert_eq!(ConstraintViolationReason::LeverageTooHigh.code(), 0x05); + assert_eq!(ConstraintViolationReason::DrawdownExceeded.code(), 0x06); + assert_eq!(ConstraintViolationReason::CooldownNotElapsed.code(), 0x07); + assert_eq!(ConstraintViolationReason::InvalidStateSnapshot.code(), 0x08); + assert_eq!(ConstraintViolationReason::InvalidConstraintSet.code(), 0x09); + assert_eq!(ConstraintViolationReason::InvalidActionPayload.code(), 0x0A); + } + + #[test] + fn test_kernel_main_success_status() { + // Normal execution should produce Success status + let input = make_input(vec![1, 2, 3]); + let input_bytes = input.encode().unwrap(); + + let journal_bytes = kernel_main(&input_bytes).unwrap(); + let journal = KernelJournalV1::decode(&journal_bytes).unwrap(); + + assert_eq!(journal.execution_status, ExecutionStatus::Success); + } + + #[test] + fn test_kernel_main_with_constraints_success() { + use constraints::ConstraintSetV1; + use kernel_guest::kernel_main_with_constraints; + + let input = make_input(vec![1, 2, 3]); + let input_bytes = input.encode().unwrap(); + let constraints = ConstraintSetV1::default(); + + let journal_bytes = kernel_main_with_constraints(&input_bytes, &constraints).unwrap(); + let journal = KernelJournalV1::decode(&journal_bytes).unwrap(); + + assert_eq!(journal.execution_status, ExecutionStatus::Success); + } + + #[test] + fn test_constraint_set_default_values() { + use constraints::ConstraintSetV1; + + let constraints = ConstraintSetV1::default(); + + assert_eq!(constraints.version, 1); + assert_eq!(constraints.max_position_notional, u64::MAX); + assert_eq!(constraints.max_leverage_bps, 100_000); // 10x + assert_eq!(constraints.max_drawdown_bps, 10_000); // 100% + assert_eq!(constraints.cooldown_seconds, 0); + assert_eq!(constraints.max_actions_per_output, MAX_ACTIONS_PER_OUTPUT as u32); + assert_eq!(constraints.allowed_asset_id, [0u8; 32]); + } + + #[test] + fn test_state_snapshot_decoding() { + use constraints::StateSnapshotV1; + + // Valid snapshot + let mut snapshot_bytes = Vec::new(); + snapshot_bytes.extend_from_slice(&1u32.to_le_bytes()); // version + snapshot_bytes.extend_from_slice(&1000u64.to_le_bytes()); // last_execution_ts + snapshot_bytes.extend_from_slice(&2000u64.to_le_bytes()); // current_ts + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // current_equity + snapshot_bytes.extend_from_slice(&110_000u64.to_le_bytes()); // peak_equity + + let snapshot = StateSnapshotV1::decode(&snapshot_bytes).unwrap(); + assert_eq!(snapshot.snapshot_version, 1); + assert_eq!(snapshot.last_execution_ts, 1000); + assert_eq!(snapshot.current_ts, 2000); + assert_eq!(snapshot.current_equity, 100_000); + assert_eq!(snapshot.peak_equity, 110_000); + } + + #[test] + fn test_state_snapshot_decoding_too_short() { + use constraints::StateSnapshotV1; + + // Too short - should return None + let short_bytes = vec![1, 2, 3]; + assert!(StateSnapshotV1::decode(&short_bytes).is_none()); + } + + #[test] + fn test_state_snapshot_decoding_wrong_version() { + use constraints::StateSnapshotV1; + + // Wrong version + let mut bad_version = Vec::new(); + bad_version.extend_from_slice(&2u32.to_le_bytes()); // version = 2 (invalid) + bad_version.extend_from_slice(&[0u8; 32]); // pad to 36 bytes + + assert!(StateSnapshotV1::decode(&bad_version).is_none()); + } + + #[test] + fn test_enforce_constraints_echo_action() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + let input = make_input(vec![]); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1, 2, 3], + }], + }; + let constraints = ConstraintSetV1::default(); + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_ok()); + } + + #[test] + fn test_enforce_constraints_unknown_action_type() { + use constraints::{enforce_constraints, ConstraintSetV1}; + + let input = make_input(vec![]); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: 0xFFFFFFFF, // Unknown type + target: [0x11; 32], + payload: vec![], + }], + }; + let constraints = ConstraintSetV1::default(); + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::UnknownActionType); + assert_eq!(violation.action_index, Some(0)); + } + + #[test] + fn test_enforce_constraints_too_many_actions() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + let input = make_input(vec![]); + let output = AgentOutput { + actions: vec![ + ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }; + 65 // 65 actions, max is 64 + ], + }; + let constraints = ConstraintSetV1::default(); + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidOutputStructure); + } + + #[test] + fn test_enforce_constraints_position_too_large() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_OPEN_POSITION}; + + let input = make_input(vec![]); + + // Build OpenPosition payload + let mut payload = Vec::with_capacity(45); + payload.extend_from_slice(&[0x42; 32]); // asset_id + payload.extend_from_slice(&1_000_001u64.to_le_bytes()); // notional (exceeds limit) + payload.extend_from_slice(&10_000u32.to_le_bytes()); // leverage_bps (1x) + payload.push(0); // direction + + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_OPEN_POSITION, + target: [0x22; 32], + payload, + }], + }; + + let constraints = ConstraintSetV1 { + max_position_notional: 1_000_000, + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::PositionTooLarge); + } + + #[test] + fn test_enforce_constraints_leverage_too_high() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_OPEN_POSITION}; + + let input = make_input(vec![]); + + // Build OpenPosition payload with 10x leverage + let mut payload = Vec::with_capacity(45); + payload.extend_from_slice(&[0x42; 32]); // asset_id + payload.extend_from_slice(&1_000u64.to_le_bytes()); // notional + payload.extend_from_slice(&100_000u32.to_le_bytes()); // leverage_bps (10x) + payload.push(0); // direction + + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_OPEN_POSITION, + target: [0x22; 32], + payload, + }], + }; + + let constraints = ConstraintSetV1 { + max_leverage_bps: 50_000, // 5x max + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::LeverageTooHigh); + } + + #[test] + fn test_enforce_constraints_cooldown_not_elapsed() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + // Create input with state snapshot + let mut snapshot_bytes = Vec::new(); + snapshot_bytes.extend_from_slice(&1u32.to_le_bytes()); // version + snapshot_bytes.extend_from_slice(&1000u64.to_le_bytes()); // last_execution_ts + snapshot_bytes.extend_from_slice(&1030u64.to_le_bytes()); // current_ts (30s later) + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // current_equity + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // peak_equity + + let input = make_input(snapshot_bytes); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + let constraints = ConstraintSetV1 { + cooldown_seconds: 60, // 60s cooldown + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::CooldownNotElapsed); + assert_eq!(violation.action_index, None); // Global constraint + } + + #[test] + fn test_enforce_constraints_drawdown_exceeded() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + // Create input with state snapshot showing 30% drawdown + let mut snapshot_bytes = Vec::new(); + snapshot_bytes.extend_from_slice(&1u32.to_le_bytes()); // version + snapshot_bytes.extend_from_slice(&1000u64.to_le_bytes()); // last_execution_ts + snapshot_bytes.extend_from_slice(&2000u64.to_le_bytes()); // current_ts + snapshot_bytes.extend_from_slice(&70_000u64.to_le_bytes()); // current_equity (70%) + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // peak_equity + + let input = make_input(snapshot_bytes); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + let constraints = ConstraintSetV1 { + max_drawdown_bps: 2_000, // 20% max drawdown + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::DrawdownExceeded); + assert_eq!(violation.action_index, None); // Global constraint + } + + #[test] + fn test_enforce_constraints_invalid_payload() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_OPEN_POSITION}; + + let input = make_input(vec![]); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_OPEN_POSITION, + target: [0x22; 32], + payload: vec![1, 2, 3], // Too short for OpenPosition (needs 45 bytes) + }], + }; + + let constraints = ConstraintSetV1::default(); + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidActionPayload); + } + + #[test] + fn test_journal_failure_has_empty_commitment() { + use constraints::EMPTY_OUTPUT_COMMITMENT; + + // Create a journal with Failure status + let journal = KernelJournalV1 { + protocol_version: PROTOCOL_VERSION, + kernel_version: KERNEL_VERSION, + agent_id: [0x42; 32], + agent_code_hash: [0xaa; 32], + constraint_set_hash: [0xbb; 32], + input_root: [0xcc; 32], + execution_nonce: 1, + input_commitment: [0xdd; 32], + action_commitment: EMPTY_OUTPUT_COMMITMENT, // On failure + execution_status: ExecutionStatus::Failure, + }; + + // Verify round-trip preserves all fields + let encoded = journal.encode().unwrap(); + let decoded = KernelJournalV1::decode(&encoded).unwrap(); + + assert_eq!(decoded.execution_status, ExecutionStatus::Failure); + assert_eq!(decoded.action_commitment, EMPTY_OUTPUT_COMMITMENT); + } + + // ======================================================================== + // P0.3: Missing Snapshot Safety Tests + // ======================================================================== + + #[test] + fn test_missing_snapshot_with_cooldown_enabled_fails() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + // No snapshot provided (empty opaque_agent_inputs) + let input = make_input(vec![]); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + // Enable cooldown constraint + let constraints = ConstraintSetV1 { + cooldown_seconds: 60, // Cooldown enabled + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidStateSnapshot); + assert_eq!(violation.action_index, None); // Global constraint + } + + #[test] + fn test_missing_snapshot_with_drawdown_enabled_fails() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + // No snapshot provided (empty opaque_agent_inputs) + let input = make_input(vec![]); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + // Enable drawdown constraint (< 100%) + let constraints = ConstraintSetV1 { + max_drawdown_bps: 2_000, // 20% max drawdown (enabled) + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidStateSnapshot); + assert_eq!(violation.action_index, None); // Global constraint + } + + #[test] + fn test_missing_snapshot_with_disabled_constraints_passes() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + // No snapshot provided (empty opaque_agent_inputs) + let input = make_input(vec![]); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + // Both cooldown and drawdown disabled (default values) + let constraints = ConstraintSetV1 { + cooldown_seconds: 0, // Disabled + max_drawdown_bps: 10_000, // 100% = disabled + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_ok()); + } + + #[test] + fn test_drawdown_with_equity_growth_passes() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + // Create input with state snapshot where current_equity > peak_equity + let mut snapshot_bytes = Vec::new(); + snapshot_bytes.extend_from_slice(&1u32.to_le_bytes()); // version + snapshot_bytes.extend_from_slice(&1000u64.to_le_bytes()); // last_execution_ts + snapshot_bytes.extend_from_slice(&2000u64.to_le_bytes()); // current_ts + snapshot_bytes.extend_from_slice(&120_000u64.to_le_bytes()); // current_equity (120% of peak) + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // peak_equity + + let input = make_input(snapshot_bytes); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + // Enable strict drawdown constraint + let constraints = ConstraintSetV1 { + max_drawdown_bps: 500, // 5% max drawdown (strict) + ..ConstraintSetV1::default() + }; + + // Should pass because current_equity > peak_equity means 0 drawdown + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_ok()); + } + + // ======================================================================== + // P0.3: Payload Trailing Bytes Rejection Tests + // ======================================================================== + + #[test] + fn test_open_position_payload_trailing_bytes_rejected() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_OPEN_POSITION}; + + let input = make_input(vec![]); + + // Build valid OpenPosition payload (45 bytes) then add trailing byte + let mut payload = Vec::with_capacity(46); + payload.extend_from_slice(&[0x42; 32]); // asset_id + payload.extend_from_slice(&1000u64.to_le_bytes()); // notional + payload.extend_from_slice(&10_000u32.to_le_bytes()); // leverage_bps (1x) + payload.push(0); // direction + payload.push(0xFF); // TRAILING BYTE + + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_OPEN_POSITION, + target: [0x22; 32], + payload, + }], + }; + + let constraints = ConstraintSetV1::default(); + let result = enforce_constraints(&input, &output, &constraints); + + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidActionPayload); + assert_eq!(violation.action_index, Some(0)); + } + + #[test] + fn test_close_position_payload_trailing_bytes_rejected() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_CLOSE_POSITION}; + + let input = make_input(vec![]); + + // Build valid ClosePosition payload (32 bytes) then add trailing byte + let mut payload = Vec::with_capacity(33); + payload.extend_from_slice(&[0xab; 32]); // position_id + payload.push(0xFF); // TRAILING BYTE + + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_CLOSE_POSITION, + target: [0x33; 32], + payload, + }], + }; + + let constraints = ConstraintSetV1::default(); + let result = enforce_constraints(&input, &output, &constraints); + + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidActionPayload); + assert_eq!(violation.action_index, Some(0)); + } + + #[test] + fn test_adjust_position_payload_trailing_bytes_rejected() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ADJUST_POSITION}; + + let input = make_input(vec![]); + + // Build valid AdjustPosition payload (44 bytes) then add trailing byte + let mut payload = Vec::with_capacity(45); + payload.extend_from_slice(&[0xab; 32]); // position_id + payload.extend_from_slice(&2000u64.to_le_bytes()); // new_notional + payload.extend_from_slice(&20_000u32.to_le_bytes()); // new_leverage_bps + payload.push(0xFF); // TRAILING BYTE + + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ADJUST_POSITION, + target: [0x44; 32], + payload, + }], + }; + + let constraints = ConstraintSetV1::default(); + let result = enforce_constraints(&input, &output, &constraints); + + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidActionPayload); + assert_eq!(violation.action_index, Some(0)); + } + + #[test] + fn test_swap_payload_trailing_bytes_rejected() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_SWAP}; + + let input = make_input(vec![]); + + // Build valid Swap payload (72 bytes) then add trailing byte + let mut payload = Vec::with_capacity(73); + payload.extend_from_slice(&[0xaa; 32]); // from_asset + payload.extend_from_slice(&[0xbb; 32]); // to_asset + payload.extend_from_slice(&1000u64.to_le_bytes()); // amount + payload.push(0xFF); // TRAILING BYTE + + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_SWAP, + target: [0x55; 32], + payload, + }], + }; + + let constraints = ConstraintSetV1::default(); + let result = enforce_constraints(&input, &output, &constraints); + + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidActionPayload); + assert_eq!(violation.action_index, Some(0)); + } + + // ======================================================================== + // P0.3: ConstraintSet Invariant Validation Tests + // ======================================================================== + + #[test] + fn test_invalid_constraint_set_max_actions_too_large() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + let input = make_input(vec![]); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + // max_actions_per_output exceeds protocol limit (64) + let constraints = ConstraintSetV1 { + max_actions_per_output: 65, + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidConstraintSet); + assert_eq!(violation.action_index, None); // Global constraint + } + + #[test] + fn test_invalid_constraint_set_drawdown_too_large() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + let input = make_input(vec![]); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + // max_drawdown_bps exceeds 10000 (100%) + let constraints = ConstraintSetV1 { + max_drawdown_bps: 10_001, + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidConstraintSet); + assert_eq!(violation.action_index, None); // Global constraint + } + + #[test] + fn test_valid_constraint_set_zero_leverage() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + let input = make_input(vec![]); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + // max_leverage_bps == 0 is valid (would reject all leveraged positions) + let constraints = ConstraintSetV1 { + max_leverage_bps: 0, + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + // Should pass for echo action (no leverage check) + assert!(result.is_ok()); + } + + // ======================================================================== + // P0.3: Asset Whitelist Tests + // ======================================================================== + + #[test] + fn test_open_position_asset_not_whitelisted_fails() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_OPEN_POSITION}; + + let input = make_input(vec![]); + + // Build OpenPosition payload with asset_id = [0x99; 32] + let mut payload = Vec::with_capacity(45); + payload.extend_from_slice(&[0x99; 32]); // asset_id (NOT whitelisted) + payload.extend_from_slice(&1000u64.to_le_bytes()); // notional + payload.extend_from_slice(&10_000u32.to_le_bytes()); // leverage_bps (1x) + payload.push(0); // direction + + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_OPEN_POSITION, + target: [0x22; 32], + payload, + }], + }; + + // Whitelist only allows [0x42; 32] + let constraints = ConstraintSetV1 { + allowed_asset_id: [0x42; 32], + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::AssetNotWhitelisted); + assert_eq!(violation.action_index, Some(0)); + } + + #[test] + fn test_swap_from_asset_not_whitelisted_fails() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_SWAP}; + + let input = make_input(vec![]); + + // Build Swap payload with from_asset = [0x99; 32] (not whitelisted) + let mut payload = Vec::with_capacity(72); + payload.extend_from_slice(&[0x99; 32]); // from_asset (NOT whitelisted) + payload.extend_from_slice(&[0x42; 32]); // to_asset (whitelisted) + payload.extend_from_slice(&1000u64.to_le_bytes()); // amount + + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_SWAP, + target: [0x55; 32], + payload, + }], + }; + + // Whitelist only allows [0x42; 32] + let constraints = ConstraintSetV1 { + allowed_asset_id: [0x42; 32], + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::AssetNotWhitelisted); + assert_eq!(violation.action_index, Some(0)); + } + + #[test] + fn test_swap_to_asset_not_whitelisted_fails() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_SWAP}; + + let input = make_input(vec![]); + + // Build Swap payload with to_asset = [0x99; 32] (not whitelisted) + let mut payload = Vec::with_capacity(72); + payload.extend_from_slice(&[0x42; 32]); // from_asset (whitelisted) + payload.extend_from_slice(&[0x99; 32]); // to_asset (NOT whitelisted) + payload.extend_from_slice(&1000u64.to_le_bytes()); // amount + + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_SWAP, + target: [0x55; 32], + payload, + }], + }; + + // Whitelist only allows [0x42; 32] + let constraints = ConstraintSetV1 { + allowed_asset_id: [0x42; 32], + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::AssetNotWhitelisted); + assert_eq!(violation.action_index, Some(0)); + } + + #[test] + fn test_allowed_asset_id_zero_allows_any_asset() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_OPEN_POSITION}; + + let input = make_input(vec![]); + + // Build OpenPosition payload with arbitrary asset_id + let mut payload = Vec::with_capacity(45); + payload.extend_from_slice(&[0x99; 32]); // asset_id (any value) + payload.extend_from_slice(&1000u64.to_le_bytes()); // notional + payload.extend_from_slice(&10_000u32.to_le_bytes()); // leverage_bps (1x) + payload.push(0); // direction + + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_OPEN_POSITION, + target: [0x22; 32], + payload, + }], + }; + + // allowed_asset_id = [0; 32] means all assets are allowed + let constraints = ConstraintSetV1 { + allowed_asset_id: [0u8; 32], // Zero = all assets allowed + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + // Should pass - zero allowed_asset_id means no whitelist restriction + assert!(result.is_ok()); + } + + // ======================================================================== + // P0.3: Cooldown Timestamp Overflow Tests + // ======================================================================== + + #[test] + fn test_cooldown_timestamp_overflow_rejected() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + // Create input with state snapshot where last_execution_ts is near u64::MAX + let mut snapshot_bytes = Vec::new(); + snapshot_bytes.extend_from_slice(&1u32.to_le_bytes()); // version + snapshot_bytes.extend_from_slice(&u64::MAX.to_le_bytes()); // last_execution_ts (malicious) + snapshot_bytes.extend_from_slice(&1000u64.to_le_bytes()); // current_ts + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // current_equity + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // peak_equity + + let input = make_input(snapshot_bytes); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + // Enable cooldown constraint + let constraints = ConstraintSetV1 { + cooldown_seconds: 60, + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + // Overflow should result in InvalidStateSnapshot, not bypass cooldown + assert_eq!(violation.reason, ConstraintViolationReason::InvalidStateSnapshot); + assert_eq!(violation.action_index, None); + } + + #[test] + fn test_cooldown_timestamp_near_overflow_boundary() { + use constraints::{enforce_constraints, ConstraintSetV1, ACTION_TYPE_ECHO}; + + // last_execution_ts + cooldown_seconds would overflow + let malicious_ts = u64::MAX - 30; // Will overflow when adding 60 + + let mut snapshot_bytes = Vec::new(); + snapshot_bytes.extend_from_slice(&1u32.to_le_bytes()); // version + snapshot_bytes.extend_from_slice(&malicious_ts.to_le_bytes()); // last_execution_ts + snapshot_bytes.extend_from_slice(&1000u64.to_le_bytes()); // current_ts + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // current_equity + snapshot_bytes.extend_from_slice(&100_000u64.to_le_bytes()); // peak_equity + + let input = make_input(snapshot_bytes); + let output = AgentOutput { + actions: vec![ActionV1 { + action_type: ACTION_TYPE_ECHO, + target: [0x11; 32], + payload: vec![1], + }], + }; + + let constraints = ConstraintSetV1 { + cooldown_seconds: 60, + ..ConstraintSetV1::default() + }; + + let result = enforce_constraints(&input, &output, &constraints); + assert!(result.is_err()); + let violation = result.unwrap_err(); + assert_eq!(violation.reason, ConstraintViolationReason::InvalidStateSnapshot); + } + // ======================================================================== // Test Helpers // ======================================================================== diff --git a/crates/kernel-core/src/codec.rs b/crates/kernel-core/src/codec.rs index 8654bd5..6210421 100644 --- a/crates/kernel-core/src/codec.rs +++ b/crates/kernel-core/src/codec.rs @@ -326,9 +326,11 @@ impl CanonicalEncode for KernelJournalV1 { buf.extend_from_slice(&self.input_commitment); buf.extend_from_slice(&self.action_commitment); - // ExecutionStatus encoding: Success = 0x01 (0x00 reserved to catch uninitialized memory) + // ExecutionStatus encoding: Success = 0x01, Failure = 0x02 + // 0x00 is reserved to catch uninitialized memory buf.push(match self.execution_status { ExecutionStatus::Success => 0x01, + ExecutionStatus::Failure => 0x02, }); debug_assert_eq!(buf.len(), JOURNAL_SIZE); @@ -411,9 +413,11 @@ impl CanonicalDecode for KernelJournalV1 { .map_err(|_| CodecError::UnexpectedEndOfInput)?; offset += 32; - // ExecutionStatus decoding: 0x01 = Success, 0x00 and anything else is invalid + // ExecutionStatus decoding: 0x01 = Success, 0x02 = Failure + // 0x00 and anything else is invalid let execution_status = match bytes[offset] { 0x01 => ExecutionStatus::Success, + 0x02 => ExecutionStatus::Failure, status => return Err(CodecError::InvalidExecutionStatus(status)), }; offset += 1; diff --git a/crates/kernel-core/src/types.rs b/crates/kernel-core/src/types.rs index caa2f1a..09fbeec 100644 --- a/crates/kernel-core/src/types.rs +++ b/crates/kernel-core/src/types.rs @@ -61,16 +61,24 @@ pub struct KernelJournalV1 { /// Execution status enum. /// -/// Encoding: Success = 0x01 -/// 0x00 is reserved/invalid (prevents uninitialized memory from being interpreted as success). -/// Any other value is invalid and must be rejected on decode. +/// Encoding: +/// - Success = 0x01: Execution completed and constraints passed +/// - Failure = 0x02: Execution completed but constraints violated +/// - 0x00 is reserved/invalid (prevents uninitialized memory from being interpreted as success) +/// - 0x03-0xFF are reserved for future expansion /// -/// For P0.1, only Success is defined. Failure cases abort before -/// journal commit, so no failure status is needed in the journal. -#[derive(Clone, Debug, PartialEq)] +/// On Failure, the journal is still produced with: +/// - action_commitment = SHA256(empty AgentOutput encoding) +/// - execution_status = Failure (0x02) +/// +/// Verifiers/contracts should reject state transitions for Failure journals. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExecutionStatus { - /// Execution completed successfully. Encoded as 0x01. + /// Execution completed successfully and all constraints passed. Encoded as 0x01. Success, + /// Execution completed but constraints were violated. Encoded as 0x02. + /// The action_commitment will be the commitment to an empty AgentOutput. + Failure, } /// Structured action format for agent output. @@ -217,15 +225,85 @@ pub enum AgentError { TooManyActions, } -/// Constraint checking errors +/// Constraint violation reason codes (stable numeric codes for determinism). +/// +/// These codes are used to identify the specific constraint that was violated. +/// The numeric values are stable and should not be changed once defined. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum ConstraintViolationReason { + /// Output structure is invalid (too many actions, payload too large) + InvalidOutputStructure = 0x01, + /// Action type is not recognized/supported + UnknownActionType = 0x02, + /// Asset is not in the whitelist + AssetNotWhitelisted = 0x03, + /// Position size exceeds maximum allowed + PositionTooLarge = 0x04, + /// Leverage exceeds maximum allowed + LeverageTooHigh = 0x05, + /// Drawdown exceeds maximum allowed + DrawdownExceeded = 0x06, + /// Cooldown period has not elapsed since last execution + CooldownNotElapsed = 0x07, + /// State snapshot is invalid or missing required fields + InvalidStateSnapshot = 0x08, + /// Constraint set configuration is invalid + InvalidConstraintSet = 0x09, + /// Action payload is malformed or invalid + InvalidActionPayload = 0x0A, +} + +impl ConstraintViolationReason { + /// Get the numeric code for this violation reason. + pub fn code(self) -> u8 { + self as u8 + } +} + +/// Detailed constraint violation information. +#[derive(Clone, Debug, PartialEq)] +pub struct ConstraintViolation { + /// The specific reason for the violation + pub reason: ConstraintViolationReason, + /// Index of the action that violated the constraint (if applicable) + /// None for global violations (cooldown, drawdown, etc.) + pub action_index: Option, +} + +impl ConstraintViolation { + /// Create a new constraint violation for a specific action. + pub fn action(reason: ConstraintViolationReason, index: usize) -> Self { + Self { + reason, + action_index: Some(index), + } + } + + /// Create a new constraint violation for a global constraint. + pub fn global(reason: ConstraintViolationReason) -> Self { + Self { + reason, + action_index: None, + } + } +} + +/// Constraint checking errors (legacy compatibility + detailed violations) #[derive(Clone, Debug, PartialEq)] pub enum ConstraintError { - /// An action violated a constraint - ViolatedConstraint { action_index: usize, reason: &'static str }, - /// Output structure is invalid + /// A constraint was violated (detailed) + Violation(ConstraintViolation), + /// Output structure is invalid (legacy) InvalidOutput, } +impl From for ConstraintError { + fn from(v: ConstraintViolation) -> Self { + ConstraintError::Violation(v) + } +} + impl From for KernelError { fn from(e: CodecError) -> Self { KernelError::Codec(e) diff --git a/crates/kernel-guest/src/lib.rs b/crates/kernel-guest/src/lib.rs index 5aa94ac..3a06d07 100644 --- a/crates/kernel-guest/src/lib.rs +++ b/crates/kernel-guest/src/lib.rs @@ -1,6 +1,6 @@ use kernel_core::*; use agent_traits::{Agent, AgentContext, TrivialAgent}; -use constraints::{check, validate_output_structure, ConstraintMeta}; +use constraints::{enforce_constraints, ConstraintSetV1, EMPTY_OUTPUT_COMMITMENT}; /// Main kernel execution function. /// @@ -9,16 +9,26 @@ use constraints::{check, validate_output_structure, ConstraintMeta}; /// 2. Verifies protocol and kernel versions /// 3. Computes input commitment /// 4. Executes the agent -/// 5. Validates and checks constraints on output +/// 5. Enforces constraints on agent output (UNSKIPPABLE) /// 6. Computes action commitment /// 7. Constructs and returns the journal /// +/// # P0.3 Constraint Enforcement +/// +/// Constraints are ALWAYS enforced after agent execution. If any constraint +/// is violated: +/// - `execution_status` is set to `Failure` (0x02) +/// - `action_commitment` is computed over an empty `AgentOutput` +/// - A valid journal is still produced +/// +/// This ensures constraint violations are provable and verifiable on-chain. +/// /// # Arguments /// * `input_bytes` - Canonical encoding of KernelInputV1 /// /// # Returns -/// * `Ok(Vec)` - Canonical encoding of KernelJournalV1 -/// * `Err(KernelError)` - Execution failed, no journal produced +/// * `Ok(Vec)` - Canonical encoding of KernelJournalV1 (always produced) +/// * `Err(KernelError)` - Critical failure (decoding, version mismatch) /// /// # Determinism /// @@ -62,29 +72,116 @@ pub fn kernel_main(input_bytes: &[u8]) -> Result, KernelError> { let agent_output = TrivialAgent::run(&agent_ctx, &input.opaque_agent_inputs) .map_err(KernelError::AgentExecutionFailed)?; - // 6. Validate output structure - validate_output_structure(&agent_output) - .map_err(KernelError::ConstraintViolation)?; + // 6. Get constraint set (P0.3: use default permissive constraints) + let constraint_set = ConstraintSetV1::default(); + + // 7. ENFORCE CONSTRAINTS (UNSKIPPABLE) + // This is the critical safety check that validates all agent actions. + let (validated_output, execution_status) = + match enforce_constraints(&input, &agent_output, &constraint_set) { + Ok(validated) => { + // Constraints passed - use validated output + (validated, ExecutionStatus::Success) + } + Err(_violation) => { + // Constraints violated - use empty output and Failure status + // The violation details are not included in the journal for P0.3 + // but could be logged or added in future versions. + (AgentOutput { actions: vec![] }, ExecutionStatus::Failure) + } + }; + + // 8. Compute action commitment + // On Success: computed over validated output + // On Failure: computed over empty output (deterministic constant) + let action_commitment = if execution_status == ExecutionStatus::Success { + let output_bytes = validated_output + .encode() + .map_err(KernelError::EncodingFailed)?; + compute_action_commitment(&output_bytes) + } else { + // Use pre-computed constant for empty output commitment + EMPTY_OUTPUT_COMMITMENT + }; - // 7. Build constraint metadata - let constraint_meta = ConstraintMeta { + // 9. Construct journal with all identity and commitment fields + let journal = KernelJournalV1 { + protocol_version: PROTOCOL_VERSION, + kernel_version: KERNEL_VERSION, agent_id: input.agent_id, agent_code_hash: input.agent_code_hash, constraint_set_hash: input.constraint_set_hash, input_root: input.input_root, execution_nonce: input.execution_nonce, + input_commitment, + action_commitment, + execution_status, }; - // 8. Check constraints (MANDATORY) - check(&agent_output, &constraint_meta) - .map_err(KernelError::ConstraintViolation)?; + // 10. Encode and return journal (always produced) + journal.encode().map_err(KernelError::EncodingFailed) +} + +/// Execute kernel with custom constraint set. +/// +/// This variant allows specifying a custom constraint set instead of +/// using the default. Useful for testing and specialized deployments. +pub fn kernel_main_with_constraints( + input_bytes: &[u8], + constraint_set: &ConstraintSetV1, +) -> Result, KernelError> { + // 1. Decode input + let input = KernelInputV1::decode(input_bytes)?; + + // 2. Validate versions + if input.protocol_version != PROTOCOL_VERSION { + return Err(KernelError::UnsupportedProtocolVersion { + expected: PROTOCOL_VERSION, + actual: input.protocol_version, + }); + } - // 9. Compute action commitment (encode() automatically canonicalizes) - let agent_output_bytes = agent_output.encode() - .map_err(KernelError::EncodingFailed)?; - let action_commitment = compute_action_commitment(&agent_output_bytes); + if input.kernel_version != KERNEL_VERSION { + return Err(KernelError::UnsupportedKernelVersion { + expected: KERNEL_VERSION, + actual: input.kernel_version, + }); + } + + // 3. Compute input commitment + let input_commitment = compute_input_commitment(input_bytes); + + // 4. Build agent context + let agent_ctx = AgentContext { + agent_id: input.agent_id, + agent_code_hash: input.agent_code_hash, + constraint_set_hash: input.constraint_set_hash, + input_root: input.input_root, + execution_nonce: input.execution_nonce, + }; + + // 5. Execute agent + let agent_output = TrivialAgent::run(&agent_ctx, &input.opaque_agent_inputs) + .map_err(KernelError::AgentExecutionFailed)?; + + // 6. ENFORCE CONSTRAINTS (UNSKIPPABLE) + let (validated_output, execution_status) = + match enforce_constraints(&input, &agent_output, constraint_set) { + Ok(validated) => (validated, ExecutionStatus::Success), + Err(_violation) => (AgentOutput { actions: vec![] }, ExecutionStatus::Failure), + }; + + // 7. Compute action commitment + let action_commitment = if execution_status == ExecutionStatus::Success { + let output_bytes = validated_output + .encode() + .map_err(KernelError::EncodingFailed)?; + compute_action_commitment(&output_bytes) + } else { + EMPTY_OUTPUT_COMMITMENT + }; - // 10. Construct journal with all identity and commitment fields + // 8. Construct and return journal let journal = KernelJournalV1 { protocol_version: PROTOCOL_VERSION, kernel_version: KERNEL_VERSION, @@ -95,7 +192,7 @@ pub fn kernel_main(input_bytes: &[u8]) -> Result, KernelError> { execution_nonce: input.execution_nonce, input_commitment, action_commitment, - execution_status: ExecutionStatus::Success, + execution_status, }; journal.encode().map_err(KernelError::EncodingFailed) diff --git a/docs/P0_DOCUMENTATION.md b/docs/P0.1_DOCUMENTATION.md similarity index 87% rename from docs/P0_DOCUMENTATION.md rename to docs/P0.1_DOCUMENTATION.md index 6fe96cc..fcd1eff 100644 --- a/docs/P0_DOCUMENTATION.md +++ b/docs/P0.1_DOCUMENTATION.md @@ -70,9 +70,9 @@ As a user of the kernel, you should assume that action order is normalized and s Constraint enforcement is mandatory. Every kernel execution validates the agent output structure and then applies the constraint policy associated with the provided constraint set hash. -In P0.1, the constraint logic is intentionally a stub that always succeeds. However, the call is still required and enforced. This locks in the protocol structure and ensures that future versions can introduce real constraints without changing the execution flow. +In P0.1, the constraint logic was intentionally a stub that always succeeds. This locked in the protocol structure. As of P0.3, real constraint enforcement is implemented, validating action types, payload schemas, asset whitelists, position limits, leverage limits, cooldown periods, and drawdown limits. -If any constraint check fails, execution aborts immediately and no journal is produced. +If any constraint check fails in P0.3+, the kernel produces a `Failure` journal with an empty action commitment. The proof remains valid, but verifiers should reject the state transition. See `docs/P0.3_DOCUMENTATION.md` and `spec/constraints.md` for full constraint semantics. ## Action commitment @@ -93,9 +93,13 @@ When you build on top of the kernel, your on-chain or off-chain verifier should ## Failure semantics -The kernel follows a single, simple rule for failures. Any error results in no journal. There are no partial results, no failure receipts, and no ambiguous states. +The kernel distinguishes between two types of failures: -This applies to decoding errors, version mismatches, agent execution failures, constraint violations, and encoding errors. In the zkVM guest, failures cause a panic, which prevents any journal from being committed. +**Hard failures** (decoding errors, version mismatches, encoding errors): No journal is produced. In the zkVM guest, these cause a panic which prevents any journal from being committed. + +**Constraint violations** (P0.3+): A `Failure` journal is produced with `execution_status = 0x02` and an empty action commitment. The proof is valid, but verifiers should reject the state transition. This allows constraint violations to be provable on-chain. + +In P0.1, all failures resulted in no journal. As of P0.3, constraint violations are auditable through failure journals. ## zkVM integration diff --git a/docs/P0.2_DOCUMENTATION.md b/docs/P0.2_DOCUMENTATION.md new file mode 100644 index 0000000..1d87149 --- /dev/null +++ b/docs/P0.2_DOCUMENTATION.md @@ -0,0 +1,290 @@ +# Canonical Codec and Commitment Specification + +This document specifies the canonical binary encoding for the kernel protocol (version 1). +All encodings are deterministic and consensus-critical. + +## Design Principles + +1. **Determinism**: Identical data structures always produce identical byte sequences +2. **Self-describing lengths**: Variable-length fields are prefixed with their byte length +3. **Little-endian integers**: All multi-byte integers use little-endian byte order +4. **No implicit padding**: Structures are tightly packed with no alignment padding +5. **Strict decoding**: Trailing bytes, invalid versions, and out-of-range values are rejected + +--- + +## Primitive Encoding Rules + +### Integers + +| Type | Encoding | Size | +|------|----------|------| +| `u32` | Little-endian | 4 bytes | +| `u64` | Little-endian | 8 bytes | +| `u8` | Raw byte | 1 byte | + +### Fixed-Size Byte Arrays + +| Type | Encoding | Size | +|------|----------|------| +| `[u8; 32]` | Raw bytes (no length prefix) | 32 bytes | + +### Variable-Length Byte Arrays + +| Type | Encoding | Size | +|------|----------|------| +| `Vec` | `[length: u32][data: u8*]` | 4 + length bytes | + +The `length` field specifies the **number of bytes** in the data that follows. + +--- + +## KernelInputV1 + +### Layout + +| Offset | Field | Type | Size | Description | +|--------|-------|------|------|-------------| +| 0 | `protocol_version` | u32 | 4 | Wire format version | +| 4 | `kernel_version` | u32 | 4 | Kernel semantics version | +| 8 | `agent_id` | [u8; 32] | 32 | Agent identifier | +| 40 | `agent_code_hash` | [u8; 32] | 32 | SHA-256 of agent binary | +| 72 | `constraint_set_hash` | [u8; 32] | 32 | SHA-256 of constraint set | +| 104 | `input_root` | [u8; 32] | 32 | External state root (market/vault snapshot) | +| 136 | `execution_nonce` | u64 | 8 | Replay protection nonce | +| 144 | `opaque_agent_inputs_len` | u32 | 4 | Length of agent input data (bytes) | +| 148 | `opaque_agent_inputs` | [u8; *] | variable | Agent-specific input (max 64,000 bytes) | + +### Size + +- **Fixed header**: 148 bytes +- **Total**: 148 + `opaque_agent_inputs_len` bytes +- **Minimum**: 148 bytes (empty input) +- **Maximum**: 148 + 64,000 = 64,148 bytes + +### Validation Rules + +1. `protocol_version` MUST equal `PROTOCOL_VERSION` (currently 1) +2. `kernel_version` MUST equal `KERNEL_VERSION` (currently 1) +3. `opaque_agent_inputs_len` MUST NOT exceed `MAX_AGENT_INPUT_BYTES` (64,000) +4. Total bytes MUST equal exactly 148 + `opaque_agent_inputs_len` (no trailing bytes) +5. Decoders MUST reject if `148 + opaque_agent_inputs_len` would overflow + +--- + +## KernelJournalV1 + +### Layout + +| Offset | Field | Type | Size | Description | +|--------|-------|------|------|-------------| +| 0 | `protocol_version` | u32 | 4 | Wire format version | +| 4 | `kernel_version` | u32 | 4 | Kernel semantics version | +| 8 | `agent_id` | [u8; 32] | 32 | Agent identifier (copied from input) | +| 40 | `agent_code_hash` | [u8; 32] | 32 | Agent code hash (copied from input) | +| 72 | `constraint_set_hash` | [u8; 32] | 32 | Constraint set hash (copied from input) | +| 104 | `input_root` | [u8; 32] | 32 | External state root (copied from input) | +| 136 | `execution_nonce` | u64 | 8 | Execution nonce (copied from input) | +| 144 | `input_commitment` | [u8; 32] | 32 | SHA-256 of encoded KernelInputV1 | +| 176 | `action_commitment` | [u8; 32] | 32 | SHA-256 of encoded AgentOutput | +| 208 | `execution_status` | u8 | 1 | Execution result | + +### Size + +- **Fixed**: 209 bytes (always) + +### Validation Rules + +1. `protocol_version` MUST equal `PROTOCOL_VERSION` (currently 1) +2. `kernel_version` MUST equal `KERNEL_VERSION` (currently 1) +3. `execution_status` MUST equal 0x01 (Success) or 0x02 (Failure) +4. Total bytes MUST equal exactly 209 (no more, no less) + +--- + +## ExecutionStatus + +### Encoding + +| Value | Status | Description | +|-------|--------|-------------| +| 0x00 | Reserved | Invalid (catches uninitialized memory) | +| 0x01 | Success | Execution completed successfully | +| 0x02 | Failure | Constraint violation (P0.3+) | +| 0x03-0xFF | Reserved | Invalid (reserved for future expansion) | + +### Rationale + +The value 0x00 is explicitly reserved to detect bugs where uninitialized memory is accidentally interpreted as a valid status. + +**P0.1-P0.2:** Only 0x01 (Success) was valid. Failures caused the kernel to abort before committing a journal. + +**P0.3+:** Both 0x01 (Success) and 0x02 (Failure) are valid. Constraint violations produce a Failure journal with an empty action commitment. This allows constraint violations to be provable on-chain while still rejecting invalid state transitions. + +Decoders MUST reject any value other than 0x01 or 0x02. + +--- + +## ActionV1 + +### Layout + +| Offset | Field | Type | Size | Description | +|--------|-------|------|------|-------------| +| 0 | `action_type` | u32 | 4 | Action type identifier | +| 4 | `target` | [u8; 32] | 32 | Target address/identifier | +| 36 | `payload_len` | u32 | 4 | Length of payload data (bytes) | +| 40 | `payload` | [u8; *] | variable | Action-specific data (max 16,384 bytes) | + +### Size + +- **Fixed header**: 40 bytes +- **Total**: 40 + `payload_len` bytes +- **Minimum**: 40 bytes (empty payload) +- **Maximum**: 40 + 16,384 = 16,424 bytes (`MAX_SINGLE_ACTION_BYTES`) + +### Validation Rules + +1. `payload_len` MUST NOT exceed `MAX_ACTION_PAYLOAD_BYTES` (16,384) +2. Total bytes MUST equal exactly 40 + `payload_len` (no trailing bytes) +3. When embedded in AgentOutput, the `action_len` prefix MUST exactly equal the actual byte length of the ActionV1 encoding (i.e., `action_len == 40 + payload_len`) + +--- + +## AgentOutput + +### Layout + +| Offset | Field | Type | Size | Description | +|--------|-------|------|------|-------------| +| 0 | `action_count` | u32 | 4 | Number of actions | +| 4 | actions[0..n] | ActionV1[] | variable | Length-prefixed actions | + +Each action is encoded as: +- `action_len: u32` (4 bytes) - Byte length of the following ActionV1 encoding +- `action: ActionV1` (variable) - The encoded action + +### Size + +- **Minimum**: 4 bytes (zero actions) +- **Maximum**: computed as follows: + - Per-action overhead: `action_len` prefix (4) + `MAX_SINGLE_ACTION_BYTES` (16,424) = 16,428 bytes + - Total: 4 + `MAX_ACTIONS_PER_OUTPUT` × 16,428 = 4 + 64 × 16,428 = **1,051,396 bytes** + +### Validation Rules + +1. `action_count` MUST NOT exceed `MAX_ACTIONS_PER_OUTPUT` (64) +2. Each `action_len` MUST NOT exceed `MAX_SINGLE_ACTION_BYTES` (16,424) +3. Each `action_len` MUST exactly equal the number of bytes consumed by the following ActionV1 encoding +4. Exactly `action_count` actions MUST be present; fewer bytes implies `UnexpectedEndOfInput`, more bytes implies `InvalidLength` +5. Total bytes MUST equal the sum of all prefixes and action encodings (no trailing bytes) + +--- + +## Canonical Ordering + +Actions MUST be sorted into canonical order before encoding. This ensures deterministic `action_commitment` regardless of the order agents produce actions. + +### Ordering Rules + +Actions are sorted using lexicographic comparison in this priority: + +1. `action_type` (ascending, unsigned integer comparison) +2. `target` (lexicographic byte comparison, 32 bytes) +3. `payload` (lexicographic byte comparison of raw payload bytes) + +**Important**: The `payload_len` field is **not** part of the sort key; only the raw payload bytes are compared. Actions with identical `action_type`, `target`, and `payload` bytes are considered equal regardless of encoding. + +### Example + +Given actions: +- A: `{type: 2, target: 0x11..., payload: [1]}` +- B: `{type: 1, target: 0x22..., payload: [2]}` +- C: `{type: 1, target: 0x11..., payload: [3]}` + +Canonical order: **C, B, A** + +Reasoning: +1. C and B have `action_type=1`, A has `action_type=2` → A comes last +2. Between C and B: C has `target=0x11...`, B has `target=0x22...` → C comes first (0x11 < 0x22) + +--- + +## Commitment Computation + +### Input Commitment + +``` +input_commitment = SHA-256(encoded_KernelInputV1) +``` + +The commitment is computed over the **complete canonical encoding** of KernelInputV1, including: +- All fixed header fields (148 bytes) +- The `opaque_agent_inputs_len` length prefix (4 bytes) +- The `opaque_agent_inputs` data bytes + +### Action Commitment + +``` +action_commitment = SHA-256(encoded_AgentOutput) +``` + +The commitment is computed over the **complete canonical encoding** of AgentOutput, including: +- The `action_count` field (4 bytes) +- All `action_len` prefixes and ActionV1 encodings +- Actions MUST be in canonical order (see Canonical Ordering) + +--- + +## Error Handling + +### Codec Errors + +| Error | Condition | +|-------|-----------| +| `UnexpectedEndOfInput` | Insufficient bytes to decode a field | +| `InvalidLength` | Trailing bytes after complete structure, or length mismatch | +| `InvalidVersion` | Protocol or kernel version does not match expected constant | +| `InputTooLarge` | `opaque_agent_inputs_len` > 64,000 | +| `ActionPayloadTooLarge` | `payload_len` > 16,384 | +| `TooManyActions` | `action_count` > 64 | +| `ActionTooLarge` | Individual action encoding > 16,424 bytes | +| `InvalidExecutionStatus` | Status byte is not 0x01 or 0x02 | +| `ArithmeticOverflow` | Length calculation (e.g., offset + field_len) would overflow | + +### Strict Decoding + +Decoders MUST reject: +- Inputs with trailing bytes beyond the expected structure size +- Unknown or unsupported version numbers (protocol_version or kernel_version) +- Out-of-range size values (exceeding defined maximums) +- Invalid enumeration values (execution_status not in {0x01, 0x02}) +- Any computation where `offset + field_len` would overflow `usize` + +--- + +## Constants + +| Constant | Value | Description | +|----------|-------|-------------| +| `PROTOCOL_VERSION` | 1 | Current protocol version | +| `KERNEL_VERSION` | 1 | Current kernel version | +| `MAX_AGENT_INPUT_BYTES` | 64,000 | Maximum agent input size (bytes) | +| `MAX_ACTION_PAYLOAD_BYTES` | 16,384 | Maximum action payload size (bytes) | +| `MAX_ACTIONS_PER_OUTPUT` | 64 | Maximum actions per output | +| `MAX_SINGLE_ACTION_BYTES` | 16,424 | Maximum encoded ActionV1 size (40 + 16,384) | +| `JOURNAL_SIZE` | 209 | Fixed KernelJournalV1 size (bytes) | + +**Note**: `MAX_AGENT_INPUT_BYTES` is 64,000 bytes, not 64 KiB (65,536). This is an intentional limit. + +--- + +## Test Vectors + +See `tests/vectors/` for golden test vectors including: +- `kernel_input_v1.json` - KernelInputV1 encoding vectors +- `kernel_journal_v1.json` - KernelJournalV1 encoding vectors + +Each vector file contains: +- Positive vectors with fields, encoded hex, and commitment hex +- Negative vectors with invalid encodings and expected errors diff --git a/docs/P0.3_DOCUMENTATION.md b/docs/P0.3_DOCUMENTATION.md new file mode 100644 index 0000000..fdb494b --- /dev/null +++ b/docs/P0.3_DOCUMENTATION.md @@ -0,0 +1,309 @@ +# P0.3: Constraint System Documentation + +## Overview + +P0.3 implements an unskippable, auditable constraint engine inside the zkVM guest that validates all agent-proposed actions against economic safety rules. If any rule is violated, execution provably fails and produces a Failure journal with an empty action commitment. + +## Architecture + +### Dataflow + +1. Host provides `KernelInputV1` containing constraint_set_hash, opaque_agent_inputs, etc. +2. Guest runs agent code → returns proposed `AgentOutput` +3. Guest runs constraint engine over proposed output: + - Validates constraint set invariants + - Validates every action + global invariants +4. If valid: + - Compute `action_commitment = SHA256(encoded AgentOutput)` + - Produce `KernelJournalV1 { execution_status = Success, ... }` +5. If invalid: + - Set `execution_status = Failure` + - Set `action_commitment` to empty output commitment + - Produce valid journal (proof still verifies, but verifier rejects state transition) + +### Module Structure + +``` +crates/ +├── constraints/ # Constraint engine implementation +│ └── src/lib.rs # enforce_constraints(), payload decoders, validation +├── kernel-guest/ # Kernel integration +│ └── src/lib.rs # kernel_main() calls constraints unconditionally +└── host-tests/ # Comprehensive test suite + └── src/lib.rs # 78+ tests including constraint validation +``` + +--- + +## Constraint Set (ConstraintSetV1) + +The constraint set defines economic safety parameters. For P0.3, constraints are embedded in the guest binary and referenced by `constraint_set_hash`. + +### Schema (60 bytes) + +| Offset | Field | Type | Description | +|--------|-------|------|-------------| +| 0 | version | u32 | Must be 1 | +| 4 | max_position_notional | u64 | Maximum position size | +| 12 | max_leverage_bps | u32 | Maximum leverage (basis points) | +| 16 | max_drawdown_bps | u32 | Maximum drawdown (basis points, ≤10000) | +| 20 | cooldown_seconds | u32 | Minimum seconds between executions | +| 24 | max_actions_per_output | u32 | Maximum actions per output (≤64) | +| 28 | allowed_asset_id | [u8; 32] | Single allowed asset (zero = all allowed) | + +### Invariant Validation (P0.3) + +The constraint set is validated before use: +- `version` must be 1 +- `max_actions_per_output` must be ≤ 64 (protocol maximum); may be 0 (rejects any non-empty output) +- `max_drawdown_bps` must be ≤ 10,000 (100%) +- `max_leverage_bps` may be 0 (only `leverage_bps == 0` passes) + +Invalid constraint sets return `InvalidConstraintSet` error. + +### Default Values + +```rust +ConstraintSetV1 { + version: 1, + max_position_notional: u64::MAX, // No limit + max_leverage_bps: 100_000, // 10x max + max_drawdown_bps: 10_000, // 100% (disabled) + cooldown_seconds: 0, // No cooldown + max_actions_per_output: 64, // Protocol max + allowed_asset_id: [0u8; 32], // All assets allowed +} +``` + +--- + +## Action Types + +### Supported Types (P0.3) + +| Code | Name | Payload Size | +|------|------|--------------| +| 0x00000001 | Echo | Any (no schema) | +| 0x00000002 | OpenPosition | 45 bytes (exact) | +| 0x00000003 | ClosePosition | 32 bytes (exact) | +| 0x00000004 | AdjustPosition | 44 bytes (exact) | +| 0x00000005 | Swap | 72 bytes (exact) | + +Unknown action types return `UnknownActionType` error. + +### Payload Schemas + +**Encoding:** All integer fields in action payloads use little-endian encoding. + +**P0.3 Strict Length Enforcement:** All payloads must be exactly the specified size. Trailing bytes are rejected to prevent encoding malleability. + +#### OpenPosition (45 bytes) +| Offset | Field | Type | Size | +|--------|-------|------|------| +| 0 | asset_id | [u8; 32] | 32 | +| 32 | notional | u64 | 8 | +| 40 | leverage_bps | u32 | 4 | +| 44 | direction | u8 | 1 | + +#### ClosePosition (32 bytes) +| Offset | Field | Type | Size | +|--------|-------|------|------| +| 0 | position_id | [u8; 32] | 32 | + +#### AdjustPosition (44 bytes) +| Offset | Field | Type | Size | +|--------|-------|------|------| +| 0 | position_id | [u8; 32] | 32 | +| 32 | new_notional | u64 | 8 | +| 40 | new_leverage_bps | u32 | 4 | + +#### Swap (72 bytes) +| Offset | Field | Type | Size | +|--------|-------|------|------| +| 0 | from_asset | [u8; 32] | 32 | +| 32 | to_asset | [u8; 32] | 32 | +| 64 | amount | u64 | 8 | + +--- + +## State Snapshot (StateSnapshotV1) + +Required for cooldown and drawdown constraints. Provided in `opaque_agent_inputs`. + +### Schema (36 bytes) + +| Offset | Field | Type | Size | +|--------|-------|------|------| +| 0 | snapshot_version | u32 | 4 | +| 4 | last_execution_ts | u64 | 8 | +| 12 | current_ts | u64 | 8 | +| 20 | current_equity | u64 | 8 | +| 28 | peak_equity | u64 | 8 | + +### Optionality + +**Missing Snapshot Definition:** A snapshot is considered missing if `opaque_agent_inputs.len() < 36` or if `snapshot_version != 1`. + +**Snapshot Present:** A snapshot is present when the snapshot prefix decodes successfully (`snapshot_version == 1` and `len >= 36`). + +Snapshot is optional unless cooldown or drawdown constraints are enabled: +``` +IF snapshot is missing AND (cooldown_seconds > 0 OR max_drawdown_bps < 10_000): + Violation: InvalidStateSnapshot +ELSE IF snapshot is missing: + snapshot is considered empty; global checks are skipped +``` + +--- + +## Constraint Rules + +### Evaluation Order (Deterministic) + +1. Validate constraint set version and invariants +2. Validate output structure (action count, payload sizes) +3. For each action (in order): + - Validate action type is known + - Decode and validate payload schema + - Check asset whitelist (if applicable) + - Check position size (if applicable; for AdjustPosition, only when `new_notional > 0`) + - Check leverage (if applicable; for AdjustPosition, only when `new_leverage_bps > 0`) +4. Validate global invariants (cooldown, drawdown) +5. First violation stops evaluation + +### Asset Whitelist + +``` +IF allowed_asset_id != [0; 32]: + REQUIRE: asset_id == allowed_asset_id (exact match) +``` + +P0.3 supports single-asset whitelist only. Zero means all assets allowed. + +### Cooldown + +``` +IF cooldown_seconds > 0: + required_ts = last_execution_ts + cooldown_seconds + IF overflow: InvalidStateSnapshot + REQUIRE: current_ts >= required_ts +``` + +**Overflow Protection:** `checked_add` is used to prevent malicious timestamps from bypassing cooldown via saturation. + +**Timestamp Arithmetic:** All timestamp arithmetic is performed in `u64`; overflow is treated as `InvalidStateSnapshot`. + +### Drawdown + +``` +IF max_drawdown_bps < 10000: + IF peak_equity == 0: InvalidStateSnapshot + drawdown = if current_equity >= peak_equity { 0 } else { peak_equity - current_equity } + drawdown_bps = drawdown * 10000 / peak_equity + REQUIRE: drawdown_bps <= max_drawdown_bps +``` + +**Drawdown Disabled Rule:** Drawdown checks are disabled if and only if `max_drawdown_bps == 10_000` (100%). + +When `current_equity >= peak_equity`, drawdown is defined as 0 (no drawdown). This prevents underflow and makes the rule deterministic. + +--- + +## Failure Semantics + +### On Constraint Violation + +1. `execution_status` = Failure (0x02) +2. `action_commitment` = SHA256([0x00, 0x00, 0x00, 0x00]) (empty output) +3. Valid `KernelJournalV1` is always produced +4. Proof still verifies, but verifiers/contracts should reject state transitions + +### Empty Output Commitment + +``` +df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119 +``` + +This is the SHA-256 hash of an empty `AgentOutput` (action_count = 0). + +### Violation Reason Codes + +| Code | Name | Description | +|------|------|-------------| +| 0x01 | InvalidOutputStructure | Too many actions or payload too large | +| 0x02 | UnknownActionType | Action type not recognized | +| 0x03 | AssetNotWhitelisted | Asset not in allowed list | +| 0x04 | PositionTooLarge | Position exceeds size limit | +| 0x05 | LeverageTooHigh | Leverage exceeds limit | +| 0x06 | DrawdownExceeded | Portfolio drawdown too high | +| 0x07 | CooldownNotElapsed | Too soon since last execution | +| 0x08 | InvalidStateSnapshot | Snapshot malformed or invalid | +| 0x09 | InvalidConstraintSet | Constraint configuration invalid | +| 0x0A | InvalidActionPayload | Payload doesn't match schema | + +--- + +## Target Field Limitation + +The `action.target` field is **not validated** by the constraint engine in P0.3. This field is passed through to executor contracts without constraint enforcement. + +Executor contracts are responsible for validating the target field according to their own rules. + +**Security Posture:** If the executor allows arbitrary calls based on `target`, then P0.3 constraints do not prevent malicious call targets. Operators must ensure executors implement appropriate target validation. + +--- + +## API Reference + +### Primary Function + +```rust +pub fn enforce_constraints( + input: &KernelInputV1, + proposed: &AgentOutput, + constraint_set: &ConstraintSetV1, +) -> Result +``` + +Returns the validated output (same as proposed) if all constraints pass, or a `ConstraintViolation` with reason code and optional action index. + +### Integration in kernel_main + +```rust +// 1. Execute agent +let proposed_output = agent.run(&input); + +// 2. Enforce constraints (MANDATORY) +let (validated_output, status) = match enforce_constraints(&input, &proposed_output, &constraints) { + Ok(output) => (output, ExecutionStatus::Success), + Err(_) => (AgentOutput { actions: vec![] }, ExecutionStatus::Failure), +}; + +// 3. Compute commitment over validated output +let action_commitment = compute_action_commitment(&validated_output.encode()?); + +// 4. Produce journal (always) +``` + +--- + +## Test Coverage + +82 tests covering: +- Payload trailing bytes rejection (all 4 types) +- Constraint set invariant validation +- Cooldown timestamp overflow protection +- Asset whitelist rejection (OpenPosition, Swap from/to) +- All violation reason codes +- Golden vectors for commitments +- Round-trip encoding/decoding + +See `crates/host-tests/src/lib.rs` and `tests/vectors/constraints/constraint_vectors.json`. + +--- + +## References + +- Full specification: `spec/constraints.md` +- Test vectors: `tests/vectors/constraints/` +- Implementation: `crates/constraints/src/lib.rs` diff --git a/spec/codec.md b/spec/codec.md index 5e8483f..1ffb1ec 100644 --- a/spec/codec.md +++ b/spec/codec.md @@ -1,207 +1,225 @@ -# Canonical Codec Specification +# Canonical Codec and Commitments -This document specifies the canonical binary encoding for the kernel protocol (version 1). -All encodings are deterministic and consensus-critical. +This document specifies the canonical binary encoding for all kernel protocol types. +All implementations MUST produce identical byte sequences for the same logical values. ## Design Principles -1. **Determinism**: Identical data structures always produce identical byte sequences -2. **Self-describing lengths**: Variable-length fields are prefixed with their byte length -3. **Little-endian integers**: All multi-byte integers use little-endian byte order -4. **No implicit padding**: Structures are tightly packed with no alignment padding -5. **Strict decoding**: Trailing bytes, invalid versions, and out-of-range values are rejected +1. **Deterministic**: Same logical value always encodes to identical bytes +2. **Self-describing lengths**: Variable-length fields use length prefixes +3. **Little-endian**: All multi-byte integers use little-endian byte order +4. **Strict decoding**: Trailing bytes and invalid values are rejected +5. **Bounded sizes**: All fields have explicit size limits --- -## Primitive Encoding Rules +## Primitive Types ### Integers -| Type | Encoding | Size | -|------|----------|------| -| `u32` | Little-endian | 4 bytes | -| `u64` | Little-endian | 8 bytes | -| `u8` | Raw byte | 1 byte | +| Type | Size | Encoding | +|------|------|----------| +| `u32` | 4 bytes | Little-endian | +| `u64` | 8 bytes | Little-endian | ### Fixed-Size Byte Arrays -| Type | Encoding | Size | -|------|----------|------| -| `[u8; 32]` | Raw bytes (no length prefix) | 32 bytes | +| Type | Size | Encoding | +|------|------|----------| +| `[u8; 32]` | 32 bytes | Raw bytes (no prefix) | ### Variable-Length Byte Arrays -| Type | Encoding | Size | -|------|----------|------| -| `Vec` | `[length: u32][data: u8*]` | 4 + length bytes | +``` +┌────────────┬────────────────┐ +│ length: u32│ data: [u8] │ +└────────────┴────────────────┘ +``` -The `length` field specifies the **number of bytes** in the data that follows. +- Length prefix is u32 little-endian +- Data follows immediately +- Maximum lengths are type-specific --- ## KernelInputV1 -### Layout - -| Offset | Field | Type | Size | Description | -|--------|-------|------|------|-------------| -| 0 | `protocol_version` | u32 | 4 | Wire format version | -| 4 | `kernel_version` | u32 | 4 | Kernel semantics version | -| 8 | `agent_id` | [u8; 32] | 32 | Agent identifier | -| 40 | `agent_code_hash` | [u8; 32] | 32 | SHA-256 of agent binary | -| 72 | `constraint_set_hash` | [u8; 32] | 32 | SHA-256 of constraint set | -| 104 | `input_root` | [u8; 32] | 32 | External state root (market/vault snapshot) | -| 136 | `execution_nonce` | u64 | 8 | Replay protection nonce | -| 144 | `opaque_agent_inputs_len` | u32 | 4 | Length of agent input data (bytes) | -| 148 | `opaque_agent_inputs` | [u8; *] | variable | Agent-specific input (max 64,000 bytes) | - -### Size +Total size: 148 + `opaque_agent_inputs.len()` bytes -- **Fixed header**: 148 bytes -- **Total**: 148 + `opaque_agent_inputs_len` bytes -- **Minimum**: 148 bytes (empty input) -- **Maximum**: 148 + 64,000 = 64,148 bytes +``` +Offset │ Field │ Type │ Size +───────┼───────────────────────┼───────────┼────── +0 │ protocol_version │ u32 │ 4 +4 │ kernel_version │ u32 │ 4 +8 │ agent_id │ [u8; 32] │ 32 +40 │ agent_code_hash │ [u8; 32] │ 32 +72 │ constraint_set_hash │ [u8; 32] │ 32 +104 │ input_root │ [u8; 32] │ 32 +136 │ execution_nonce │ u64 │ 8 +144 │ opaque_agent_inputs │ Vec │ 4 + len +``` -### Validation Rules +### Validation Rules (Decode) -1. `protocol_version` MUST equal `PROTOCOL_VERSION` (currently 1) -2. `kernel_version` MUST equal `KERNEL_VERSION` (currently 1) -3. `opaque_agent_inputs_len` MUST NOT exceed `MAX_AGENT_INPUT_BYTES` (64,000) -4. Total bytes MUST equal exactly 148 + `opaque_agent_inputs_len` (no trailing bytes) -5. Decoders MUST reject if `148 + opaque_agent_inputs_len` would overflow +1. `protocol_version` MUST equal `PROTOCOL_VERSION` (1) +2. `kernel_version` MUST equal `KERNEL_VERSION` (1) +3. `opaque_agent_inputs.len()` MUST NOT exceed `MAX_AGENT_INPUT_BYTES` (64,000) +4. Total bytes consumed MUST equal input length (no trailing bytes) --- ## KernelJournalV1 -### Layout - -| Offset | Field | Type | Size | Description | -|--------|-------|------|------|-------------| -| 0 | `protocol_version` | u32 | 4 | Wire format version | -| 4 | `kernel_version` | u32 | 4 | Kernel semantics version | -| 8 | `agent_id` | [u8; 32] | 32 | Agent identifier (copied from input) | -| 40 | `agent_code_hash` | [u8; 32] | 32 | Agent code hash (copied from input) | -| 72 | `constraint_set_hash` | [u8; 32] | 32 | Constraint set hash (copied from input) | -| 104 | `input_root` | [u8; 32] | 32 | External state root (copied from input) | -| 136 | `execution_nonce` | u64 | 8 | Execution nonce (copied from input) | -| 144 | `input_commitment` | [u8; 32] | 32 | SHA-256 of encoded KernelInputV1 | -| 176 | `action_commitment` | [u8; 32] | 32 | SHA-256 of encoded AgentOutput | -| 208 | `execution_status` | u8 | 1 | Execution result | +Fixed size: 209 bytes -### Size - -- **Fixed**: 209 bytes (always) +``` +Offset │ Field │ Type │ Size +───────┼───────────────────────┼─────────────────┼────── +0 │ protocol_version │ u32 │ 4 +4 │ kernel_version │ u32 │ 4 +8 │ agent_id │ [u8; 32] │ 32 +40 │ agent_code_hash │ [u8; 32] │ 32 +72 │ constraint_set_hash │ [u8; 32] │ 32 +104 │ input_root │ [u8; 32] │ 32 +136 │ execution_nonce │ u64 │ 8 +144 │ input_commitment │ [u8; 32] │ 32 +176 │ action_commitment │ [u8; 32] │ 32 +208 │ execution_status │ ExecutionStatus │ 1 +``` -### Validation Rules +### Validation Rules (Decode) -1. `protocol_version` MUST equal `PROTOCOL_VERSION` (currently 1) -2. `kernel_version` MUST equal `KERNEL_VERSION` (currently 1) -3. `execution_status` MUST equal 0x01 (Success) -4. Total bytes MUST equal exactly 209 (no more, no less) +1. `protocol_version` MUST equal `PROTOCOL_VERSION` (1) +2. `kernel_version` MUST equal `KERNEL_VERSION` (1) +3. `execution_status` MUST be valid (0x01 or 0x02) +4. Total bytes MUST equal 209 (no trailing bytes) --- ## ExecutionStatus -### Encoding +Single byte encoding: -| Value | Status | Description | -|-------|--------|-------------| -| 0x00 | Reserved | Invalid (catches uninitialized memory) | -| 0x01 | Success | Execution completed successfully | -| 0x02-0xFF | Reserved | Invalid (reserved for future expansion) | +| Value | Name | Description | +|-------|------|-------------| +| `0x00` | Reserved | Invalid (catches uninitialized memory) | +| `0x01` | `Success` | Execution completed and all constraints passed | +| `0x02` | `Failure` | Execution completed but constraints violated | +| `0x03-0xFF` | Reserved | Reserved for future use | ### Rationale -In protocol version 1, the journal is **only published for successful execution**. Failures cause the kernel to abort before committing a journal. Therefore, only 0x01 (Success) is a valid value. The value 0x00 is explicitly reserved to detect bugs where uninitialized memory is accidentally interpreted as a valid status. +- `0x00` is deliberately invalid to catch uninitialized memory bugs +- `0x01` for Success follows boolean conventions (1 = true = success) +- `0x02` for Failure distinguishes constraint violations from panics/aborts -Decoders MUST reject any value other than 0x01. +### P0.3 Failure Semantics + +When `execution_status == Failure`: +- `action_commitment` is computed over an **empty AgentOutput** `{ actions: [] }` +- The empty output encodes to `[0x00, 0x00, 0x00, 0x00]` +- `action_commitment = SHA-256([0x00, 0x00, 0x00, 0x00])` = `df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119` --- ## ActionV1 -### Layout - -| Offset | Field | Type | Size | Description | -|--------|-------|------|------|-------------| -| 0 | `action_type` | u32 | 4 | Action type identifier | -| 4 | `target` | [u8; 32] | 32 | Target address/identifier | -| 36 | `payload_len` | u32 | 4 | Length of payload data (bytes) | -| 40 | `payload` | [u8; *] | variable | Action-specific data (max 16,384 bytes) | +Variable size: 40 + `payload.len()` bytes -### Size +``` +Offset │ Field │ Type │ Size +───────┼──────────────┼──────────┼────── +0 │ action_type │ u32 │ 4 +4 │ target │ [u8; 32] │ 32 +36 │ payload │ Vec │ 4 + len +``` -- **Fixed header**: 40 bytes -- **Total**: 40 + `payload_len` bytes -- **Minimum**: 40 bytes (empty payload) -- **Maximum**: 40 + 16,384 = 16,424 bytes (`MAX_SINGLE_ACTION_BYTES`) +### Validation Rules (Decode) -### Validation Rules +1. `payload.len()` MUST NOT exceed `MAX_ACTION_PAYLOAD_BYTES` (16,384) +2. Total bytes consumed MUST equal input length (no trailing bytes) -1. `payload_len` MUST NOT exceed `MAX_ACTION_PAYLOAD_BYTES` (16,384) -2. Total bytes MUST equal exactly 40 + `payload_len` (no trailing bytes) -3. When embedded in AgentOutput, the `action_len` prefix MUST exactly equal the actual byte length of the ActionV1 encoding (i.e., `action_len == 40 + payload_len`) +### Canonical Ordering ---- +Actions are sorted before encoding for determinism: -## AgentOutput +1. Primary: `action_type` (ascending) +2. Secondary: `target` (lexicographic) +3. Tertiary: `payload` (lexicographic) -### Layout +Note: `payload_len` is NOT part of the sort key (it's derivable from payload). -| Offset | Field | Type | Size | Description | -|--------|-------|------|------|-------------| -| 0 | `action_count` | u32 | 4 | Number of actions | -| 4 | actions[0..n] | ActionV1[] | variable | Length-prefixed actions | +--- -Each action is encoded as: -- `action_len: u32` (4 bytes) - Byte length of the following ActionV1 encoding -- `action: ActionV1` (variable) - The encoded action +## AgentOutput -### Size +Variable size: 4 + sum of encoded action sizes -- **Minimum**: 4 bytes (zero actions) -- **Maximum**: computed as follows: - - Per-action overhead: `action_len` prefix (4) + `MAX_SINGLE_ACTION_BYTES` (16,424) = 16,428 bytes - - Total: 4 + `MAX_ACTIONS_PER_OUTPUT` × 16,428 = 4 + 64 × 16,428 = **1,051,396 bytes** +``` +┌───────────────┬──────────────────────────────────┐ +│ action_count │ actions[0..action_count] │ +│ (u32) │ (length-prefixed ActionV1 list) │ +└───────────────┴──────────────────────────────────┘ +``` -### Validation Rules +Each action is prefixed with its encoded length: +``` +┌────────────┬──────────────────┐ +│ action_len │ ActionV1 bytes │ +│ (u32) │ (action_len) │ +└────────────┴──────────────────┘ +``` -1. `action_count` MUST NOT exceed `MAX_ACTIONS_PER_OUTPUT` (64) -2. Each `action_len` MUST NOT exceed `MAX_SINGLE_ACTION_BYTES` (16,424) -3. Each `action_len` MUST exactly equal the number of bytes consumed by the following ActionV1 encoding -4. Exactly `action_count` actions MUST be present; fewer bytes implies `UnexpectedEndOfInput`, more bytes implies `InvalidLength` -5. Total bytes MUST equal the sum of all prefixes and action encodings (no trailing bytes) +### Size Limits ---- +- `action_count` MUST NOT exceed `MAX_ACTIONS_PER_OUTPUT` (64) +- Each `action_len` MUST NOT exceed `MAX_SINGLE_ACTION_BYTES` (16,424) -## Canonical Ordering +### Maximum Encoded Size -Actions MUST be sorted into canonical order before encoding. This ensures deterministic `action_commitment` regardless of the order agents produce actions. +``` +max_size = 4 + 64 * (4 + 4 + 32 + 4 + 16384) + = 4 + 64 * 16428 + = 1,051,396 bytes (~1 MB) +``` -### Ordering Rules +### Canonicalization -Actions are sorted using lexicographic comparison in this priority: +The `encode()` method automatically sorts actions into canonical order. +This ensures identical outputs regardless of the order actions were added. -1. `action_type` (ascending, unsigned integer comparison) -2. `target` (lexicographic byte comparison, 32 bytes) -3. `payload` (lexicographic byte comparison of raw payload bytes) +--- -**Important**: The `payload_len` field is **not** part of the sort key; only the raw payload bytes are compared. Actions with identical `action_type`, `target`, and `payload` bytes are considered equal regardless of encoding. +## Constants -### Example +| Constant | Value | Description | +|----------|-------|-------------| +| `PROTOCOL_VERSION` | 1 | Current protocol version | +| `KERNEL_VERSION` | 1 | Current kernel version | +| `MAX_AGENT_INPUT_BYTES` | 64,000 | Maximum opaque_agent_inputs size | +| `MAX_ACTIONS_PER_OUTPUT` | 64 | Maximum actions per output | +| `MAX_ACTION_PAYLOAD_BYTES` | 16,384 | Maximum payload per action | +| `MAX_SINGLE_ACTION_BYTES` | 16,424 | Maximum encoded action size | +| `EMPTY_OUTPUT_COMMITMENT` | `df3f61...` | SHA-256 of empty AgentOutput | -Given actions: -- A: `{type: 2, target: 0x11..., payload: [1]}` -- B: `{type: 1, target: 0x22..., payload: [2]}` -- C: `{type: 1, target: 0x11..., payload: [3]}` +--- -Canonical order: **C, B, A** +## Error Handling -Reasoning: -1. C and B have `action_type=1`, A has `action_type=2` → A comes last -2. Between C and B: C has `target=0x11...`, B has `target=0x22...` → C comes first (0x11 < 0x22) +### CodecError Variants + +| Variant | Description | +|---------|-------------| +| `InvalidLength` | Trailing bytes after valid data | +| `InvalidVersion { expected, actual }` | Version mismatch | +| `InputTooLarge { size, limit }` | opaque_agent_inputs exceeds limit | +| `OutputTooLarge { size, limit }` | Output exceeds size limit | +| `UnexpectedEndOfInput` | Insufficient bytes for decoding | +| `InvalidExecutionStatus(u8)` | Invalid status byte | +| `ArithmeticOverflow` | Integer overflow during size calculation | +| `TooManyActions { count, limit }` | Action count exceeds limit | +| `ActionPayloadTooLarge { size, limit }` | Payload exceeds limit | +| `ActionTooLarge { size, limit }` | Encoded action exceeds limit | --- @@ -210,76 +228,36 @@ Reasoning: ### Input Commitment ``` -input_commitment = SHA-256(encoded_KernelInputV1) +input_commitment = SHA-256(encoded_kernel_input_v1) ``` -The commitment is computed over the **complete canonical encoding** of KernelInputV1, including: -- All fixed header fields (148 bytes) -- The `opaque_agent_inputs_len` length prefix (4 bytes) -- The `opaque_agent_inputs` data bytes +The commitment is computed over the entire encoded KernelInputV1 bytes. ### Action Commitment ``` -action_commitment = SHA-256(encoded_AgentOutput) +action_commitment = SHA-256(encoded_agent_output) ``` -The commitment is computed over the **complete canonical encoding** of AgentOutput, including: -- The `action_count` field (4 bytes) -- All `action_len` prefixes and ActionV1 encodings -- Actions MUST be in canonical order (see Canonical Ordering) - ---- - -## Error Handling - -### Codec Errors - -| Error | Condition | -|-------|-----------| -| `UnexpectedEndOfInput` | Insufficient bytes to decode a field | -| `InvalidLength` | Trailing bytes after complete structure, or length mismatch | -| `InvalidVersion` | Protocol or kernel version does not match expected constant | -| `InputTooLarge` | `opaque_agent_inputs_len` > 64,000 | -| `ActionPayloadTooLarge` | `payload_len` > 16,384 | -| `TooManyActions` | `action_count` > 64 | -| `ActionTooLarge` | Individual action encoding > 16,424 bytes | -| `InvalidExecutionStatus` | Status byte is not 0x01 | -| `ArithmeticOverflow` | Length calculation (e.g., offset + field_len) would overflow | - -### Strict Decoding - -Decoders MUST reject: -- Inputs with trailing bytes beyond the expected structure size -- Unknown or unsupported version numbers (protocol_version or kernel_version) -- Out-of-range size values (exceeding defined maximums) -- Invalid enumeration values (execution_status ≠ 0x01) -- Any computation where `offset + field_len` would overflow `usize` +The commitment is computed over the canonicalized, encoded AgentOutput. ---- - -## Constants - -| Constant | Value | Description | -|----------|-------|-------------| -| `PROTOCOL_VERSION` | 1 | Current protocol version | -| `KERNEL_VERSION` | 1 | Current kernel version | -| `MAX_AGENT_INPUT_BYTES` | 64,000 | Maximum agent input size (bytes) | -| `MAX_ACTION_PAYLOAD_BYTES` | 16,384 | Maximum action payload size (bytes) | -| `MAX_ACTIONS_PER_OUTPUT` | 64 | Maximum actions per output | -| `MAX_SINGLE_ACTION_BYTES` | 16,424 | Maximum encoded ActionV1 size (40 + 16,384) | -| `JOURNAL_SIZE` | 209 | Fixed KernelJournalV1 size (bytes) | - -**Note**: `MAX_AGENT_INPUT_BYTES` is 64,000 bytes, not 64 KiB (65,536). This is an intentional limit. +On constraint failure (P0.3): +``` +empty_output = AgentOutput { actions: [] } +encoded = [0x00, 0x00, 0x00, 0x00] +action_commitment = SHA-256(encoded) = df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119 +``` --- ## Test Vectors -See `tests/vectors/` for golden test vectors including: +Golden test vectors are available in `tests/vectors/`: - `kernel_input_v1.json` - KernelInputV1 encoding vectors - `kernel_journal_v1.json` - KernelJournalV1 encoding vectors +- `constraints/constraint_vectors.json` - Constraint enforcement vectors -Each vector file contains: -- Positive vectors with fields, encoded hex, and commitment hex -- Negative vectors with invalid encodings and expected errors +Each vector includes: +- Encoded hex bytes +- Decoded field values +- SHA-256 commitment (where applicable) diff --git a/spec/constraints.md b/spec/constraints.md new file mode 100644 index 0000000..9b4c952 --- /dev/null +++ b/spec/constraints.md @@ -0,0 +1,464 @@ +# Constraint System Specification + +This document specifies the constraint enforcement system for the kernel protocol (P0.3). +Constraints are enforced inside the guest (zkVM) and are unskippable. + +## Overview + +The constraint system validates agent-proposed actions against economic safety rules before commitment. Key properties: + +1. **Unskippable**: Constraints are enforced after every agent execution, unconditionally +2. **Deterministic**: Same inputs always produce same validation results +3. **Provable**: Constraint violations result in a valid proof with Failure status +4. **Auditable**: Clear error codes identify which constraint was violated + +--- + +## Failure Semantics + +When a constraint is violated: + +1. `execution_status` is set to `Failure` (0x02) +2. `action_commitment` is computed over an **empty AgentOutput** (zero actions) +3. A valid `KernelJournalV1` is always produced +4. The proof is still valid, but verifiers/contracts should reject state transitions + +This design ensures: +- Constraint violations are provable and verifiable on-chain +- No ambiguity between "success with zero actions" and "constraint failure" +- Host and prover behavior is consistent + +--- + +## Action Types + +### Supported Action Types (P0.3) + +| Code | Name | Description | +|------|------|-------------| +| `0x00000001` | `Echo` | Echo/test action (TrivialAgent) | +| `0x00000002` | `OpenPosition` | Open a new trading position | +| `0x00000003` | `ClosePosition` | Close an existing position | +| `0x00000004` | `AdjustPosition` | Modify position size or leverage | +| `0x00000005` | `Swap` | Asset swap/exchange | + +Any action type not in this list is **invalid** and causes a constraint violation. + +### Action Payload Schemas + +**Encoding:** All integer fields in action payloads use little-endian encoding, consistent with the kernel codec specification. + +#### Echo (0x00000001) + +No schema enforcement. Payload is opaque bytes. + +#### OpenPosition (0x00000002) + +``` +Offset | Field | Type | Size | Description +-------|---------------|-----------|------|------------- +0 | asset_id | [u8; 32] | 32 | Asset identifier +32 | notional | u64 | 8 | Position size in base units +40 | leverage_bps | u32 | 4 | Leverage in basis points (10000 = 1x) +44 | direction | u8 | 1 | 0 = Long, 1 = Short +``` + +**Total: 45 bytes (exact)** + +P0.3 requires exact payload length. Trailing bytes are rejected to prevent encoding malleability. + +#### ClosePosition (0x00000003) + +``` +Offset | Field | Type | Size | Description +-------|---------------|-----------|------|------------- +0 | position_id | [u8; 32] | 32 | Position identifier to close +``` + +**Total: 32 bytes (exact)** + +P0.3 requires exact payload length. Trailing bytes are rejected to prevent encoding malleability. + +#### AdjustPosition (0x00000004) + +``` +Offset | Field | Type | Size | Description +-------|---------------|-----------|------|------------- +0 | position_id | [u8; 32] | 32 | Position identifier +32 | new_notional | u64 | 8 | New position size (0 = unchanged) +40 | new_leverage | u32 | 4 | New leverage in bps (0 = unchanged) +``` + +**Total: 44 bytes (exact)** + +P0.3 requires exact payload length. Trailing bytes are rejected to prevent encoding malleability. + +#### Swap (0x00000005) + +``` +Offset | Field | Type | Size | Description +-------|---------------|-----------|------|------------- +0 | from_asset | [u8; 32] | 32 | Source asset identifier +32 | to_asset | [u8; 32] | 32 | Destination asset identifier +64 | amount | u64 | 8 | Amount to swap +``` + +**Total: 72 bytes (exact)** + +P0.3 requires exact payload length. Trailing bytes are rejected to prevent encoding malleability. + +--- + +## Constraint Set + +### ConstraintSetV1 Schema + +The constraint set defines the economic safety parameters. For P0.3, constraints are embedded in the guest binary and referenced by `constraint_set_hash`. + +``` +Offset | Field | Type | Size | Description +-------|-------------------------|-----------|------|------------- +0 | version | u32 | 4 | Must be 1 +4 | max_position_notional | u64 | 8 | Maximum position size +12 | max_leverage_bps | u32 | 4 | Maximum leverage (basis points) +16 | max_drawdown_bps | u32 | 4 | Maximum drawdown (basis points) +20 | cooldown_seconds | u32 | 4 | Minimum seconds between executions +24 | max_actions_per_output | u32 | 4 | Maximum actions per output +28 | allowed_asset_id | [u8; 32] | 32 | Single allowed asset ID (P0.3) +``` + +Total: 60 bytes + +### Constraint Set Validation (P0.3) + +The following invariants are validated: +- `version` must be 1 +- `max_actions_per_output` must be ≤ 64 (protocol maximum); may be 0 (rejects any non-empty output) +- `max_drawdown_bps` must be ≤ 10,000 (100%) +- `max_leverage_bps` may be 0 (only `leverage_bps == 0` passes) +- `cooldown_seconds` has no upper bound (operator choice) + +### Default Constraint Set (P0.3) + +For P0.3, a permissive default constraint set is used: + +```rust +ConstraintSetV1 { + version: 1, + max_position_notional: u64::MAX, // No position size limit + max_leverage_bps: 100_000, // 10x max leverage + max_drawdown_bps: 10_000, // 100% drawdown allowed (disabled) + cooldown_seconds: 0, // No cooldown + max_actions_per_output: 64, // Match protocol max + allowed_asset_id: [0u8; 32], // Zero = all assets allowed +} +``` + +--- + +## State Snapshot + +To enforce cooldown and drawdown constraints, the guest requires a state snapshot. This is provided in the `opaque_agent_inputs` field with the following prefix structure: + +### StateSnapshotV1 Schema + +``` +Offset | Field | Type | Size | Description +-------|----------------------|-----------|------|------------- +0 | snapshot_version | u32 | 4 | Must be 1 +4 | last_execution_ts | u64 | 8 | Timestamp of last execution +12 | current_ts | u64 | 8 | Current timestamp (from input) +20 | current_equity | u64 | 8 | Current portfolio equity +28 | peak_equity | u64 | 8 | Peak portfolio equity +``` + +Total: 36 bytes + +**Snapshot Prefix Rule:** The snapshot is decoded from the first 36 bytes of `opaque_agent_inputs`. Any trailing bytes are agent-specific data and are ignored by the constraint engine. This allows agents to pass additional state through `opaque_agent_inputs` without affecting constraint validation. + +### Snapshot Parsing Rules + +``` +IF snapshot is missing AND (constraint_set.cooldown_seconds > 0 OR constraint_set.max_drawdown_bps < 10_000): + Violation: InvalidStateSnapshot (0x08) +ELSE IF snapshot is missing: + snapshot is considered empty; global checks are skipped +``` + +**Rationale:** When cooldown or drawdown constraints are enabled, they are safety-critical. Allowing missing snapshots would bypass these protections. + +**Snapshot Optionality (P0.3):** Snapshot is optional unless cooldown or drawdown constraints are enabled. Malformed snapshots with wrong version are treated as missing. This means a wrong-version snapshot combined with disabled cooldown/drawdown will pass validation. + +**Missing Snapshot Definition:** A snapshot is considered missing if `opaque_agent_inputs.len() < 36` or if `snapshot_version != 1`. + +--- + +## Constraint Rules + +### Evaluation Order + +Constraints are evaluated in the following deterministic order: + +1. **Output structure validation** + - `action_count` <= `max_actions_per_output` + - Each action payload size <= `MAX_ACTION_PAYLOAD_BYTES` + +2. **Per-action validation** (for each action in order) + - Action type must be known/supported + - Payload must match expected schema for action type + - Asset whitelist check (if applicable) + - Position size check (if applicable) + - Leverage check (if applicable) + +3. **Global invariants** + - Cooldown check (if `cooldown_seconds > 0`; missing snapshot → `InvalidStateSnapshot`) + - Drawdown check (if `max_drawdown_bps < 10_000`; missing snapshot → `InvalidStateSnapshot`) + +Evaluation stops at the first violation. + +### Rule Details + +#### Output Structure (Rule 1) + +``` +REQUIRE: output.actions.len() <= constraint_set.max_actions_per_output +REQUIRE: for all actions: action.payload.len() <= MAX_ACTION_PAYLOAD_BYTES +``` + +Violation: `InvalidOutputStructure` (0x01) + +#### Unknown Action Type (Rule 2a) + +``` +REQUIRE: action.action_type IN supported_action_types +``` + +Violation: `UnknownActionType` (0x02) + +#### Asset Whitelist (Rule 2b) + +``` +IF constraint_set.allowed_asset_id != [0; 32]: + REQUIRE: asset_id == allowed_asset_id (exact match) +``` + +Violation: `AssetNotWhitelisted` (0x03) + +**P0.3 Semantics:** Single-asset whitelist via exact ID match. +- If `allowed_asset_id == [0; 32]`, all assets are allowed +- If `allowed_asset_id != [0; 32]`, only the exact matching asset_id is allowed + +Future versions may support multi-asset whitelists via Merkle proofs. + +#### Position Size (Rule 2c) + +For OpenPosition and AdjustPosition: + +``` +REQUIRE: notional <= constraint_set.max_position_notional +``` + +Violation: `PositionTooLarge` (0x04) + +**AdjustPosition Semantics:** For AdjustPosition, position size checks apply only when `new_notional > 0`. A value of 0 means "unchanged" and bypasses the check. + +#### Leverage (Rule 2d) + +For OpenPosition and AdjustPosition: + +``` +REQUIRE: leverage_bps <= constraint_set.max_leverage_bps +``` + +Violation: `LeverageTooHigh` (0x05) + +**AdjustPosition Semantics:** For AdjustPosition, leverage checks apply only when `new_leverage_bps > 0`. A value of 0 means "unchanged" and bypasses the check. + +#### Drawdown (Rule 3a) + +``` +IF constraint_set.max_drawdown_bps < 10_000: + IF state_snapshot.peak_equity == 0: + Violation: InvalidStateSnapshot (0x08) + + # Handle equity growth (current > peak) as 0 drawdown + drawdown = if current_equity >= peak_equity { 0 } else { peak_equity - current_equity } + drawdown_bps = drawdown * 10000 / peak_equity + + REQUIRE: drawdown_bps <= constraint_set.max_drawdown_bps +``` + +Violation: `DrawdownExceeded` (0x06) + +**Drawdown Disabled Rule:** Drawdown checks are disabled if and only if `max_drawdown_bps == 10_000` (100%). Any value less than 10,000 enables drawdown enforcement. This is consistent with the snapshot parsing rules above. + +**Note:** When `current_equity >= peak_equity`, drawdown is defined as 0 (no drawdown). This prevents underflow and makes the rule deterministic. + +#### Cooldown (Rule 3b) + +**Snapshot Present:** `state_snapshot is present` means the snapshot prefix decodes successfully (`snapshot_version == 1` and `opaque_agent_inputs.len() >= 36`). + +``` +IF constraint_set.cooldown_seconds > 0 AND state_snapshot is present: + required_ts = last_execution_ts + cooldown_seconds + IF required_ts overflows (u64): + Violation: InvalidStateSnapshot (0x08) + REQUIRE: current_ts >= required_ts +``` + +Violation: `CooldownNotElapsed` (0x07) + +**Overflow Protection (P0.3):** If `last_execution_ts + cooldown_seconds` overflows, the snapshot is considered invalid. This prevents maliciously large timestamp values from bypassing cooldown checks via saturation. + +**Timestamp Arithmetic:** All timestamp arithmetic is performed in `u64`; overflow is treated as `InvalidStateSnapshot`. + +--- + +## Violation Reason Codes + +| Code | Name | Description | +|------|------|-------------| +| 0x01 | `InvalidOutputStructure` | Too many actions or payload too large | +| 0x02 | `UnknownActionType` | Action type not recognized | +| 0x03 | `AssetNotWhitelisted` | Asset not in allowed list | +| 0x04 | `PositionTooLarge` | Position exceeds size limit | +| 0x05 | `LeverageTooHigh` | Leverage exceeds limit | +| 0x06 | `DrawdownExceeded` | Portfolio drawdown too high | +| 0x07 | `CooldownNotElapsed` | Too soon since last execution | +| 0x08 | `InvalidStateSnapshot` | Snapshot malformed or invalid | +| 0x09 | `InvalidConstraintSet` | Constraint configuration invalid | +| 0x0A | `InvalidActionPayload` | Payload doesn't match schema | + +--- + +## Empty Output Commitment + +On constraint failure, the `action_commitment` is computed over an empty `AgentOutput`: + +``` +empty_output = AgentOutput { actions: vec![] } +empty_encoded = encode(empty_output) // = [0x00, 0x00, 0x00, 0x00] (action_count = 0) +action_commitment = SHA-256(empty_encoded) +``` + +The constant empty output commitment is: +``` +df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119 +``` + +This is the SHA-256 hash of `[0x00, 0x00, 0x00, 0x00]`. + +--- + +## Target Field (P0.3 Limitation) + +The `action.target` field is **not validated** by the constraint engine in P0.3. This field is passed through to executor contracts without any constraint enforcement. + +**Security Note:** Executor contracts are responsible for validating the `target` field according to their own rules. The constraint system does not restrict which targets can be called. + +**Security Posture:** If the executor allows arbitrary calls based on `target`, then P0.3 constraints do not prevent malicious call targets. Operators must ensure executors implement appropriate target validation. + +Future versions may add per-action-type target validation (e.g., only allowing specific contract addresses for swaps). + +--- + +## Determinism Requirements + +The constraint engine MUST be fully deterministic: + +- **No host time**: Use `current_ts` from state snapshot +- **No randomness**: All decisions based on input data only +- **No floating point**: Use integer arithmetic with explicit rounding +- **Bounded iteration**: All loops have fixed bounds +- **Stable ordering**: Evaluate actions in input order + +--- + +## Test Vector Format + +Test vectors are JSON files in `tests/vectors/constraints/`: + +```json +{ + "name": "test_case_name", + "description": "Human-readable description", + "constraint_set": { + "version": 1, + "max_position_notional": 1000000, + "max_leverage_bps": 50000, + "max_drawdown_bps": 2000, + "cooldown_seconds": 60, + "max_actions_per_output": 64, + "allowed_asset_id": "0000...0000" + }, + "state_snapshot": { + "snapshot_version": 1, + "last_execution_ts": 1000, + "current_ts": 1100, + "current_equity": 100000, + "peak_equity": 100000 + }, + "proposed_actions": [ + { + "action_type": 2, + "target": "1111...1111", + "payload_hex": "..." + } + ], + "expected": { + "status": "Success", + "action_commitment": "...", + "violation_reason": null + } +} +``` + +For failure cases: + +```json +{ + "expected": { + "status": "Failure", + "action_commitment": "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119", + "violation_reason": "LeverageTooHigh", + "violation_action_index": 0 + } +} +``` + +--- + +## Integration with Kernel + +### kernel_main Flow (P0.3) + +``` +1. Decode KernelInputV1 +2. Validate versions +3. Compute input_commitment +4. Execute agent → proposed_output +5. Enforce constraints: + IF enforce_constraints(input, proposed_output) == Ok(validated): + output = validated + status = Success + ELSE: + output = AgentOutput { actions: [] } + status = Failure +6. Compute action_commitment over output +7. Construct and return KernelJournalV1 +``` + +The journal is **always** produced, even on constraint failure. + +--- + +## Constants + +| Constant | Value | Description | +|----------|-------|-------------| +| `ACTION_TYPE_ECHO` | 0x00000001 | Echo/test action | +| `ACTION_TYPE_OPEN_POSITION` | 0x00000002 | Open position | +| `ACTION_TYPE_CLOSE_POSITION` | 0x00000003 | Close position | +| `ACTION_TYPE_ADJUST_POSITION` | 0x00000004 | Adjust position | +| `ACTION_TYPE_SWAP` | 0x00000005 | Asset swap | +| `EMPTY_OUTPUT_COMMITMENT` | `df3f61...` | SHA-256 of empty output | diff --git a/tests/vectors/constraints/constraint_vectors.json b/tests/vectors/constraints/constraint_vectors.json new file mode 100644 index 0000000..9ccbdfe --- /dev/null +++ b/tests/vectors/constraints/constraint_vectors.json @@ -0,0 +1,379 @@ +{ + "description": "Test vectors for P0.3 constraint enforcement", + "version": 1, + "vectors": [ + { + "name": "echo_action_valid", + "description": "Echo action should always pass with default constraints", + "constraint_set": { + "version": 1, + "max_position_notional": "18446744073709551615", + "max_leverage_bps": 100000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 0, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": null, + "proposed_actions": [ + { + "action_type": 1, + "target": "1111111111111111111111111111111111111111111111111111111111111111", + "payload_hex": "010203" + } + ], + "expected": { + "status": "Success", + "violation_reason": null, + "violation_action_index": null + } + }, + { + "name": "open_position_valid", + "description": "Valid OpenPosition action within all limits", + "constraint_set": { + "version": 1, + "max_position_notional": "1000000", + "max_leverage_bps": 50000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 0, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": null, + "proposed_actions": [ + { + "action_type": 2, + "target": "2222222222222222222222222222222222222222222222222222222222222222", + "payload_hex": "4242424242424242424242424242424242424242424242424242424242424242e80300000000000010270000000000" + } + ], + "expected": { + "status": "Success", + "violation_reason": null, + "violation_action_index": null + }, + "notes": "asset_id=0x42*32, notional=1000, leverage_bps=10000 (1x), direction=0 (long)" + }, + { + "name": "unknown_action_type", + "description": "Unknown action type should fail with UnknownActionType", + "constraint_set": { + "version": 1, + "max_position_notional": "18446744073709551615", + "max_leverage_bps": 100000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 0, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": null, + "proposed_actions": [ + { + "action_type": 255, + "target": "1111111111111111111111111111111111111111111111111111111111111111", + "payload_hex": "" + } + ], + "expected": { + "status": "Failure", + "action_commitment": "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119", + "violation_reason": "UnknownActionType", + "violation_reason_code": 2, + "violation_action_index": 0 + } + }, + { + "name": "position_too_large", + "description": "Position size exceeds max_position_notional", + "constraint_set": { + "version": 1, + "max_position_notional": "1000000", + "max_leverage_bps": 100000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 0, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": null, + "proposed_actions": [ + { + "action_type": 2, + "target": "2222222222222222222222222222222222222222222222222222222222222222", + "payload_hex": "424242424242424242424242424242424242424242424242424242424242424241420f000000000010270000000000" + } + ], + "expected": { + "status": "Failure", + "action_commitment": "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119", + "violation_reason": "PositionTooLarge", + "violation_reason_code": 4, + "violation_action_index": 0 + }, + "notes": "notional=1000001 exceeds max 1000000" + }, + { + "name": "leverage_too_high", + "description": "Leverage exceeds max_leverage_bps", + "constraint_set": { + "version": 1, + "max_position_notional": "18446744073709551615", + "max_leverage_bps": 50000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 0, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": null, + "proposed_actions": [ + { + "action_type": 2, + "target": "2222222222222222222222222222222222222222222222222222222222222222", + "payload_hex": "4242424242424242424242424242424242424242424242424242424242424242e803000000000000e0930100000000" + } + ], + "expected": { + "status": "Failure", + "action_commitment": "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119", + "violation_reason": "LeverageTooHigh", + "violation_reason_code": 5, + "violation_action_index": 0 + }, + "notes": "leverage_bps=100000 (10x) exceeds max 50000 (5x)" + }, + { + "name": "cooldown_not_elapsed", + "description": "Execution attempted before cooldown period elapsed", + "constraint_set": { + "version": 1, + "max_position_notional": "18446744073709551615", + "max_leverage_bps": 100000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 60, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": { + "snapshot_version": 1, + "last_execution_ts": 1000, + "current_ts": 1030, + "current_equity": 100000, + "peak_equity": 100000 + }, + "proposed_actions": [ + { + "action_type": 1, + "target": "1111111111111111111111111111111111111111111111111111111111111111", + "payload_hex": "010203" + } + ], + "expected": { + "status": "Failure", + "action_commitment": "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119", + "violation_reason": "CooldownNotElapsed", + "violation_reason_code": 7, + "violation_action_index": null + }, + "notes": "current_ts (1030) < last_execution_ts (1000) + cooldown (60) = 1060" + }, + { + "name": "drawdown_exceeded", + "description": "Portfolio drawdown exceeds max_drawdown_bps", + "constraint_set": { + "version": 1, + "max_position_notional": "18446744073709551615", + "max_leverage_bps": 100000, + "max_drawdown_bps": 2000, + "cooldown_seconds": 0, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": { + "snapshot_version": 1, + "last_execution_ts": 1000, + "current_ts": 2000, + "current_equity": 70000, + "peak_equity": 100000 + }, + "proposed_actions": [ + { + "action_type": 1, + "target": "1111111111111111111111111111111111111111111111111111111111111111", + "payload_hex": "010203" + } + ], + "expected": { + "status": "Failure", + "action_commitment": "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119", + "violation_reason": "DrawdownExceeded", + "violation_reason_code": 6, + "violation_action_index": null + }, + "notes": "30% drawdown ((100000-70000)*10000/100000=3000 bps) exceeds max 2000 bps (20%)" + }, + { + "name": "too_many_actions", + "description": "Action count exceeds max_actions_per_output", + "constraint_set": { + "version": 1, + "max_position_notional": "18446744073709551615", + "max_leverage_bps": 100000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 0, + "max_actions_per_output": 2, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": null, + "proposed_actions": [ + { + "action_type": 1, + "target": "1111111111111111111111111111111111111111111111111111111111111111", + "payload_hex": "01" + }, + { + "action_type": 1, + "target": "2222222222222222222222222222222222222222222222222222222222222222", + "payload_hex": "02" + }, + { + "action_type": 1, + "target": "3333333333333333333333333333333333333333333333333333333333333333", + "payload_hex": "03" + } + ], + "expected": { + "status": "Failure", + "action_commitment": "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119", + "violation_reason": "InvalidOutputStructure", + "violation_reason_code": 1, + "violation_action_index": null + }, + "notes": "3 actions exceed max_actions_per_output of 2" + }, + { + "name": "empty_output_valid", + "description": "Empty output is always valid", + "constraint_set": { + "version": 1, + "max_position_notional": "18446744073709551615", + "max_leverage_bps": 100000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 0, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": null, + "proposed_actions": [], + "expected": { + "status": "Success", + "violation_reason": null, + "violation_action_index": null + } + }, + { + "name": "close_position_valid", + "description": "Valid ClosePosition action", + "constraint_set": { + "version": 1, + "max_position_notional": "18446744073709551615", + "max_leverage_bps": 100000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 0, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": null, + "proposed_actions": [ + { + "action_type": 3, + "target": "3333333333333333333333333333333333333333333333333333333333333333", + "payload_hex": "abababababababababababababababababababababababababababababababab" + } + ], + "expected": { + "status": "Success", + "violation_reason": null, + "violation_action_index": null + }, + "notes": "position_id=0xab*32" + }, + { + "name": "swap_valid", + "description": "Valid Swap action", + "constraint_set": { + "version": 1, + "max_position_notional": "18446744073709551615", + "max_leverage_bps": 100000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 0, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": null, + "proposed_actions": [ + { + "action_type": 5, + "target": "5555555555555555555555555555555555555555555555555555555555555555", + "payload_hex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbe803000000000000" + } + ], + "expected": { + "status": "Success", + "violation_reason": null, + "violation_action_index": null + }, + "notes": "from_asset=0xaa*32, to_asset=0xbb*32, amount=1000" + }, + { + "name": "invalid_open_position_payload", + "description": "OpenPosition with truncated payload", + "constraint_set": { + "version": 1, + "max_position_notional": "18446744073709551615", + "max_leverage_bps": 100000, + "max_drawdown_bps": 10000, + "cooldown_seconds": 0, + "max_actions_per_output": 64, + "allowed_asset_id": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "state_snapshot": null, + "proposed_actions": [ + { + "action_type": 2, + "target": "2222222222222222222222222222222222222222222222222222222222222222", + "payload_hex": "42424242424242424242" + } + ], + "expected": { + "status": "Failure", + "action_commitment": "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119", + "violation_reason": "InvalidActionPayload", + "violation_reason_code": 10, + "violation_action_index": 0 + }, + "notes": "Payload is only 10 bytes, OpenPosition requires 45 bytes minimum" + } + ], + "constants": { + "empty_output_commitment": "df3f619804a92fdb4057192dc43dd748ea778adc52bc498ce80524c014b81119", + "violation_reason_codes": { + "InvalidOutputStructure": 1, + "UnknownActionType": 2, + "AssetNotWhitelisted": 3, + "PositionTooLarge": 4, + "LeverageTooHigh": 5, + "DrawdownExceeded": 6, + "CooldownNotElapsed": 7, + "InvalidStateSnapshot": 8, + "InvalidConstraintSet": 9, + "InvalidActionPayload": 10 + }, + "action_types": { + "Echo": 1, + "OpenPosition": 2, + "ClosePosition": 3, + "AdjustPosition": 4, + "Swap": 5 + } + } +}