Skip to main content
You may follow the sample flight booking project to get started quickly. All you need to do is change the host calls and flight-specific logic to match your needs. Below is a minimal walkthrough of the relevant pieces.
Key concepts and tips before starting:

Repository Structure

your-contract/
├── src/
│   ├── lib.rs          ← WIT export entry point + function dispatch
│   ├── search.rs       ← search-offers — Duffel search (no PII)
│   └── booking.rs      ← book-offer — Duffel booking (PII via http-with-placeholders)
├── wit/
│   └── world.wit       ← host interfaces your contract imports
└── Cargo.toml

Files

world.wit — declare host imports

package acme:travel-contract@0.1.0;

world contract {
  import t3n:host/kv-store@0.1.0;
  import t3n:host/logging@0.1.0;
  import t3n:host/tenant-context@0.1.0;
  import t3n:host/http-iface@0.1.0;   // synchronous HTTPcall a third-party API
  // PII-safe variant: the host substitutes {{profile.*}} markers from the
  // caller's profile so plaintext PII never enters the contract.
  import t3n:host/http-with-placeholders@0.1.0;

  export t3n:contract/dispatch@0.1.0;
}
The interfaces you import here are what your contract can use — there is no separate manifest. The host links your contract against the matching tenant-* world and refuses to load it if it imports an interface that isn’t part of any tenant world.

Cargo.toml — compile to a WASM component

[package]
name = "your-contract"
version = "0.1.0"
edition = "2021"

# crate-type cdylib is what makes the wasm32-wasip2 target emit a
# WASM *component* (not a bare module). Keep "lib" too so the
# business logic stays unit-testable natively.
[lib]
crate-type = ["cdylib", "lib"]

[dependencies]
# wit-bindgen's macro generates the bindings from wit/world.wit at
# compile time — no separate codegen step.
wit-bindgen = { version = "0.49", default-features = false, features = ["macros", "realloc"] }
serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] }
serde_json = { version = "1.0", default-features = false, features = ["alloc"] }

# Small, self-contained artifact — keeps registration under the size cap.
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = true

lib.rs — contract entry point

wit_bindgen::generate!({ world: "contract", path: "wit" });
use exports::t3n::contract::dispatch::{Guest, ContractInput, ContractOutput, ContractError};

struct Component;

impl Guest for Component {
    fn dispatch(input: ContractInput) -> Result<ContractOutput, ContractError> {
        match input.function.as_str() {
            "search-offers" => search::search_offers(input),  // no PII — plain http
            "book-offer"    => booking::book_offer(input),    // PII via http-with-placeholders
            other => Err(ContractError::UnknownFunction { name: other.to_string() }),
        }
    }
}

export!(Component);

search.rs — search_offers (synchronous http, no PII)

use crate::bindings::t3n::host::{http_iface, logging};

pub fn search_offers(input: ContractInput) -> Result<ContractOutput, ContractError> {
    let req: SearchOffersRequest = serde_json::from_slice(&input.payload)
        .map_err(|e| ContractError::InvalidInput { detail: e.to_string() })?;
    let api_key = duffel_api_key()?;

    // Synchronous HTTP — the response is available in the same call (no PII in flight).
    let resp = http_iface::call(&http_iface::Request {
        method: "POST".to_string(),
        url: "https://api.duffel.com/air/offer_requests?return_offers=false".to_string(),
        headers: vec![
            ("Authorization".to_string(), format!("Bearer {api_key}")),
            ("Duffel-Version".to_string(), "v2".to_string()),
        ],
        body: Some(serde_json::to_vec(&req)?),
    })?;

    logging::info("searched offers");
    Ok(ContractOutput { payload: resp.body })
}
Outbound HTTP is authorized by the user, not the contract.

booking.rs — book_offer (PII via http-with-placeholders)

fn book_offer_wasm(req: BookOfferReq) -> Result<Booking, String> {
    use serde_json::json;

    let api_key = get_api_key()?;
    let order_body = json!({
        "data": {
            "type": "instant",
            "selected_offers": [req.offer_id],
            "passengers": [{
                "id": req.passenger_id,
                // Resolved from the user's profile (privacy-preserving path):
                "given_name": "{{profile.first_name}}",
                "family_name": "{{profile.last_name}}",
                "born_on": "{{profile.date_of_birth}}",
                "gender": "{{profile.gender}}",
                "email": "{{profile.verified_contacts.email.value}}",
                // Demo-hardcoded (no profile source):
                "title": "mr",
                "passport_number": "X12345678",
                "passport_country_code": "GB",
                "passport_expiry_date": "2030-01-01",
                "phone_number": "+442071234567",
            }],
            "payments": [{
                "type": "balance",
                "amount": req.total_amount,
                "currency": req.total_currency,
            }]
        }
    });
book-offer is the PII-bearing counterpart — it uses http-with-placeholders so the passenger’s name, DOB, and email never enter the contract. api_key is read from the secrets map. The key is seeded by the SDK. There is no set-credentials function. Callers read it from the secrets map (tail only — the host prefixes z:<tid>:):
use crate::bindings::t3n::host::kv_store;

fn duffel_api_key() -> Result<String, ContractError> {
    let bytes = kv_store::get("secrets", "duffel_api_key")?
        .ok_or(ContractError::CredentialsNotConfigured)?;
    String::from_utf8(bytes)
        .map_err(|_| ContractError::InternalError { detail: "invalid api key encoding".into() })
}

Key Design Rules

  • kv_store::get / kv_store::put take the map tail only — never z:<tid>:. The host prefixes it based on your contract’s tenant identity.
  • Error types use Result<T, ContractError> with one variant per failure reason. Never return bool or a stringly-typed error string.
  • http_iface::call is synchronous — you get the response back before the function returns. Use it when you need to act on the API response in the same call.
  • For calls carrying user PII, use http-with-placeholders: put {{profile.<field>}} markers in the request and the host resolves them from the caller’s profile inside the enclave, so plaintext PII never enters your contract.