1. Write your TEE contract - Terminal 3 Documentation

Documentation Index

Fetch the complete documentation index at: /llms.txt

Use this file to discover all available pages before exploring further.

Clone the reference implementation now rather than typing the Rust code by hand — it’s a separate project from the Node/TypeScript app you built in Quickstart, so put it in its own folder alongside it, not inside it:

cd ..                                                        # out of my-t3n-app, back to a shared parent folder
git clone https://github.com/Terminal-3/z-tenant-flight.git
cd z-tenant-flight

Below is a walkthrough of the pieces inside that repo — change the host calls and flight-specific logic to match your needs once you understand them. A TEE contract is a Rust crate compiled to a WASM component. It exports its functions through a contracts WIT interface and imports only the host capabilities it needs.

Key concepts and tips before starting:

Repository Structure

z-tenant-flight/
├── src/
│   ├── lib.rs          ← wit-bindgen entry point + Guest impl that dispatches to each fn
│   ├── search.rs       ← search-offers — Duffel search (no PII)
│   └── booking.rs      ← book-offer — Duffel booking (PII via http-with-placeholders)
├── wit/
│   ├── world.wit       ← the world your contract exports + the host interfaces it imports
│   └── deps/           ← vendored host interface packages (host-interfaces, host-tenant)
└── Cargo.toml

The packages under wit/deps/ define the host ABI your contract links against — vendor the versions your target cluster provides (here, host-interfaces-2.1.0/ and host-tenant-1.0.0/).

Files

world.wit — declare your interface + host imports

package z:tenant-flight@0.4.0;

world tenant-flight {
  import host:tenant/tenant-context@1.0.0;
  import host:interfaces/logging@2.1.0;
  import host:interfaces/kv-store@2.1.0;
  import host:interfaces/http@2.1.0;                    // search (no PII)
  import host:interfaces/http-with-placeholders@2.1.0;  // booking (PII via placeholders)

export contracts;
}

interface contracts {
  // Uniform 3-field envelope used by every node-callable contract.
  //   input        — JSON arguments for this function, as bytes
  //   user-profile — None for tenant contracts (profile is resolved host-side)
  //   context      — node-minted DynamicContext (trusted), as bytes
  record generic-input {
    input:        option<list<u8>>, 
    user-profile: option<list<u8>>, 
    context:      option<list<u8>>,  
  }

// One func per operation. Each takes generic-input and returns JSON bytes on
  // success, or an error string. There is no central `dispatch` function and no
  // `ContractError` enum — the function name *is* the export.
  search-offers: func(req: generic-input) -> result<list<u8>, string>;
  book-offer:    func(req: generic-input) -> result<list<u8>, string>;
}

Cargo.toml — compile to a WASM component

[package]
name = "z-tenant-flight"
version = "0.4.1"
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/ at compile time.
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 — generate bindings + dispatch to each function

wit_bindgen::generate!({
    world: "tenant-flight",
    path: "wit",
    additional_derives: [\
        serde::Deserialize,\
        serde::Serialize,\
    ],
    generate_all,
});

mod booking;
mod search;

struct Component;

// Implement the exported `contracts` interface. Each generated method unwraps
// the input bytes and hands off to the module that does the work.
#[cfg(target_arch = "wasm32")]
impl exports::z::tenant_flight::contracts::Guest for Component {
    fn search_offers(req: exports::z::tenant_flight::contracts::GenericInput) -> Result<Vec<u8>, String> {
        let input = req.input.ok_or("search-offers: missing input")?;
        search::search_offers(&input)
    }

fn book_offer(req: exports::z::tenant_flight::contracts::GenericInput) -> Result<Vec<u8>, String> {
        let input = req.input.ok_or("book-offer: missing input")?;
        booking::book_offer(&input)
    }
}

#[cfg(target_arch = "wasm32")]
export!(Component);

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

The http interface is synchronous: the response is available before the call returns. Build a Request with a Verb, headers, and an optional payload.

use crate::host::interfaces::{http as http_iface, logging};

let resp = http_iface::call(&http_iface::Request {
    method: http_iface::Verb::Post,
    url: format!("{DUFFEL_BASE}/air/offer_requests?return_offers=false"),
    headers: Some(duffel_headers(&api_key)),         // Vec<(String, String)>
    payload: Some(serde_json::to_vec(&offer_request_body).map_err(|e| e.to_string())?),
})
.map_err(|e| format!("duffel offer-request: {e}"))?;

if resp.code != 201 {
    let body = String::from_utf8_lossy(&resp.payload);
    return Err(format!("Duffel offer-request failed: HTTP {} — {body}", resp.code));
}
let _ = logging::info("offer request created");
// resp.payload holds the response bytes — parse with serde_json.

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

For calls that carry user PII, use http-with-placeholders. Put {{profile.<field>}} markers in the request body; the host resolves them from the calling user’s profile at dispatch time, so plaintext PII never enters WASM memory.

use crate::host::interfaces::http_with_placeholders as hwp;
use serde_json::json;

let order_body = json!({
    "data": {
        "type": "instant",
        "selected_offers": [req.offer_id],
        "passengers": [{\
            "id": req.passenger_id,                              // opaque Duffel id — not PII\
            // Resolved host-side from the user's profile (PII never enters WASM):\
            "given_name":  "{{profile.first_name}}",
            "family_name": "{{profile.last_name}}",
            "born_on":     "{{profile.date_of_birth}}",
            "email":       "{{profile.verified_contacts.email.value}}",
        }],
        "payments": [{ "type": "balance", "amount": req.total_amount, "currency": req.total_currency }]
    }
});

let resp = hwp::call(&hwp::Request {
    method: hwp::Verb::Post,
    url: format!("{DUFFEL_BASE}/air/orders"),
    headers: Some(duffel_headers(&api_key)),
    payload: Some(serde_json::to_vec(&order_body).map_err(|e| e.to_string())?),
})
.map_err(|e| format!("duffel create-order: {{}}", format_http_error(e)))?;

Reading secrets from the secrets KV map

The API key is read from the tenant’s secrets KV map at runtime. The key is seeded by the tenant SDK before the contract runs — there is no set-credentials host function. kv-store calls take the fullz:<tid>:<map> name; build it from tenant-context at runtime (the host enforces the prefix):

use crate::host::{interfaces::kv_store, tenant::tenant_context};

fn get_api_key() -> Result<String, String> {
    // tenant_did() already returns the tid as a string — do not hex::encode it again.
    // (Wrapping it in hex::encode a second time is a real bug some teams have hit:
    // it silently produces a map path that doesn't match anything you created.)
    let tid = tenant_context::tenant_did();
    let map_name = format!("z:{}:secrets", tid);
    let bytes = kv_store::get(&map_name, b"duffel_api_key")
        .map_err(|e| format!("kv read: {e}"))?
        .ok_or("duffel_api_key not found in z:<tid>:secrets — populate it via the tenant SDK before use")?;
    String::from_utf8(bytes).map_err(|e| e.to_string())
}

Key Design Rules