Agent documentation index: llms.txt. Markdown versions of documentation pages are available by appending .md to the page URL.
Skip to main content

Build Your First Extension

Build your first Flare Confidential Compute (FCC) extension from the Hello World scaffold example. This guide walks you through the setup and testing flow, how extensions fit into the TEE stack, which files you customize, and how to deploy and test on Coston2 testnet.

Find the scaffold example on GitHub. For background on FCC and Flare Compute Extensions (FCE), see the FCC overview.

What you will build

A Go HTTP server that runs inside a Trusted Execution Environment (TEE). Onchain callers send instructions through your InstructionSender smart contract; the TEE infrastructure relays them to your extension's POST /action handler, and you return results that callers poll from the extension proxy.

Overview

The scaffold ships a working GREETING extension with two commands:

  1. SAY_HELLO — JSON payload {"name":"..."}; returns a greeting string and increments a counter.
  2. SAY_GOODBYE — ABI-encoded (name, reason); returns a farewell string and increments a counter.

First, clone the scaffold and its build dependencies so you can move on to the instruction lifecycle and the files you own. Understand the instruction lifecycle and the files you own. Then, deploy the Hello World smart contract and TEE stack against the Coston2 testnet. Finally, customize the OPType and OPCommand handlers, as well as the on-chain entry point, for your own extension.

How an Extension Works

An extension does not communicate directly with the chain. A user (or another contract) calls your Solidity InstructionSender smart contract, which calls the TeeExtensionRegistry.sendInstructions() function. Data providers relay the instruction; your local ext-proxy queues it; the TEE node delivers it to your extension.

You control steps that start and end the flow: the onchain InstructionSender smart contract and the Go action handlers. Attestation, signing, and message routing are handled by the TEE node and proxy.

Architecture

The extension stack runs as three Docker services:

  • extension-tee TEE node: TEE node plus your Go extension (listens for POST /action and GET /state).
  • ext-proxy proxy server: Watches Coston2 testnet for instructions targeting your extension, forwards them to the TEE, and exposes results and /info on the public proxy URL.
  • redis database: In-memory store used by the proxy for queue and internal state.

Clone the Scaffold Example

This example's Docker build scripts expect tee-node, tee-proxy, and the scaffold as sibling directories under a common parent — the extension module uses a replace directive for tee-node, and start-services.sh builds tee-proxy unless you set REGISTRY to a remote image registry. Clone the repositories in the layout below before you build.

mkdir -p tee/extension-examples
cd tee

git clone https://github.com/flare-foundation/tee-node.git
git clone https://github.com/flare-foundation/tee-proxy.git
git clone https://github.com/flare-foundation/fce-extension-scaffold.git \
extension-examples/extension-scaffold

cd extension-examples/extension-scaffold

Your tree should look like this:

tee/
├── tee-node/
├── tee-proxy/
└── extension-examples/
└── extension-scaffold/ # working directory for the rest of this guide

Hello World Onchain Smart Contract

The HelloWorldInstructionSender smart contract is the only onchain entry point for your operations, and it connects the rest of the flow.

It talks to:

  • TeeExtensionRegistry — registers extensions and accepts sendInstructions calls that require a fee.
  • TeeMachineRegistry — returns random TEE machine IDs for your extension via getRandomTeeIds function.
View the code
contracts/InstructionSender.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;

// TODO: Replace local interfaces with imports from flare-smart-contracts-v2 once published as a package.
import { ITeeExtensionRegistry } from "./interfaces/ITeeExtensionRegistry.sol";
import { ITeeMachineRegistry } from "./interfaces/ITeeMachineRegistry.sol";

/// @title HelloWorldInstructionSender
/// @author Flare Foundation
/// @notice Hello World example — on-chain entry point for sending instructions to the TEE.
///
/// DO NOT MODIFY: constructor, setExtensionId(), _getExtensionId()
contract HelloWorldInstructionSender {
/// @notice Operation type for greeting actions (SAY_HELLO, SAY_GOODBYE).
// forge-lint: disable-next-line(unsafe-typecast)
bytes32 public constant OP_TYPE_GREETING = bytes32("GREETING");

/// @notice Command for the SAY_HELLO action.
// forge-lint: disable-next-line(unsafe-typecast)
bytes32 public constant OP_COMMAND_SAY_HELLO = bytes32("SAY_HELLO");

/// @notice Command for the SAY_GOODBYE action.
// forge-lint: disable-next-line(unsafe-typecast)
bytes32 public constant OP_COMMAND_SAY_GOODBYE = bytes32("SAY_GOODBYE");

/// @notice Reference to the TEE extension registry contract.
ITeeExtensionRegistry public immutable TEE_EXTENSION_REGISTRY;
/// @notice Reference to the TEE machine registry contract.
ITeeMachineRegistry public immutable TEE_MACHINE_REGISTRY;

/// @notice First public extension ID. The registry reserves IDs below this
/// for system/reserved extensions; public extensions are assigned from here up.
uint256 private constant FIRST_PUBLIC_EXTENSION_ID = 0x10000; // 65536

uint256 private _extensionId;

/// @notice Payload for the SAY_GOODBYE instruction.
struct SayGoodbyeMessage {
string name;
string reason;
}

/// @notice Initializes the contract with registry addresses.
/// @param _teeExtensionRegistry Address of the TEE extension registry.
/// @param _teeMachineRegistry Address of the TEE machine registry.
constructor(
ITeeExtensionRegistry _teeExtensionRegistry,
ITeeMachineRegistry _teeMachineRegistry
) {
require(address(_teeExtensionRegistry) != address(0), "TeeExtensionRegistry cannot be zero address");
require(address(_teeMachineRegistry) != address(0), "TeeMachineRegistry cannot be zero address");
require(address(_teeExtensionRegistry).code.length > 0, "TeeExtensionRegistry has no code");
require(address(_teeMachineRegistry).code.length > 0, "TeeMachineRegistry has no code");
TEE_EXTENSION_REGISTRY = _teeExtensionRegistry;
TEE_MACHINE_REGISTRY = _teeMachineRegistry;
}

/// @notice Finds and sets this contract's extension id. Can only be set once.
/// DO NOT MODIFY this function.
function setExtensionId() external {
require(_extensionId == 0, "Extension ID already set.");

uint256 c = TEE_EXTENSION_REGISTRY.nextPublicExtensionId();
for (uint256 i = FIRST_PUBLIC_EXTENSION_ID; i < c; ++i) {
if (TEE_EXTENSION_REGISTRY.getTeeExtensionInstructionsSender(i) == address(this)) {
_extensionId = i;
return;
}
}
revert("Extension ID not found.");
}

/// @notice Sends a SAY_HELLO instruction to the TEE.
/// @param _message JSON-encoded payload (e.g. {"name": "Alice"}).
function sendSayHello(bytes calldata _message) external payable {
address[] memory teeIds = TEE_MACHINE_REGISTRY.getRandomTeeIds(_getExtensionId(), 1);
address[] memory cosigners = new address[](0);

ITeeExtensionRegistry.TeeInstructionParams memory params = ITeeExtensionRegistry.TeeInstructionParams({
opType: OP_TYPE_GREETING,
opCommand: OP_COMMAND_SAY_HELLO,
message: _message,
cosigners: cosigners,
cosignersThreshold: 0,
claimBackAddress: msg.sender
});


TEE_EXTENSION_REGISTRY.sendInstructions{value: msg.value}(
teeIds,
params
);
}

/// @notice Sends a SAY_GOODBYE instruction to the TEE.
/// @param _name The name of the person to say goodbye to.
/// @param _reason The reason for saying goodbye.
function sendSayGoodbye(string calldata _name, string calldata _reason) external payable {
address[] memory teeIds = TEE_MACHINE_REGISTRY.getRandomTeeIds(_getExtensionId(), 1);
address[] memory cosigners = new address[](0);

ITeeExtensionRegistry.TeeInstructionParams memory params = ITeeExtensionRegistry.TeeInstructionParams({
opType: OP_TYPE_GREETING,
opCommand: OP_COMMAND_SAY_GOODBYE,
message: abi.encode(SayGoodbyeMessage({name: _name, reason: _reason})),
cosigners: cosigners,
cosignersThreshold: 0,
claimBackAddress: msg.sender
});

TEE_EXTENSION_REGISTRY.sendInstructions{value: msg.value}(
teeIds,
params
);
}

/// @notice Returns the cached extension ID, reverting if not yet set.
/// @return The extension ID assigned to this contract.
function _getExtensionId() internal view returns (uint256) {
require(_extensionId != 0, "Extension ID is not set.");
return _extensionId;
}
}
note

Constructor arguments are the two Flare system registry addresses, which the deploy tooling reads from the config/coston2/deployed-addresses.json file on Coston2. On Coston2, they are already deployed; the deploy tooling reads them from the config/coston2/deployed-addresses.json file. This is temporary while FCC is in development. On release, addresses will be available through the FlareContractRegistry contract.

Customizing the Extension

The scaffold is a complete Hello World example. The HTTP server and deploy scripts are ready to use — customize these files so OPType and OPCommand strings stay aligned across Solidity and Go:

#FileWhat you change
1internal/config/config.goOPType and OPCommand string constants and SemVer Version
2pkg/types/types.goRequest, response, and state structs
3internal/extension/extension.goRouting and handlers
4pkg/types/register.goDecoders for the types server
5contracts/InstructionSender.solMatching bytes32 constants and send functions
6tools/cmd/run-test/main.goE2E payloads and assertions

OPType and OPCommand Must Match

Solidity stores commands as bytes32("..."). Go compares hashed string constants with teeutils.ToHash(...). The strings must be identical in all three places:

bytes32 constant OP_TYPE_GREETING       = bytes32("GREETING");
bytes32 constant OP_COMMAND_SAY_HELLO = bytes32("SAY_HELLO");
bytes32 constant OP_COMMAND_SAY_GOODBYE = bytes32("SAY_GOODBYE");
const (
OPTypeGreeting = "GREETING"
OPCommandSayHello = "SAY_HELLO"
OPCommandSayGoodbye = "SAY_GOODBYE"
)
case dataFixed.OPType == teeutils.ToHash(config.OPTypeGreeting):
// then sub-route on OPCommand
warning

Mismatch of the OPType or OPCommand strings is the most common cause of unsupported op type / unsupported op command responses.

Config Constants

internal/config/config.go
// Package config contains configuration values and defaults used by the extension.
package config

import (
"os"
"strconv"
"time"
)

const (
Version = "0.1.0"

OPTypeGreeting = "GREETING"
OPCommandSayHello = "SAY_HELLO"
OPCommandSayGoodbye = "SAY_GOODBYE"

TimeoutShutdown = 5 * time.Second
)

// Defaults.
var (
ExtensionPort = 8080
SignPort = 9090
TypesServerPort = 8100
)

// Environment variables override defaults.
func init() {
ep := os.Getenv("EXTENSION_PORT")
sp := os.Getenv("SIGN_PORT")
tp := os.Getenv("TYPES_SERVER_PORT")

if ep != "" {
if v, err := strconv.Atoi(ep); err == nil {
ExtensionPort = v
}
}
if sp != "" {
if v, err := strconv.Atoi(sp); err == nil {
SignPort = v
}
}
if tp != "" {
if v, err := strconv.Atoi(tp); err == nil {
TypesServerPort = v
}
}
}

Increment Version when behavior or the onchain interface changes, because the TEE registration path treats code version as part of the extension lifecycle.

Request and Response Types

pkg/types/types.go
// Package types contains types that could be useful to other apps when interacting with this extension.
package types

import (
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)

// SayHelloRequest is the JSON payload sent via the Solidity contract.
type SayHelloRequest struct {
Name string `json:"name"`
}

// SayHelloResponse is the JSON payload returned in ActionResult.Data.
type SayHelloResponse struct {
Greeting string `json:"greeting"`
GreetingNumber int `json:"greetingNumber"`
}

// SayGoodbyeRequest is the ABI-decoded payload sent via the Solidity contract.
type SayGoodbyeRequest struct {
Name string `json:"name"`
Reason string `json:"reason"`
}

// SayGoodbyeResponse is the JSON payload returned in ActionResult.Data.
type SayGoodbyeResponse struct {
Farewell string `json:"farewell"`
FarewellNumber int `json:"farewellNumber"`
}

// SayGoodbyeMessageArg describes the ABI layout of SayGoodbyeMessage from the Solidity contract.
var SayGoodbyeMessageArg abi.Argument

func init() {
tupleTy, _ := abi.NewType("tuple", "", []abi.ArgumentMarshaling{
{Name: "name", Type: "string"},
{Name: "reason", Type: "string"},
})
SayGoodbyeMessageArg = abi.Argument{Type: tupleTy}
}

// State holds the extension's observable state, returned by GET /state.
type State struct {
GreetingCount int `json:"greetingCount"`
LastGreeting string `json:"lastGreeting"`
FarewellCount int `json:"farewellCount"`
LastFarewell string `json:"lastFarewell"`
}

// --- DO NOT MODIFY below this line. ---

// StateResponse is the envelope returned by GET /state.
type StateResponse struct {
StateVersion common.Hash `json:"stateVersion"`
State State `json:"state"`
}

The SAY_HELLO command uses JSON in OriginalMessage. The SAY_GOODBYE command uses ABI encoding so the contract can accept typed string arguments and abi.encode them for the TEE.

Handlers

Each handler follows the same pattern: decode, validate, execute under a mutex if you touch shared state, then call buildResult.

Status values in ActionResult:

  • 0 — error (message in Log)
  • 1 — success
  • ≥ 2 — pending (async work; later POST final result to the node)
internal/extension/extension.go
package extension

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sync"

"extension-scaffold/internal/config"
"extension-scaffold/pkg/types"

"github.com/flare-foundation/go-flare-common/pkg/tee/instruction"
"github.com/flare-foundation/go-flare-common/pkg/tee/structs"
teetypes "github.com/flare-foundation/tee-node/pkg/types"
teeutils "github.com/flare-foundation/tee-node/pkg/utils"

"github.com/flare-foundation/tee-node/pkg/processorutils"
)

type Extension struct {
mu sync.RWMutex
Server *http.Server

greetingCount int
lastGreeting string
farewellCount int
lastFarewell string
}

// --- DO NOT MODIFY: New(), actionHandler() are boilerplate.
func New(extensionPort, signPort int) *Extension {
e := &Extension{}

mux := http.NewServeMux()
mux.HandleFunc("GET /state", e.stateHandler)
mux.HandleFunc("POST /action", e.actionHandler)

e.Server = &http.Server{Addr: fmt.Sprintf(":%d", extensionPort), Handler: mux}
return e
}

// stateHandler() structure is boilerplate but update the State field mapping to match your Extension fields.
func (e *Extension) stateHandler(w http.ResponseWriter, r *http.Request) {
e.mu.RLock()
stateResponse := types.StateResponse{
StateVersion: teeutils.ToHash(config.Version),
State: types.State{
GreetingCount: e.greetingCount,
LastGreeting: e.lastGreeting,
FarewellCount: e.farewellCount,
LastFarewell: e.lastFarewell,
},
}
e.mu.RUnlock()

err := json.NewEncoder(w).Encode(stateResponse)
if err != nil {
http.Error(w, fmt.Sprintf("sending response: %v", err), http.StatusInternalServerError)
return
}
}

func (e *Extension) processAction(action teetypes.Action) (int, []byte) {
dataFixed, err := processorutils.Parse[instruction.DataFixed](action.Data.Message)
if err != nil {
return http.StatusBadRequest, []byte(fmt.Sprintf("decoding fixed data: %v", err))
}

switch {
case dataFixed.OPType == teeutils.ToHash(config.OPTypeGreeting):
return e.processGreeting(action, dataFixed)

default:
return http.StatusNotImplemented, []byte(fmt.Sprintf(
"unsupported op type: received %s, expected %s (%s)",
dataFixed.OPType.Hex(), teeutils.ToHash(config.OPTypeGreeting).Hex(), config.OPTypeGreeting,
))
}
}

// processGreeting routes GREETING instructions by OPCommand.
func (e *Extension) processGreeting(action teetypes.Action, df *instruction.DataFixed) (int, []byte) {
switch {
case df.OPCommand == teeutils.ToHash(config.OPCommandSayHello):
ar := e.processSayHello(action, df)
b, _ := json.Marshal(ar)
return http.StatusOK, b

case df.OPCommand == teeutils.ToHash(config.OPCommandSayGoodbye):
ar := e.processSayGoodbye(action, df)
b, _ := json.Marshal(ar)
return http.StatusOK, b

default:
return http.StatusNotImplemented, []byte(fmt.Sprintf(
"unsupported op command: received %s, expected one of [%s (%s), %s (%s)]",
df.OPCommand.Hex(),
teeutils.ToHash(config.OPCommandSayHello).Hex(), config.OPCommandSayHello,
teeutils.ToHash(config.OPCommandSayGoodbye).Hex(), config.OPCommandSayGoodbye,
))
}
}

// processSayHello handles SAY_HELLO instructions: returns a greeting and tracks count.
func (e *Extension) processSayHello(action teetypes.Action, df *instruction.DataFixed) teetypes.ActionResult {
var req types.SayHelloRequest
dec := json.NewDecoder(bytes.NewReader(df.OriginalMessage))
dec.DisallowUnknownFields()
err := dec.Decode(&req)
if err != nil {
return buildResult(action, df, nil, 0, fmt.Errorf("decoding request: %w", err))
}

if req.Name == "" {
return buildResult(action, df, nil, 0, fmt.Errorf("name must not be empty"))
}

e.mu.Lock()
e.greetingCount++
greetingNumber := e.greetingCount
greeting := fmt.Sprintf("Hello, %s! Welcome to Flare Confidential Compute.", req.Name)
e.lastGreeting = greeting
e.mu.Unlock()

resp := types.SayHelloResponse{
Greeting: greeting,
GreetingNumber: greetingNumber,
}
data, _ := json.Marshal(resp)

return buildResult(action, df, data, 1, nil)
}

// processSayGoodbye handles SAY_GOODBYE instructions: returns a farewell and tracks count.
func (e *Extension) processSayGoodbye(action teetypes.Action, df *instruction.DataFixed) teetypes.ActionResult {
var req types.SayGoodbyeRequest
err := structs.DecodeTo(types.SayGoodbyeMessageArg, df.OriginalMessage, &req)
if err != nil {
return buildResult(action, df, nil, 0, fmt.Errorf("decoding request: %w", err))
}

if req.Name == "" {
return buildResult(action, df, nil, 0, fmt.Errorf("name must not be empty"))
}

e.mu.Lock()
e.farewellCount++
farewellNumber := e.farewellCount
farewell := fmt.Sprintf("Goodbye, %s! Reason: %s", req.Name, req.Reason)
e.lastFarewell = farewell
e.mu.Unlock()

resp := types.SayGoodbyeResponse{
Farewell: farewell,
FarewellNumber: farewellNumber,
}
data, _ := json.Marshal(resp)

return buildResult(action, df, data, 1, nil)
}

Leave New() and the HTTP wiring in place. Add your own OPType cases in processAction, then implement command handlers next to processSayHello / processSayGoodbye.

Decoder Registry

Register message and result decoders so tooling and the types server can decode payloads for your commands:

pkg/types/register.go
package types

import "extension-scaffold/pkg/decoder"

// RegisterDecoders registers all type decoders for this extension.
// Extension developers: add new registrations here for each OPType/OPCommand.
func RegisterDecoders(r *decoder.Registry) {
// SAY_HELLO message (JSON)
r.Register(
decoder.RegistryKey{OPType: "GREETING", OPCommand: "SAY_HELLO", Kind: decoder.KindMessage},
decoder.NewJSONDecoder[SayHelloRequest](),
)
// SAY_HELLO result (JSON)
r.Register(
decoder.RegistryKey{OPType: "GREETING", OPCommand: "SAY_HELLO", Kind: decoder.KindResult},
decoder.NewJSONDecoder[SayHelloResponse](),
)
// SAY_GOODBYE message (ABI-encoded)
r.Register(
decoder.RegistryKey{OPType: "GREETING", OPCommand: "SAY_GOODBYE", Kind: decoder.KindMessage},
decoder.NewABIDecoder[SayGoodbyeRequest](SayGoodbyeMessageArg),
)
// SAY_GOODBYE result (JSON)
r.Register(
decoder.RegistryKey{OPType: "GREETING", OPCommand: "SAY_GOODBYE", Kind: decoder.KindResult},
decoder.NewJSONDecoder[SayGoodbyeResponse](),
)
}

Deploying and Testing on Coston2

This walkthrough runs a local simulated TEE against the live Coston2 chain (chain ID 114) using Docker and an HTTPS tunnel. To follow the setup path, configure the .env file, the indexer TOML, and ngrok before running the core script sequence.

Quick start

After configuring the .env file, the indexer TOML, and ngrok, the core script sequence is:

./scripts/pre-build.sh
./scripts/start-services.sh --chain coston2
./scripts/post-build.sh
./scripts/test.sh

Step 1: Configure .env

cp .env.example .env

Set at least:

.env
DEPLOYMENT_PRIVATE_KEY="<your-funded-coston2-private-key-hex-no-0x>"
INITIAL_OWNER="0x<your-address>"
PROXY_PRIVATE_KEY="<same-or-another-funded-key>"
CHAIN_URL=https://coston2-api.flare.network/ext/C/rpc
ADDRESSES_FILE=./config/coston2/deployed-addresses.json
LOCAL_MODE=false
SIMULATED_TEE=true
NORMAL_PROXY_URL=https://tee-proxy-coston2-1.flare.rocks
EXT_PROXY_URL=https://<your-tunnel-domain>
  • LOCAL_MODE=false — required on live networks (attestation path enabled).
  • SIMULATED_TEE=true — use a simulated code hash/platform so you can develop without Confidential VM hardware.
  • EXT_PROXY_URL — public HTTPS URL of your tunnel to host port 6674 (set in Step 2).

Step 2: Reserve a Public Proxy URL

The pre-build, post-build, start-services, and test scripts all use EXT_PROXY_URL. Set it before starting Docker services.

Security — read before exposing port 6674

Exposing port 6674 makes your local ext-proxy reachable over HTTPS. Anyone with the tunnel URL can call the proxy HTTP API.

Use a tunnel only for Coston2 testnet, and stop it when finished.

In a separate terminal:

ngrok http 6674

Or with cloudflared:

cloudflared tunnel --url http://localhost:6674

Copy the HTTPS URL into EXT_PROXY_URL in .env.

info

Cloudflared quick tunnels generate a new URL on each run — update EXT_PROXY_URL whenever the domain changes.

Step 3: Configure the indexer database

The local ext-proxy queries Flare's C-chain indexer for TEE events. Copy the Coston2 examples and fill in credentials:

cp config/proxy/extension_proxy.coston2.docker.toml.example \
config/proxy/extension_proxy.coston2.docker.toml
cp config/proxy/extension_proxy.coston2.toml.example \
config/proxy/extension_proxy.coston2.toml

Edit the [db] block:

config/proxy/extension_proxy.coston2.docker.toml
[db]
host = "<indexer-db-host>"
port = 3306
database = "<indexer-db-name>"
username = "<indexer-db-user>"
password = "<indexer-db-password>"
log_queries = false

Chain ID 114 and the Coston2 Flare system contract addresses are already set in the example files.

Flare Indexer Access

To get indexer credentials, contact support or X and share what you are building.

Step 4: Deploy the contract and register the extension

./scripts/pre-build.sh

This compiles Solidity, deploys the HelloWorldInstructionSender smart contract, and registers the extension on the TeeExtensionRegistry registry contract. On success, it writes the EXTENSION_ID and INSTRUCTION_SENDER to the config/extension.env file.

warning

Once the config/extension.env file exists, pre-build reuses it and skips minting a new extension. Use ./scripts/pre-build.sh --force only when you intentionally want a new extension. Forcing a new pre-build deploys a new sender and registers a new extension ID, while your existing TEE machine may still be tied to the old ID - end-to-end tests then fail with mismatches such as MachineManager.TooMany().

Step 5: Start the extension stack

./scripts/start-services.sh --chain coston2

This builds local/tee-proxy if needed (from the sibling tee-proxy clone), builds extension-tee from your scaffold plus the sibling tee-node module, and starts the Redis database, the ext-proxy proxy server, and the extension-tee TEE node with the Coston2 compose overlay.

Wait until the proxy is healthy locally:

until curl -sf http://localhost:6674/info >/dev/null 2>&1; do sleep 2; done
echo "Extension proxy is ready"

Confirm the public tunnel sees the same proxy:

source .env
curl -sf "$EXT_PROXY_URL/info" | jq .

Step 6: Verify the proxy

source .env
curl -s "$EXT_PROXY_URL/info" | jq '.machineData'

For a simulated TEE, expect:

FieldExpected
codeHashSimulated hash (0x194844cf…)
extensionIdMatches EXTENSION_ID in config/extension.env
initialOwnerMatches your INITIAL_OWNER

Step 7: Register the TEE machine

./scripts/post-build.sh

This allows your TEE code version for the extension, registers TEE governance (defaults to the deployer as sole signer), then registers the TEE machine onchain (using EXT_PROXY_URL and NORMAL_PROXY_URL from .env).

Step 8: Run the end-to-end test

./scripts/test.sh

The test calls setExtensionId() if needed, sends SAY_HELLO and SAY_GOODBYE instructions through the deployed contract, polls the proxy for ActionResults, and asserts the greeting/farewell payloads.

If it passes, your Hello World extension is live against Coston2.

Making It Your Own

  1. Choose OPType/OPCommand names that describe your operations.
  2. Update Solidity constants and send functions; regenerate Go bindings if you change the ABI (./scripts/generate-bindings.sh).
  3. Implement handlers and types; register decoders.
  4. Update tools/cmd/run-test/main.go and re-run ./scripts/test.sh.
  5. Rebuild the TEE image after code changes: ./scripts/start-services.sh --chain coston2 (compose uses --build).

The scaffold README file covers renaming placeholders (docs/manual-setup.md). Hooks scripts/extension-setup.sh and scripts/extension-post-setup.sh are no-ops in Hello World — add deploy-time or post-registration setup there when your extension needs it.

Troubleshooting

SymptomWhat to check
unsupported op type / unsupported op commandSolidity bytes32("...") strings must match Go config constants exactly.
Proxy never becomes readyTunnel to 6674, indexer [db] credentials, and docker compose logs for ext-proxy.
Pre-build skips mintingconfig/extension.env already exists — reused by default; use --force only if you intend a new extension ID.
InvalidGovernanceHashGOVERNANCE_SIGNERS / GOVERNANCE_THRESHOLD must match between .env and the TEE node (or leave both unset).
MachineManager.TooMany() or wrong extensionExtension ID in config/extension.env does not match the TEE registered by post-build — avoid casual --force.
Attestation / registration failuresConfirm LOCAL_MODE=false, SIMULATED_TEE=true, and NORMAL_PROXY_URL=https://tee-proxy-coston2-1.flare.rocks.
Docker build cannot find tee-nodeConfirm the sibling clone layout under tee/ described in Clone the Scaffold.

Stop the Coston2 stack when you are done:

docker compose -f docker-compose.yaml -f docker-compose.coston2.yaml down
What's next?

When you are ready for richer examples, see the Private Key Extension and Weather Insurance Extension guides. Or read the FCC overview and the FCC whitepaper.