KiiWORKS / Documentation

Developer Guide

SOURCE · docs/developer-guide.md · synced from the engineering repo

Developer Guide

Adding a New Platform Service

Every platform service follows the same pattern. Here’s how to add one.

1. Create the Crate

mkdir -p platform/my-service/src

2. Cargo.toml

[package]
name = "kiiworks-my-service"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "My new service description"

[dependencies]
kiiworks-core = { workspace = true }
axum = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }

[dev-dependencies]
tower = { workspace = true }
hyper = { workspace = true }
tokio = { workspace = true }

3. Models (src/models.rs)

use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MyItem {
    pub id: String,
    pub name: String,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
pub struct CreateMyItemRequest {
    pub name: String,
}

4. State (src/state.rs)

The state struct must derive Clone for Axum’s state extraction:

use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::models::MyItem;

#[derive(Clone)]
pub struct MyServiceState {
    pub items: Arc<RwLock<HashMap<String, MyItem>>>,
}

impl MyServiceState {
    pub fn new() -> Self {
        Self {
            items: Arc::new(RwLock::new(HashMap::new())),
        }
    }
}

5. Routes (src/routes.rs)

use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    routing::{get, post},
    Json, Router,
};
use chrono::Utc;
use crate::models::*;
use crate::state::MyServiceState;

pub fn my_service_routes() -> Router<MyServiceState> {
    Router::new()
        .route("/my-service/items", post(create_item).get(list_items))
        .route("/my-service/items/{id}", get(get_item))
}

async fn create_item(
    State(state): State<MyServiceState>,
    Json(body): Json<CreateMyItemRequest>,
) -> impl IntoResponse {
    let id = uuid::Uuid::new_v4().to_string();
    let item = MyItem {
        id: id.clone(),
        name: body.name,
        created_at: Utc::now(),
    };
    let mut items = state.items.write().await;
    items.insert(id, item.clone());
    (StatusCode::CREATED, Json(item)).into_response()
}

async fn list_items(State(state): State<MyServiceState>) -> impl IntoResponse {
    let items = state.items.read().await;
    let list: Vec<MyItem> = items.values().cloned().collect();
    (StatusCode::OK, Json(list)).into_response()
}

async fn get_item(
    State(state): State<MyServiceState>,
    Path(id): Path<String>,
) -> impl IntoResponse {
    let items = state.items.read().await;
    match items.get(&id) {
        Some(item) => (StatusCode::OK, Json(item.clone())).into_response(),
        None => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": "not found"})),
        ).into_response(),
    }
}

6. Library Root (src/lib.rs)

pub mod models;
pub mod state;
pub mod routes;

pub use state::MyServiceState;
pub use routes::my_service_routes;

7. Register in Workspace

Add to root Cargo.toml:

[workspace]
members = [
    # ...existing members...
    "platform/my-service",
]

[workspace.dependencies]
# ...existing deps...
kiiworks-my-service = { path = "platform/my-service" }

8. Register in Node

Add to platform/node/Cargo.toml:

[dependencies]
kiiworks-my-service = { workspace = true }

Add to platform/node/src/services.rs:

// My Service
let my_state = kiiworks_my_service::MyServiceState::new();
registry.register(
    "my-service",
    Some(kiiworks_my_service::my_service_routes().with_state(my_state)),
);

The .with_state(state) call is critical — it converts Router<MyServiceState> to Router<()> so the registry can merge it with other services.

9. Test

cargo build -p kiiworks-my-service
cargo test -p kiiworks-my-service
cargo build -p kiiworks-node  # Verify integration

Testing Patterns

Unit Tests (In-Module)

Most services include tests in their routes.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use tower::ServiceExt;

    #[tokio::test]
    async fn create_and_get_item() {
        let state = MyServiceState::new();
        let app = my_service_routes().with_state(state);

        // Create
        let response = app.clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/my-service/items")
                    .header("Content-Type", "application/json")
                    .body(Body::from(r#"{"name":"test"}"#))
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::CREATED);
    }
}

Integration Tests

Live integration tests against Kaspa Testnet-12:

# Requires kaspad running on Testnet-12
cargo run -p kiiworks-testnet-integration

These are a binary, not cargo test, because they require sequential execution and network access.


Common Gotchas

gen is a Reserved Keyword (Rust 2024)

The gen keyword is reserved in Rust edition 2024. When using rand:

// WRONG — won't compile
let x: u32 = rng.gen();

// CORRECT — use raw identifier
let x: u32 = rand::Rng::r#gen(&mut rng);

Clone Required for Axum State

Axum’s State extractor requires Clone. If you get:

the trait bound `MyState: Clone` is not satisfied

Add #[derive(Clone)] to your state struct.

Router vs Router<()>

Services export Router<ServiceState>. The node needs Router<()>. The conversion:

// This returns Router<MyServiceState>
let routes = my_service_routes();

// .with_state() converts to Router<()>
let routes_for_registry = routes.with_state(state);

UTXO Contention

When running multiple blockchain tests sequentially, UTXOs from previous transactions may not be confirmed yet. Add std::thread::sleep(Duration::from_secs(2)) between transaction-heavy test phases.

NetworkId Doesn’t Impl Copy

NetworkId has a Custom(String) variant, so it can’t implement Copy. Use .clone():

let network = config.network.clone(); // Not *config.network

blob-storage Catch-All Routes

The {*key} catch-all route in blob-storage conflicts with {*key}/metadata. Use separate path prefixes to avoid ambiguity.

cargo clippy —fix and Test Imports

cargo clippy --fix can remove imports that are only used in #[cfg(test)] modules. Always check test modules after running auto-fix.


Project Conventions

File Structure

Every platform service follows:

platform/{name}/
├── Cargo.toml
└── src/
    ├── lib.rs       # Re-exports
    ├── models.rs    # Serde structs
    ├── state.rs     # Clone state with Arc<RwLock<...>>
    └── routes.rs    # Axum handlers

Some services have additional files:

  • detector.rs (sentinel) — anomaly detection algorithms
  • engine.rs (workflow) — state machine logic
  • matcher.rs (circular) — geospatial matching
  • graph.rs (lineage) — DAG algorithms

Naming

  • Crate names: kiiworks-{name} (kebab-case)
  • Module imports: kiiworks_{name} (snake_case, Rust convention)
  • Service registration: "{name}" matches the platform directory name
  • Route prefix: /api/{service-name}/{service-name}/... (nested under api + service mount)

Error Responses

All services use consistent error responses:

{"error": "descriptive message"}

With appropriate HTTP status codes:

  • 201 Created — resource created
  • 200 OK — success
  • 204 No Content — deleted
  • 400 Bad Request — invalid input
  • 404 Not Found — resource doesn’t exist
  • 409 Conflict — duplicate or state conflict
  • 429 Too Many Requests — rate limited

Logging

Use tracing macros with structured fields:

use tracing::info;

info!(item_id = %id, "created item");
info!(count = items.len(), "listed items");

Never log secrets, private keys, or mnemonics.


CLI Development

The CLI lives at tools/cli/ and uses clap for argument parsing:

tools/cli/src/
├── main.rs          # Entry point, command dispatch
└── commands/
    ├── wallet.rs    # Wallet commands
    ├── did.rs       # DID commands
    ├── anchor.rs    # Anchoring commands
    ├── vc.rs        # Verifiable Credential commands
    ├── trusttag.rs  # Trust tag commands
    ├── node.rs      # Node start command
    └── config.rs    # Configuration commands

To add a new CLI command:

  1. Create tools/cli/src/commands/my_command.rs
  2. Add the subcommand to the clap enum in main.rs
  3. Implement the handler using library crates directly (no HTTP)

Build & Release

Development Build

cargo build --workspace

Release Build

cargo build --release -p kiiworks-cli
# Binary: target/release/kiiworks

Release profile (from Cargo.toml):

  • LTO enabled (link-time optimisation)
  • Single codegen unit (slower build, faster binary)
  • Symbols stripped
  • Panic = abort (smaller binary)

Lint

cargo clippy --workspace  # Must be 0 warnings

Format

cargo fmt --all  # Uses rustfmt.toml settings