diff --git a/.github/workflows/rust-workspace.yml b/.github/workflows/rust-workspace.yml new file mode 100644 index 0000000..dad46de --- /dev/null +++ b/.github/workflows/rust-workspace.yml @@ -0,0 +1,38 @@ +name: Rust Workspace Validation + +on: + pull_request: + paths: + - 'src/**/*.rs' + - 'tests/**/*.rs' + - 'crates/**/*.rs' + - 'crates/**/Cargo.toml' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/rust-abi.yml' + - '.github/workflows/rust-workspace.yml' + push: + branches: + - main + +permissions: + contents: read + +jobs: + test-rust-workspace: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust Stable Toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo Dependencies + uses: Swatinem/rust-cache@v2 + + - name: Check workspace + run: cargo check --workspace + + - name: Test workspace + run: cargo test --workspace --all-targets diff --git a/Cargo.toml b/Cargo.toml index a81a6fd..8832925 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [workspace] -members = ["crates/sentinel-core"] +members = ["crates/sentinel-core", "crates/ghost-twin"] resolver = "2" [dependencies] diff --git a/crates/ghost-twin/Cargo.toml b/crates/ghost-twin/Cargo.toml new file mode 100644 index 0000000..de4f62e --- /dev/null +++ b/crates/ghost-twin/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "ghost-twin" +version = "0.1.0" +edition = "2021" +description = "Deterministic Ghost/Twin execution boundary for SentinelAI" +license = "Apache-2.0" + +[dependencies] +sentinel-core = { path = "../sentinel-core" } diff --git a/crates/ghost-twin/README.md b/crates/ghost-twin/README.md new file mode 100644 index 0000000..420ff7d --- /dev/null +++ b/crates/ghost-twin/README.md @@ -0,0 +1,12 @@ +# ghost-twin + +Isolated deterministic execution boundary for SentinelAI. + +```text +Ghost -> TradeIntent -> Sentinel risk boundary -> Twin paper/shadow fill + \\-> ExecutionGate -> live only when allow_live=true +``` + +The crate contains no key management or signing and performs no external network operations. A future Jito adapter should accept already-signed transactions at an outer integration boundary. + +Live execution is disabled by default. diff --git a/crates/ghost-twin/src/lib.rs b/crates/ghost-twin/src/lib.rs new file mode 100644 index 0000000..763cd4e --- /dev/null +++ b/crates/ghost-twin/src/lib.rs @@ -0,0 +1,140 @@ +#![forbid(unsafe_code)] + +use sentinel_core::{Decision, SentinelCore}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TradeIntent { + pub chain_id: u64, + pub max_slippage_bps: u16, + pub amount_units: u64, + pub live_requested: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PaperFill { + pub filled: bool, + pub amount_units: u64, + pub slippage_bps: u16, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GateDecision { + Shadow, + Deny, + Live, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExecutionGate { + pub allow_live: bool, +} + +impl ExecutionGate { + pub const fn denied() -> Self { + Self { allow_live: false } + } + + pub const fn live_enabled() -> Self { + Self { allow_live: true } + } + + #[inline(always)] + pub const fn decide(&self, risk: Decision, live_requested: bool) -> GateDecision { + match risk { + Decision::Block => GateDecision::Deny, + Decision::Allow if live_requested && self.allow_live => GateDecision::Live, + Decision::Monitor => GateDecision::Shadow, + Decision::Allow => GateDecision::Shadow, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TwinEngine { + pub gate: ExecutionGate, +} + +impl TwinEngine { + pub const fn new(gate: ExecutionGate) -> Self { + Self { gate } + } + + #[inline(always)] + pub fn evaluate( + &self, + core: &mut SentinelCore, + intent: TradeIntent, + payload: &[u8], + ) -> (Decision, GateDecision, PaperFill) { + let risk = core.scan(payload); + let gate = self.gate.decide(risk, intent.live_requested); + let fill = match gate { + GateDecision::Deny => PaperFill { + filled: false, + amount_units: 0, + slippage_bps: 0, + }, + GateDecision::Shadow | GateDecision::Live => PaperFill { + filled: true, + amount_units: intent.amount_units, + slippage_bps: intent.max_slippage_bps, + }, + }; + (risk, gate, fill) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn live_is_disabled_by_default() { + let engine = TwinEngine::new(ExecutionGate::denied()); + let mut core = SentinelCore::new(); + let intent = TradeIntent { + chain_id: 1, + max_slippage_bps: 50, + amount_units: 100, + live_requested: true, + }; + let (_, gate, fill) = engine.evaluate(&mut core, intent, b"clean"); + assert_eq!(gate, GateDecision::Shadow); + assert!(fill.filled); + } + + #[test] + fn monitor_can_never_go_live() { + let engine = TwinEngine::new(ExecutionGate::live_enabled()); + let mut core = SentinelCore::new(); + core.observe(sentinel_core::Signal::Debugger, 0); + let intent = TradeIntent { + chain_id: 1, + max_slippage_bps: 50, + amount_units: 100, + live_requested: true, + }; + let (risk, gate, fill) = engine.evaluate(&mut core, intent, b"clean"); + assert_eq!(risk, Decision::Monitor); + assert_eq!(gate, GateDecision::Shadow); + assert!(fill.filled); + } + + #[test] + fn breach_denies_even_when_live_enabled() { + let engine = TwinEngine::new(ExecutionGate::live_enabled()); + let mut core = SentinelCore::new(); + core.observe(sentinel_core::Signal::Tamper, 0); + core.observe(sentinel_core::Signal::Root, 0); + let intent = TradeIntent { + chain_id: 1, + max_slippage_bps: 50, + amount_units: 100, + live_requested: true, + }; + let (risk, gate, fill) = engine.evaluate(&mut core, intent, b"clean"); + assert_eq!(risk, Decision::Block); + assert_eq!(gate, GateDecision::Deny); + assert!(!fill.filled); + } +} diff --git a/crates/sentinel-core/src/lib.rs b/crates/sentinel-core/src/lib.rs index 2af4349..2b4aa49 100644 --- a/crates/sentinel-core/src/lib.rs +++ b/crates/sentinel-core/src/lib.rs @@ -138,7 +138,7 @@ impl SentinelCore { self.decision() } #[inline(always)] fn scan_token(&mut self, data: &[u8], token: &[u8], signal: Signal) { if let Some(offset) = find_subslice(data, token) { self.observe(signal, offset); } } - #[inline(always)] pub const fn decision(&self) -> Decision { match State::from_raw(self.state.load(Ordering::Acquire)) { State::Monitor => Decision::Allow, State::Anomalous => Decision::Monitor, State::Breached => Decision::Block } } + #[inline(always)] pub fn decision(&self) -> Decision { match State::from_raw(self.state.load(Ordering::Acquire)) { State::Monitor => Decision::Allow, State::Anomalous => Decision::Monitor, State::Breached => Decision::Block } } #[inline(always)] pub const fn event_count(&self) -> usize { self.ring.len() } #[inline(always)] pub fn event(&self, index: usize) -> Option { self.ring.get(index) } }