aspens 0.8.0

Aspens crosschain trading SDK
Documentation
//! FCE action-envelope wire format (design: `sdk/docs/fce-transport-design.md`).
//!
//! Pinned against Flare's `tee-node@v0.0.22`:
//! - `pkg/utils/utils.go` — `ToHash` is `bytes32(s)`, NOT a hash.
//! - `pkg/types/direct.go` — `DirectInstruction` is JSON `{opType, opCommand, message}`.
//!
//! All three fields are go-ethereum types serialized as `0x`-prefixed lowercase
//! hex: `common.Hash` → `0x` + 64 hex; `hexutil.Bytes` → `0x` + hex (empty = `0x`).

use serde::{Deserialize, Deserializer, Serialize, Serializer};

/// The single OPType this extension answers to.
pub const OP_TYPE_ASPENS: &str = "ASPENS";

/// Direct-action OPCommands (off-chain). `DEPOSIT` is the on-chain instruction
/// channel, not a direct action — see `sdk/docs/fce-transport-design.md`.
pub const OP_WITHDRAW: &str = "WITHDRAW";
pub const OP_PLACE_ORDER: &str = "PLACE_ORDER";
pub const OP_CANCEL_ORDER: &str = "CANCEL_ORDER";
pub const OP_GET_MY_STATE: &str = "GET_MY_STATE";
pub const OP_GET_BOOK_STATE: &str = "GET_BOOK_STATE";
pub const OP_EXPORT_HISTORY: &str = "EXPORT_HISTORY";
/// Config discovery over FCE. Without it an FCE-only client can read but not
/// WRITE: building a signed order needs the market's pair decimals and the
/// base/quote chains' curves, which live only in the arborter config.
pub const OP_GET_CONFIG: &str = "GET_CONFIG";

/// `bytes32(s)` — copy the UTF-8 bytes of `s` into a 32-byte array, truncating
/// past 32 bytes and zero-padding the tail. Mirrors `teeutils.ToHash` /
/// `send-direct`'s `toBytes32` (Solidity `bytes32("ASPENS")`). NOT a hash.
pub fn to_bytes32(s: &str) -> [u8; 32] {
    let mut out = [0u8; 32];
    let b = s.as_bytes();
    let n = b.len().min(32);
    out[..n].copy_from_slice(&b[..n]);
    out
}

/// The on-wire object the ext-proxy `/direct` endpoint accepts (the proxy wraps
/// it into a tee-node `Action`; see `send-direct`). `message` carries the
/// UTF-8 bytes of the payload JSON.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DirectInstruction {
    #[serde(rename = "opType", with = "hex32")]
    pub op_type: [u8; 32],
    #[serde(rename = "opCommand", with = "hex32")]
    pub op_command: [u8; 32],
    #[serde(rename = "message", with = "hexbytes")]
    pub message: Vec<u8>,
}

impl DirectInstruction {
    /// Build a direct instruction for `command` (an `OP_*` const) carrying the
    /// already-serialized payload JSON bytes as `message`.
    pub fn new(command: &str, payload_json: Vec<u8>) -> Self {
        Self {
            op_type: to_bytes32(OP_TYPE_ASPENS),
            op_command: to_bytes32(command),
            message: payload_json,
        }
    }
}

/// `0x`-prefixed hex for a fixed 32-byte value (go-ethereum `common.Hash`).
pub(crate) mod hex32 {
    use super::*;

    pub fn serialize<S: Serializer>(v: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&format!("0x{}", hex::encode(v)))
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
        let s = String::deserialize(d)?;
        let bytes =
            hex::decode(s.strip_prefix("0x").unwrap_or(&s)).map_err(serde::de::Error::custom)?;
        bytes
            .try_into()
            .map_err(|_| serde::de::Error::custom("expected 32 bytes"))
    }
}

/// A `u64` that crosses the JSON wire as a QUOTED STRING.
///
/// JavaScript numbers are doubles, so any integer above 2^53 is rounded by
/// `JSON.parse`. In production the arborter held order 173852891691592598, the
/// browser read 173852891691592600, and every cancel failed with NotFound while
/// the order sat in the book with its collateral reserved. Rust would not have
/// rounded it, but it shares the wire, so it has to agree on the encoding —
/// see `types.U64String` on the Go side.
///
/// Serializes as a string always. Deserializes from either a string or a bare
/// number, so both ends do not have to ship at once.
pub mod u64_string {
    use super::*;
    use serde::de::{Error as DeError, Unexpected};

    pub fn serialize<S: Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&v.to_string())
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
        match serde_json::Value::deserialize(d)? {
            serde_json::Value::String(s) => s
                .parse::<u64>()
                .map_err(|e| D::Error::custom(format!("u64 string {s:?}: {e}"))),
            serde_json::Value::Number(n) => n.as_u64().ok_or_else(|| {
                D::Error::invalid_value(Unexpected::Other(&n.to_string()), &"a u64")
            }),
            other => Err(D::Error::invalid_type(
                Unexpected::Other(&other.to_string()),
                &"a u64 as string or number",
            )),
        }
    }
}

/// `0x`-prefixed hex for a variable-length byte slice (go-ethereum
/// `hexutil.Bytes`). Empty bytes serialize as `"0x"`; `"0x"` deserializes to
/// empty (matches go-ethereum).
pub mod hexbytes {
    use super::*;

    pub fn serialize<S: Serializer>(v: &[u8], s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&format!("0x{}", hex::encode(v)))
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
        let s = String::deserialize(d)?;
        let body = s.strip_prefix("0x").unwrap_or(&s);
        if body.is_empty() {
            return Ok(Vec::new());
        }
        hex::decode(body).map_err(serde::de::Error::custom)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn hex32_str(s: &str) -> String {
        format!("0x{}", hex::encode(to_bytes32(s)))
    }

    // Golden vectors — MUST match `teeutils.ToHash` byte-for-byte.
    #[test]
    fn to_bytes32_golden_vectors() {
        assert_eq!(
            hex32_str("ASPENS"),
            "0x415350454e530000000000000000000000000000000000000000000000000000"
        );
        assert_eq!(
            hex32_str("DEPOSIT"),
            "0x4445504f53495400000000000000000000000000000000000000000000000000"
        );
        assert_eq!(
            hex32_str("WITHDRAW"),
            "0x5749544844524157000000000000000000000000000000000000000000000000"
        );
        assert_eq!(
            hex32_str("PLACE_ORDER"),
            "0x504c4143455f4f52444552000000000000000000000000000000000000000000"
        );
        assert_eq!(
            hex32_str("CANCEL_ORDER"),
            "0x43414e43454c5f4f524445520000000000000000000000000000000000000000"
        );
        assert_eq!(
            hex32_str("GET_MY_STATE"),
            "0x4745545f4d595f53544154450000000000000000000000000000000000000000"
        );
        assert_eq!(
            hex32_str("GET_BOOK_STATE"),
            "0x4745545f424f4f4b5f5354415445000000000000000000000000000000000000"
        );
        assert_eq!(
            hex32_str("EXPORT_HISTORY"),
            "0x4558504f52545f484953544f5259000000000000000000000000000000000000"
        );
    }

    #[test]
    fn to_bytes32_truncates_past_32() {
        let s = "X".repeat(40);
        assert_eq!(to_bytes32(&s), [b'X'; 32]);
    }

    // The DirectInstruction JSON must be exactly {opType, opCommand, message}
    // with 0x-hex values; `message` = 0x + hex(payload JSON).
    #[test]
    fn direct_instruction_json_shape() {
        let payload = br#"{"marketId":"m1"}"#.to_vec();
        let di = DirectInstruction::new(OP_GET_BOOK_STATE, payload);
        let v: serde_json::Value = serde_json::to_value(&di).unwrap();
        assert_eq!(
            v["opType"],
            "0x415350454e530000000000000000000000000000000000000000000000000000"
        );
        assert_eq!(
            v["opCommand"],
            "0x4745545f424f4f4b5f5354415445000000000000000000000000000000000000"
        );
        // message = 0x + hex('{"marketId":"m1"}')
        assert_eq!(v["message"], "0x7b226d61726b65744964223a226d31227d");

        // round-trips
        let back: DirectInstruction = serde_json::from_value(v).unwrap();
        assert_eq!(back, di);
        assert_eq!(back.message, br#"{"marketId":"m1"}"#);
    }

    #[test]
    fn empty_hexbytes_is_0x() {
        let di = DirectInstruction::new(OP_PLACE_ORDER, Vec::new());
        let v: serde_json::Value = serde_json::to_value(&di).unwrap();
        assert_eq!(v["message"], "0x");
        let back: DirectInstruction = serde_json::from_value(v).unwrap();
        assert!(back.message.is_empty());
    }
}

#[cfg(test)]
mod u64_string_tests {
    use serde::{Deserialize, Serialize};

    #[derive(Serialize, Deserialize, PartialEq, Debug)]
    struct Holder {
        #[serde(with = "super::u64_string")]
        id: u64,
    }

    /// The id that broke cancel in production.
    const TRUE_ID: u64 = 173_852_891_691_592_598;

    #[test]
    fn serializes_as_a_quoted_string() {
        let j = serde_json::to_string(&Holder { id: TRUE_ID }).unwrap();
        assert_eq!(j, r#"{"id":"173852891691592598"}"#);
    }

    #[test]
    fn round_trips_every_u64_exactly() {
        for id in [
            TRUE_ID,
            6_755_360_711_411_187_334,
            1 << 53,
            (1 << 53) + 1,
            u64::MAX,
            0,
        ] {
            let j = serde_json::to_string(&Holder { id }).unwrap();
            let back: Holder = serde_json::from_str(&j).unwrap();
            assert_eq!(back.id, id, "round trip lost precision for {id}");
        }
    }

    #[test]
    fn accepts_a_bare_number_for_compatibility() {
        let h: Holder = serde_json::from_str(r#"{"id":42}"#).unwrap();
        assert_eq!(h.id, 42);
    }

    #[test]
    fn rejects_non_integers_and_overflow() {
        for bad in [
            r#"{"id":"1.5"}"#,
            r#"{"id":1.5}"#,
            r#"{"id":"-1"}"#,
            r#"{"id":-1}"#,
            r#"{"id":"abc"}"#,
            r#"{"id":"18446744073709551616"}"#,
            r#"{"id":null}"#,
        ] {
            assert!(
                serde_json::from_str::<Holder>(bad).is_err(),
                "accepted {bad}"
            );
        }
    }
}