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
- Move side: the test
bcs_layout_is_stableinattestation.moveasserts the encoding is deterministic and exactly 82 bytes. - Rust side:
enclave/ballast-trader/mod.rsdeclaresTradeAttestation { agent: [u8;32], market: [u8;32], amount: u64, is_bid: bool }with the same field order, andaddress ↔ [u8; 32](BCS writes 32 fixed bytes, no length prefix).
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:
- PCR0 = PCR1 =
d2f558244d778dc8c8553f3a5a10adf3e2fd146bd02e4f7b928af6906bd303dff0d6899f5b4b571f9fdaa75c5b101582 - PCR2 =
21b9efbc184807662e966d34f390821309eeac6802309798826296bf3e8bec7c10edb30948c90ba67310f7b964fc500a init_attestationdigestAbSfMc54…, EnclaveConfig0xd41be8d6…register_enclavedigest3LRQyhW3…, createdEnclave<BallastEnclave>0xc2a179…51ba1- attested public key
389fb0ebd4db1e2eb3e5fbdac9903ff852feb1f09fd445186a980b45bfd659f4
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:
--debug-modezeroes the PCRs. It is convenient for reading boot logs but fatal for attestation: a debug enclave reports all-zero measurements that will never match the registered config. Production runs must omit it.- The
API_KEYboot gate. The stock server panics on boot ifAPI_KEYis unset. The panic exitsrun.sh, which kills thevsock:3000listener, so every request times out while the enclave still showsRUNNING. The fix is sending{"API_KEY":"unused"}(the app doesn’t use the value) tovsock:7777.
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.
3. The DeepBook version-gating relink (0x74cd… → 0x22be…)
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.