nocturne-midnight 0.1.0

Rust SDK for Morpho Midnight offers, execution, simulation, verification, and position management.
Documentation
use std::{
    io::{Read, Write},
    net::TcpListener,
    sync::mpsc,
    thread,
};

use nocturne::*;
use serde_json::json;

fn serve_once(status: u16, body: serde_json::Value) -> (String, mpsc::Receiver<String>) {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let address = listener.local_addr().unwrap();
    let (sender, receiver) = mpsc::channel();
    thread::spawn(move || {
        let (mut stream, _) = listener.accept().unwrap();
        let mut request = Vec::new();
        let mut buffer = [0u8; 4096];
        let header_end;
        loop {
            let read = stream.read(&mut buffer).unwrap();
            assert_ne!(read, 0);
            request.extend_from_slice(&buffer[..read]);
            if let Some(position) = request.windows(4).position(|part| part == b"\r\n\r\n") {
                header_end = position + 4;
                break;
            }
        }
        let headers = String::from_utf8_lossy(&request[..header_end]);
        let content_length = headers
            .lines()
            .find_map(|line| {
                line.to_ascii_lowercase()
                    .strip_prefix("content-length:")
                    .map(str::trim)
                    .map(str::parse::<usize>)
            })
            .transpose()
            .unwrap()
            .unwrap_or(0);
        while request.len() < header_end + content_length {
            let read = stream.read(&mut buffer).unwrap();
            assert_ne!(read, 0);
            request.extend_from_slice(&buffer[..read]);
        }
        let _ = sender.send(String::from_utf8(request).unwrap());

        let body = serde_json::to_vec(&body).unwrap();
        let reason = if status == 200 { "OK" } else { "Error" };
        write!(stream, "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).unwrap();
        stream.write_all(&body).unwrap();
    });
    (format!("http://{address}/v0/midnight"), receiver)
}

fn hex<const N: usize>(value: &[u8; N]) -> String {
    format!("0x{}", ::hex::encode(value))
}

fn fixture_market() -> Market {
    Market {
        chain_id: U256::from(BASE_CHAIN_ID).to_be_bytes(),
        midnight: [0x10; 20],
        loan_token: [0x20; 20],
        collateral_params: vec![CollateralParams {
            token: [0x30; 20],
            lltv: U256::from(770_000_000_000_000_000u64).to_be_bytes(),
            liquidation_cursor: U256::from(250_000_000_000_000_000u64).to_be_bytes(),
            oracle: [0x40; 20],
        }],
        maturity: U256::from(54_000).to_be_bytes(),
        rcf_threshold: [0; 32],
        enter_gate: [0; 20],
        liquidator_gate: [0; 20],
    }
}

fn take_json(tick: u64, buy: bool) -> serde_json::Value {
    let market = fixture_market();
    json!({
        "market_id": hex(&market_id(&market)),
        "units": "50",
        "offer": {
            "market": {
                "chain_id": BASE_CHAIN_ID,
                "midnight": hex(&market.midnight),
                "loan_token": hex(&market.loan_token),
                "collaterals": [{
                    "token": hex(&market.collateral_params[0].token),
                    "lltv": "770000000000000000",
                    "liquidation_cursor": "250000000000000000",
                    "oracle": hex(&market.collateral_params[0].oracle)
                }],
                "maturity": 54000,
                "rcf_threshold": "0",
                "enter_gate": hex(&market.enter_gate),
                "liquidator_gate": hex(&market.liquidator_gate)
            },
            "buy": buy,
            "maker": hex(&[0x50; 20]),
            "start": 1000,
            "expiry": 3600,
            "tick": tick,
            "group": hex(&[0x60; 32]),
            "callback": hex(&[0; 20]),
            "callback_data": "0x",
            "receiver_if_maker_is_seller": if buy { hex(&[0; 20]) } else { hex(&[0x51; 20]) },
            "ratifier": hex(&[0x70; 20]),
            "reduce_only": false,
            "max_units": "100",
            "max_assets": "0",
            "continuous_fee_cap": "123"
        },
        "ratifier_data": "0xdeadbeef"
    })
}

#[tokio::test]
async fn validates_the_exact_payload_and_preserves_issues() {
    let (base, request) = serve_once(
        200,
        json!({
            "data": { "issues": [{ "rule": "future_rule", "details": { "new": true } }] }
        }),
    );
    let api = MidnightApi::new(base).unwrap();
    let result = api
        .validate_payload(BASE_CHAIN_ID, &[1, 2, 3], Some("2026-08-21T12:00:00Z"))
        .await
        .unwrap();
    assert!(!result.valid);
    assert_eq!(result.issues[0].rule, "future_rule");
    assert_eq!(result.issues[0].details, Some(json!({ "new": true })));

    let request = request.recv().unwrap();
    assert!(request.starts_with(
        "POST /v0/midnight/mempool/validate?timestamp=2026-08-21T12%3A00%3A00Z HTTP/1.1"
    ));
    assert!(request.to_ascii_lowercase().contains("sdk-version: 0.1.0"));
    assert!(request.ends_with(r#"{"chain_id":8453,"payload":"0x010203"}"#));
}

#[tokio::test]
async fn fetches_and_maps_filtered_books() {
    let id = [0x11; 32];
    let (base, request) = serve_once(
        200,
        json!({
            "cursor": "next",
            "data": [{
                "market_id": hex(&id), "chain_id": 8453,
                "midnight": hex(&[0x10; 20]), "loan_token": hex(&[0x20; 20]),
                "collaterals": [{ "token": hex(&[0x30; 20]), "lltv": "770", "liquidation_cursor": "250", "oracle": hex(&[0x40; 20]) }],
                "maturity": 54000, "rcf_threshold": "7", "enter_gate": hex(&[0; 20]), "liquidator_gate": hex(&[0; 20]),
                "asks": [{ "tick": 10, "price": "20", "units": "30", "assets": "40", "count": 2 }], "bids": []
            }]
        }),
    );
    let api = MidnightApi::new(base).unwrap();
    let page = api
        .fetch_books(&BooksQuery {
            chain_ids: vec![8453],
            market_ids: vec![id],
            limit: Some(10),
            ..Default::default()
        })
        .await
        .unwrap();
    assert_eq!(page.cursor.as_deref(), Some("next"));
    assert_eq!(page.data[0].market_id, id);
    assert_eq!(page.data[0].asks[0].assets, U256::from(40));
    let request = request.recv().unwrap();
    assert!(request.starts_with(&format!(
        "GET /v0/midnight/books?chain_ids=8453&ids={}&limit=10 HTTP/1.1",
        hex(&id)
    )));
}

#[tokio::test]
async fn executable_offers_are_bound_and_sorted_for_the_requested_side() {
    let id = market_id(&fixture_market());
    let (base, _) = serve_once(
        200,
        json!({ "data": [take_json(200, false), take_json(100, false)] }),
    );
    let takes = MidnightApi::new(base)
        .unwrap()
        .fetch_book_takeable_offers(id, BookSide::Asks)
        .await
        .unwrap();
    assert_eq!(word_to_u256(&takes[0].offer.tick), U256::from(100));
    assert_eq!(takes[0].ratifier_data, [0xde, 0xad, 0xbe, 0xef]);

    let (base, _) = serve_once(200, json!({ "data": [take_json(100, true)] }));
    let error = MidnightApi::new(base)
        .unwrap()
        .fetch_book_takeable_offers(id, BookSide::Asks)
        .await
        .unwrap_err();
    assert!(matches!(error, ApiError::InvalidResponse(message) if message.contains("side")));
}

#[tokio::test]
async fn quote_price_guard_is_recomputed_from_returned_takes() {
    let id = market_id(&fixture_market());
    let (base, _) = serve_once(
        200,
        json!({ "data": {
            "average_best_price": "0", "average_worst_price": "0",
            "available_assets": "50", "available_units": "50",
            "takeable_offers": [take_json(100, false)]
        }}),
    );
    let error = MidnightApi::new(base)
        .unwrap()
        .fetch_quote(
            id,
            BookSide::Asks,
            QuoteTarget::Units(U256::from(10)),
            Some(&QuoteGuard::AverageWorstPrice(U256::ZERO)),
        )
        .await
        .unwrap_err();
    assert!(
        matches!(error, ApiError::InvalidResponse(message) if message.contains("outside average_worst_price"))
    );
}

#[tokio::test]
async fn structured_http_errors_are_not_discarded() {
    let (base, _) = serve_once(
        422,
        json!({ "error": {
            "code": "bad_payload", "message": "payload rejected", "details": { "field": "payload" }, "request_id": "req-1"
        }}),
    );
    let error = MidnightApi::new(base)
        .unwrap()
        .validate_payload(8453, &[1], None)
        .await
        .unwrap_err();
    assert!(
        matches!(error, ApiError::Http { status: 422, code: Some(code), request_id: Some(id), .. } if code == "bad_payload" && id == "req-1")
    );
}