cotyledon 0.1.0

Framework for writing sprouts — sandboxed, event-driven on-chain trading bots.
Documentation
//! The boundary to the engine.
//!
//! Each first-class function is an RPC the engine performs on the sprout's
//! behalf. On `wasm32` (the real target) these are wasm host imports plus JSON
//! (de)serialization over a fat-pointer convention; on every other target they
//! are `todo!()` stubs so the workspace still builds natively.
//!
//! ## Wire ABI
//!
//! All values cross the boundary as JSON. A *fat pointer* is a packed `i64` =
//! `(ptr as u32) << 32 | (len as u32)`, addressing bytes in the guest's linear
//! memory. The guest owns all memory: it exports [`abi::__cotyledon_alloc`] /
//! [`abi::__cotyledon_dealloc`], and both sides allocate the buffers they hand
//! over and free the buffers they receive.
//!
//! Every value-returning import answers with an *envelope*:
//! `{"ok":true,"value":<T>}` or `{"ok":false,"error":{"kind":"…","message":"…"}}`,
//! which [`abi::decode`] turns back into a [`Result`]. Binary payloads (signing,
//! transactions, raw state) travel base64-encoded inside the JSON.

#[cfg(target_arch = "wasm32")]
pub(crate) use wasm::*;

#[cfg(not(target_arch = "wasm32"))]
pub(crate) use native::*;

// ---------------------------------------------------------------------------
// wasm32 — the real boundary
// ---------------------------------------------------------------------------

#[cfg(target_arch = "wasm32")]
pub(crate) mod abi {
    //! Fat-pointer marshaling and guest-owned memory. Shared by the host-import
    //! wrappers and by the macro-generated entrypoint (re-exported as
    //! `cotyledon::__rt`).

    use std::alloc::{Layout, alloc, dealloc};

    use serde::Serialize;
    use serde::de::DeserializeOwned;

    use crate::{Error, Result};

    /// Allocate `len` bytes for the host to write into. Returns a guest pointer.
    ///
    /// # Safety
    /// The returned region must be freed exactly once with
    /// [`__cotyledon_dealloc`] using the same `len`.
    #[unsafe(no_mangle)]
    pub extern "C" fn __cotyledon_alloc(len: i32) -> i32 {
        if len <= 0 {
            return 0;
        }
        // SAFETY: `len > 0`, alignment 1 is always valid for a byte buffer.
        unsafe { alloc(Layout::from_size_align_unchecked(len as usize, 1)) as i32 }
    }

    /// Free a region previously returned by [`__cotyledon_alloc`].
    #[unsafe(no_mangle)]
    pub extern "C" fn __cotyledon_dealloc(ptr: i32, len: i32) {
        if ptr == 0 || len <= 0 {
            return;
        }
        // SAFETY: `ptr`/`len` match a prior `__cotyledon_alloc` (same align 1).
        unsafe { dealloc(ptr as *mut u8, Layout::from_size_align_unchecked(len as usize, 1)) }
    }

    /// Pack a `(ptr, len)` pair into the wire `i64`.
    pub fn pack(ptr: i32, len: i32) -> i64 {
        (((ptr as u32 as u64) << 32) | (len as u32 as u64)) as i64
    }

    /// Split a wire `i64` back into `(ptr, len)`.
    pub fn unpack(packed: i64) -> (i32, i32) {
        let p = packed as u64;
        ((p >> 32) as i32, (p & 0xffff_ffff) as i32)
    }

    /// Copy bytes addressed by a host-returned fat pointer into an owned `Vec`,
    /// then free the host-allocated region.
    pub fn read_owned(ptr: i32, len: i32) -> Vec<u8> {
        if ptr == 0 || len <= 0 {
            return Vec::new();
        }
        // SAFETY: the host wrote `len` valid bytes at `ptr` via our allocator.
        let owned = unsafe { std::slice::from_raw_parts(ptr as *const u8, len as usize) }.to_vec();
        __cotyledon_dealloc(ptr, len);
        owned
    }

    /// Decode a response envelope into `T`. A unit `T = ()` accepts a `null`
    /// value, so this works uniformly for value-returning and void calls.
    pub fn decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
        let env: serde_json::Value =
            serde_json::from_slice(bytes).map_err(|e| Error::Codec(e.to_string()))?;
        if env.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) {
            let value = env.get("value").cloned().unwrap_or(serde_json::Value::Null);
            serde_json::from_value(value).map_err(|e| Error::Codec(e.to_string()))
        } else {
            let err = env.get("error");
            let kind = err
                .and_then(|e| e.get("kind"))
                .and_then(|v| v.as_str())
                .unwrap_or("Host");
            let message = err
                .and_then(|e| e.get("message"))
                .and_then(|v| v.as_str())
                .unwrap_or("unknown host error")
                .to_owned();
            Err(match kind {
                "Denied" => Error::Denied(message),
                "Codec" => Error::Codec(message),
                _ => Error::Host(message),
            })
        }
    }

    /// Issue a value-returning host call: serialize `req`, invoke the import,
    /// decode the response envelope into `R`.
    ///
    /// # Safety
    /// `import` must be one of the declared `cotyledon` module imports with the
    /// `(ptr, len) -> i64` signature.
    pub unsafe fn call<R: DeserializeOwned>(
        import: unsafe extern "C" fn(i32, i32) -> i64,
        req: impl Serialize,
    ) -> Result<R> {
        let bytes = serde_json::to_vec(&req).map_err(|e| Error::Codec(e.to_string()))?;
        // `bytes` stays alive across the (synchronous) host call.
        let packed = unsafe { import(bytes.as_ptr() as i32, bytes.len() as i32) };
        let (ptr, len) = unpack(packed);
        decode(&read_owned(ptr, len))
    }

    /// Fire a void host call (no response envelope).
    ///
    /// # Safety
    /// `import` must be a declared `cotyledon` void import.
    pub unsafe fn send(import: unsafe extern "C" fn(i32, i32), req: impl Serialize) {
        let Ok(bytes) = serde_json::to_vec(&req) else {
            return;
        };
        unsafe { import(bytes.as_ptr() as i32, bytes.len() as i32) };
    }
}

#[cfg(target_arch = "wasm32")]
mod imports {
    //! The raw host imports, resolved by the engine's `Linker`.
    #[link(wasm_import_module = "cotyledon")]
    unsafe extern "C" {
        pub fn host_config(ptr: i32, len: i32) -> i64;
        pub fn host_price(ptr: i32, len: i32) -> i64;
        pub fn host_wallet_address(ptr: i32, len: i32) -> i64;
        pub fn host_wallet_balance(ptr: i32, len: i32) -> i64;
        pub fn host_sign(ptr: i32, len: i32) -> i64;
        pub fn host_submit_tx(ptr: i32, len: i32) -> i64;
        pub fn host_state_get(ptr: i32, len: i32) -> i64;
        pub fn host_state_set(ptr: i32, len: i32) -> i64;
        pub fn host_emit(ptr: i32, len: i32);
        pub fn host_metric(ptr: i32, len: i32);
    }
}

#[cfg(target_arch = "wasm32")]
mod wasm {
    use base64::Engine as _;
    use base64::prelude::BASE64_STANDARD;
    use serde_json::json;

    use super::{abi, imports};
    use crate::{Error, Result};

    fn b64(bytes: &[u8]) -> String {
        BASE64_STANDARD.encode(bytes)
    }

    fn unb64(s: &str) -> Result<Vec<u8>> {
        BASE64_STANDARD.decode(s).map_err(|e| Error::Codec(e.to_string()))
    }

    /// The per-deployment config bytes (`[config]` in `sprout.toml`). The host
    /// answers with the raw config JSON (no envelope — config always exists).
    pub(crate) fn config_raw() -> Vec<u8> {
        let packed = unsafe { imports::host_config(0, 0) };
        let (ptr, len) = abi::unpack(packed);
        abi::read_owned(ptr, len)
    }

    pub(crate) async fn price(token: &str) -> Result<f64> {
        unsafe { abi::call(imports::host_price, json!({ "token": token })) }
    }

    pub(crate) async fn wallet_address() -> Result<String> {
        unsafe { abi::call(imports::host_wallet_address, json!({})) }
    }

    pub(crate) async fn wallet_balance(token: &str) -> Result<f64> {
        unsafe { abi::call(imports::host_wallet_balance, json!({ "token": token })) }
    }

    pub(crate) async fn sign(payload: &[u8]) -> Result<Vec<u8>> {
        let sig: String =
            unsafe { abi::call(imports::host_sign, json!({ "payload": b64(payload) }))? };
        unb64(&sig)
    }

    pub(crate) async fn submit_tx(signed_tx: &[u8]) -> Result<String> {
        unsafe { abi::call(imports::host_submit_tx, json!({ "signed_tx": b64(signed_tx) })) }
    }

    pub(crate) async fn state_get(key: &str) -> Result<Option<Vec<u8>>> {
        let v: Option<String> =
            unsafe { abi::call(imports::host_state_get, json!({ "key": key }))? };
        match v {
            Some(b) => Ok(Some(unb64(&b)?)),
            None => Ok(None),
        }
    }

    pub(crate) async fn state_set(key: &str, value: &[u8]) -> Result<()> {
        unsafe {
            abi::call(
                imports::host_state_set,
                json!({ "key": key, "value": b64(value) }),
            )
        }
    }

    pub(crate) fn emit(event_json: &[u8]) {
        // `event_json` is already the serialized trade; forward the raw bytes.
        unsafe { imports::host_emit(event_json.as_ptr() as i32, event_json.len() as i32) };
    }

    pub(crate) fn metric(name: &str, value: f64) {
        unsafe { abi::send(imports::host_metric, json!({ "name": name, "value": value })) };
    }
}

// ---------------------------------------------------------------------------
// native — stubs so the workspace builds off-wasm
// ---------------------------------------------------------------------------

#[cfg(not(target_arch = "wasm32"))]
mod native {
    use crate::Result;

    pub(crate) fn config_raw() -> Vec<u8> {
        todo!("host ABI: config")
    }
    pub(crate) async fn price(_token: &str) -> Result<f64> {
        todo!("host ABI: price")
    }
    pub(crate) async fn wallet_address() -> Result<String> {
        todo!("host ABI: wallet_address")
    }
    pub(crate) async fn wallet_balance(_token: &str) -> Result<f64> {
        todo!("host ABI: wallet_balance")
    }
    pub(crate) async fn sign(_payload: &[u8]) -> Result<Vec<u8>> {
        todo!("host ABI: sign")
    }
    pub(crate) async fn submit_tx(_signed_tx: &[u8]) -> Result<String> {
        todo!("host ABI: submit_tx")
    }
    pub(crate) async fn state_get(_key: &str) -> Result<Option<Vec<u8>>> {
        todo!("host ABI: state_get")
    }
    pub(crate) async fn state_set(_key: &str, _value: &[u8]) -> Result<()> {
        todo!("host ABI: state_set")
    }
    pub(crate) fn emit(_event_json: &[u8]) {
        todo!("host ABI: emit")
    }
    pub(crate) fn metric(_name: &str, _value: f64) {
        todo!("host ABI: metric")
    }
}