04 · Verifiable execution with Nautilus TEE

The capability answers “is this action within the rules?” Verifiable execution answers a harder question: “did this decision actually come from the untampered agent code I trust, and not a hijacked bot, a prompt-injected model, or a tampered host?”

Ballast answers it with Nautilus. The agent’s trade decision is signed inside an AWS Nitro enclave, and the chain verifies that signature against a key it has proven belongs to that exact enclave code, before the trade settles.

This is the path that trading::execute_trade_attested and attestation::verify_nautilus implement. It is live and proven on testnet end to end, with a real attested fill and a real forged-signature rejection (digests below). The mock verify path is unchanged and remains the default fallback.

Diagram 4: Attested execution sequence

sequenceDiagram
    autonumber
    participant Agent as Agent service
    participant Enc as Nitro enclave (/process_data)
    participant Chain as Sui (execute_trade_attested)
    participant Att as attestation::verify_nautilus
    participant Cap as capability::enforce
    participant DB as DeepBook pool

    Agent->>Enc: POST {agent, market, amount, is_bid}
    Note over Enc: stamp trusted timestamp_ms,<br/>BCS-encode IntentMessage (TradeAttestation),<br/>Ed25519-sign with attested key
    Enc-->>Agent: { response, signature }
    Agent->>Chain: PTB execute_trade_attested(.., enclave, ts, sig)
    Chain->>Att: verify_nautilus(enclave, ts, sender, market, amount, is_bid, sig)
    Att-->>Chain: true (sig matches proven key)
    Chain->>Cap: enforce(cap, market, amount, lev, clock)
    Cap-->>Chain: ok (within mandate)
    Chain->>DB: place_market_order(pay_with_deep = false)
    DB-->>Chain: filled
    Chain->>Chain: record_ok + emit TradeExecuted

Real receipt: the attested fill is testnet digest 2aPiwNyNtUCjbW2dkovw1aTpo1vYe7rLb4wvHAG5w3pM. It is a SELL of 1 SUI, signature verified on-chain, reputation 5 to 6, TradeExecuted emitted. Note that verify_nautilus runs before enforce, so a bad signature dies even earlier than a bad amount.

On-chain verification: verify_nautilus

From move/sources/attestation.move:

public fun verify_nautilus(
    enclave: &Enclave<BallastEnclave>,
    timestamp_ms: u64, agent: address, market: address,
    amount: u64, is_bid: bool, sig: vector<u8>,
): bool {
    let payload = TradeAttestation { agent, market, amount, is_bid };
    enclave.verify_signature(TRADE_INTENT, timestamp_ms, payload, &sig)
}

The Enclave<BallastEnclave> object holds the enclave’s public key, proven at registration (next section). The trade is bound to {agent, market, amount, is_bid}, so a signature cannot be replayed onto a different trade. Change any field and the Ed25519 check fails. The market and agent (ctx.sender()) passed to verify_nautilus in execute_trade_attested are derived from the real pool object and transaction sender, not free-form arguments.

The Rust ↔ Move BCS contract

The enclave (Rust) and the chain (Move) must serialize the signed message to the exact same bytes, or every signature fails. That contract is pinned on both sides.

IntentMessage {
    intent:       u8     // 1 byte   (TRADE_INTENT = 0)
    timestamp_ms: u64    // 8 bytes  (little-endian)
    payload: TradeAttestation {
        agent:  address  // 32 bytes (fixed, no length prefix)
        market: address  // 32 bytes
        amount: u64      // 8 bytes
        is_bid: bool     // 1 byte
    }
}
                         // total = 82 bytes, deterministic

The agent reads the enclave’s timestamp_ms and signature back out of the HTTP response and passes that exact timestamp to execute_trade_attested, so the message the chain rebuilds is byte-identical to what was signed. On the first live run the BCS layout matched with no iteration.

Diagram 5: Nautilus trust establishment

How an enclave’s key becomes trusted on-chain. This is the heavy lift. It turns “a server claims it ran my code” into “the chain proved it.”

flowchart TB
    subgraph build["Build (local, reproducible)"]
        eif["Build EIF (Docker)<br/>out/nitro.eif — 105 MB<br/>sha256 815308eb…b28e21"]
        pcrs["Measure PCR0/1/2<br/>(SHA-384 of the image)"]
    end
    subgraph register["Register expected identity on-chain"]
        init["attestation::init_attestation(pcr0,1,2)<br/>digest AbSfMc54…<br/>→ EnclaveConfig 0xd41be8d6…45ef781"]
    end
    subgraph run["Run on AWS Nitro hardware"]
        nitro["nitro-cli run-enclave<br/>(fresh Ed25519 keypair inside)"]
        doc["Fetch AWS attestation document<br/>(/get_attestation)"]
    end
    subgraph verify["Prove it on-chain"]
        reg["enclave::register_enclave<br/>framework verifies AWS cert chain<br/>(sui::nitro_attestation)<br/>+ asserts PCRs == EnclaveConfig<br/>digest 3LRQyhW3…"]
        obj["Enclave (BallastEnclave)<br/>0xc2a179…51ba1<br/>stores attested pk 389fb0eb…d659f4"]
    end

    eif --> pcrs --> init
    eif -->|"copy exact image"| nitro
    nitro --> doc --> reg
    init -.->|"expected PCRs"| reg
    reg --> obj

Annotated real values:

The PCRs are registered before the enclave runs, so register_enclave can only succeed if the running enclave’s measurements match the code we committed to. Sui’s framework sui::nitro_attestation verifies the AWS certificate chain inside the attestation document, anchoring trust to the AWS Nitro root.

Diagram 6: AWS Nitro deployment & security boundary

flowchart LR
    subgraph ec2["EC2 host (UNTRUSTED)"]
        socat["socat bridge<br/>TCP :3000 ⇄ vsock"]
        subgraph nitro["Nitro enclave (TRUSTED, isolated)"]
            app["ballast-trader app<br/>ephemeral Ed25519 key<br/>never leaves the enclave"]
        end
    end
    aws["AWS Nitro<br/>attestation root (cert chain)"]
    agent["Agent service"]

    agent -->|"HTTP :3000"| socat
    socat <-->|"vsock"| app
    aws -.->|"signs attestation doc<br/>binding PCRs + pubkey"| app
    app -->|"attestation doc + sig"| socat

The enclave is isolated from its own host. The EC2 box cannot read the enclave’s memory or its private key, and communicates only over a vsock channel. The host is treated as untrusted. What makes the enclave’s output trustworthy is the AWS-signed attestation document, not the host. (Operational detail: the stock server reads an API_KEY env var on boot, so the secrets blob sent to vsock:7777 must be {"API_KEY":"unused"}, not {}. See enclave/EC2-RUNBOOK.md.)

Diagram 7b: A forged signature is rejected

sequenceDiagram
    autonumber
    participant Agent as Attacker
    participant Chain as Sui (execute_trade_attested)
    participant Att as attestation::verify_nautilus
    participant Cap as capability::enforce
    participant DB as DeepBook
    Agent->>Chain: execute_trade_attested(.., enclave, ts, FORGED 64-byte sig)
    Chain->>Att: verify_nautilus(enclave, ..)
    Note over Att: Ed25519 check vs proven key FAILS
    Att-->>Chain: false
    Chain-->>Agent: abort 1 (EAttestationFailed) — reverts
    Note over Cap,DB: never reached

Real receipt: forged-signature rejection is testnet digest DMUp8tHgMaB51HKpkJd9hfv9kQvkJvee5Hi69oS9ve2Q. A bogus 64-byte signature against the same registered Enclave aborts in trading::execute_trade_attested with code 1 (EAttestationFailed) before enforce or DeepBook. It was captured against the persisted on-chain Enclave object, so the enclave host itself need not be running for the rejection path.


Engineering deep-dive: the hard problems

This section is candid about where the real difficulty was. None of it is visible in a demo, but it is what makes the rest real.

1. AWS Nitro bring-up

A Mac cannot nitro-cli run-enclave; the enclave needs real Nitro hardware. The exact locally-built out/nitro.eif (105 MB, sha256 815308eb5314b640cd9539951e96d6f2fbfd706a57095edc1e43b17094b28e21) was scp‘d to a Nitro EC2 instance (us-east-1, ~$0.19/hr) and run there. Two traps cost real time, both documented in enclave/EC2-RUNBOOK.md:

2. The reproducible EIF build & PCR match

register_enclave only succeeds if the running enclave’s PCRs equal the ones registered by init_attestation. Rather than trust reproducibility across two machines, we registered the PCRs of the locally-built image and then copied that exact .eif to the host instead of rebuilding it. That removes all doubt that the bytes measured on-chain are the bytes running. The Ballast app is dropped into the official MystenLabs/nautilus template by overwriting weather-example in place, which avoids touching Cargo features or lib.rs. The single added dependency (hex = "0.4") must go under [dependencies] with its own newline, or Cargo aborts.

The original v1 package linked DeepBook at 0x74cd5657…. The live testnet pools reject orders routed through that package version with abort code 11 (version disabled), so any execute_trade that reached the venue would have aborted at DeepBook, not in our code. The fix was an in-place package upgrade (v2) that vendors the DeepBook dependency at move/deps/deepbook with its published-at set to the pool-accepted call target 0x22be4cade64bf2d02412c7e8d0e8beea2f78828b948118d46735315409371a3c, while keeping the type origin at 0xfb28c4cbc6865bd1c897d26aecbe1f8792d1509a20ffec692c800660cbec6982. Because the upgrade preserves Ballast’s own type origin (0xe12fe8ef…), the already-issued AgentIdentity and BallastCap objects survive untouched. See 05 · Deployments for the lineage.

4. The zero-DEEP fill path

DeepBook fees are normally paid in DEEP. Requiring the agent to hold DEEP would add friction and a second token to fund. Ballast places orders with pay_with_deep = false, so fees are taken from the input token: a SELL of SUI pays its fee from SUI. The live fill confirms taker_fee_is_deep = false. This is why the demo needs no DEEP at all. Fund the BalanceManager with SUI, sell SUI against resting bids, done.