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.
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:
SAY_HELLO— JSON payload{"name":"..."}; returns a greeting string and increments a counter.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-teeTEE node: TEE node plus your Go extension (listens forPOST /actionandGET /state).ext-proxyproxy server: Watches Coston2 testnet for instructions targeting your extension, forwards them to the TEE, and exposes results and/infoon the public proxy URL.redisdatabase: 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 acceptssendInstructionscalls that require a fee.TeeMachineRegistry— returns random TEE machine IDs for your extension viagetRandomTeeIdsfunction.
View the code
// 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;
}
}
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:
| # | File | What you change |
|---|---|---|
| 1 | internal/config/config.go | OPType and OPCommand string constants and SemVer Version |
| 2 | pkg/types/types.go | Request, response, and state structs |
| 3 | internal/extension/extension.go | Routing and handlers |
| 4 | pkg/types/register.go | Decoders for the types server |
| 5 | contracts/InstructionSender.sol | Matching bytes32 constants and send functions |
| 6 | tools/cmd/run-test/main.go | E2E 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
Mismatch of the OPType or OPCommand strings is the most common cause of unsupported op type / unsupported op command responses.
Config Constants
// 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
// 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 inLog)1— success≥ 2— pending (async work; later POST final result to the node)
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:
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.
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:
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.
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.
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:
[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.
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.
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:
| Field | Expected |
|---|---|
codeHash | Simulated hash (0x194844cf…) |
extensionId | Matches EXTENSION_ID in config/extension.env |
initialOwner | Matches 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
- Choose OPType/OPCommand names that describe your operations.
- Update Solidity constants and send functions; regenerate Go bindings if you change the ABI (
./scripts/generate-bindings.sh). - Implement handlers and types; register decoders.
- Update
tools/cmd/run-test/main.goand re-run./scripts/test.sh. - 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
| Symptom | What to check |
|---|---|
unsupported op type / unsupported op command | Solidity bytes32("...") strings must match Go config constants exactly. |
| Proxy never becomes ready | Tunnel to 6674, indexer [db] credentials, and docker compose logs for ext-proxy. |
| Pre-build skips minting | config/extension.env already exists — reused by default; use --force only if you intend a new extension ID. |
InvalidGovernanceHash | GOVERNANCE_SIGNERS / GOVERNANCE_THRESHOLD must match between .env and the TEE node (or leave both unset). |
MachineManager.TooMany() or wrong extension | Extension ID in config/extension.env does not match the TEE registered by post-build — avoid casual --force. |
| Attestation / registration failures | Confirm LOCAL_MODE=false, SIMULATED_TEE=true, and NORMAL_PROXY_URL=https://tee-proxy-coston2-1.flare.rocks. |
Docker build cannot find tee-node | Confirm 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
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.