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.
An HTTP server that runs inside a Trusted Execution Environment (TEE), in Go, Python, or TypeScript.
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.
In this guide, you will:
- Clone the scaffold.
- Learn the instruction lifecycle and which files you own.
- Deploy the Hello World smart contract and TEE stack against the Coston2 testnet.
- Customize the OPType and OPCommand handlers and the onchain 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 action handlers in your chosen language.
The TEE node and proxy handle attestation, signing, and message routing.
How those signatures work is split into two parts:
- TEE public and private keys — how the machine signs results and decrypts secrets.
- Data providers and cosigners — who may send the TEE work.
Architecture
The extension stack runs as three Docker services:
extension-teeTEE node: TEE node plus your extension in Go, Python, or TypeScript (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
You need Docker, Foundry (forge, cast), and Go 1.25+ for the shared deploy tooling.
Python 3.11+ or Node 22+ is required only if you run those implementations.
Clone the scaffold example from GitHub:
git clone https://github.com/flare-foundation/fce-extension-scaffold.git
cd fce-extension-scaffold
The scaffold implements the same Hello World extension in Go, Python, and TypeScript.
This guide uses the Go default (LANGUAGE=go).
Pass --language python or --language typescript to use-chain.sh to switch implementations; the contract, scripts, and Coston2 flow stay the same.
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.
On Coston2, they are already deployed; the deploy tooling reads them from config/coston2/deployed-addresses.json.
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 one language directory plus the Solidity contract so OPType and OPCommand strings stay aligned:
| Role | Go | Python | TypeScript |
|---|---|---|---|
| OPType constants | go/internal/config/config.go | python/app/config.py | typescript/src/app/config.ts |
| Handlers | go/internal/extension/extension.go | python/app/handlers.py | typescript/src/app/handlers.ts |
| Request and response types | go/pkg/types/types.go | declared inline in handlers | declared inline in handlers |
| ABI decoding | go/pkg/types/types.go | python/app/abi.py | typescript/src/app/abi.ts |
| Shared | contracts/InstructionSender.sol, tools/cmd/run-test/main.go | — | — |
Leave the framework layer intact: go/internal/extension/utils.go and pkg/server/ in Go, python/base/ in Python, and typescript/src/base/ in TypeScript.
OPType and OPCommand Must Match
Solidity stores commands as bytes32("...").
Each language hashes the same string constants and compares them with the incoming instruction.
The strings must be identical in the contract and in every implementation you run:
- Solidity
- Go
- Python
- TypeScript
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"
)
OP_TYPE_GREETING = "GREETING"
OP_COMMAND_SAY_HELLO = "SAY_HELLO"
OP_COMMAND_SAY_GOODBYE = "SAY_GOODBYE"
export const OP_TYPE_GREETING = "GREETING";
export const OP_COMMAND_SAY_HELLO = "SAY_HELLO";
export const OP_COMMAND_SAY_GOODBYE = "SAY_GOODBYE";
Then the handler in your chosen language should match:
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.
OPType strings beginning with F_ are reserved for Flare system operations, so do not use that prefix for your own extension.
Config Constants
Increment Version when behavior or the onchain interface changes, because the TEE registration path treats code version as part of the extension lifecycle.
- Go
- Python
- TypeScript
// 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
)
// Environment variables override defaults.
func init() {
ep := os.Getenv("EXTENSION_PORT")
sp := os.Getenv("SIGN_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
}
}
}
"""★ Configuration: version and operation identifiers.
Mirrors go/internal/config/config.go. The op-type and op-command strings MUST
match the bytes32 constants in contracts/InstructionSender.sol exactly, or
actions fall through to "unsupported op type".
"""
VERSION = "0.1.0"
OP_TYPE_GREETING = "GREETING"
OP_COMMAND_SAY_HELLO = "SAY_HELLO"
OP_COMMAND_SAY_GOODBYE = "SAY_GOODBYE"
/**
* ★ Configuration: version and operation identifiers.
*
* Mirrors go/internal/config/config.go. The op-type and op-command strings MUST
* match the bytes32 constants in contracts/InstructionSender.sol exactly, or
* actions fall through to "unsupported op type".
*/
export const VERSION = "0.1.0";
export const OP_TYPE_GREETING = "GREETING";
export const OP_COMMAND_SAY_HELLO = "SAY_HELLO";
export const OP_COMMAND_SAY_GOODBYE = "SAY_GOODBYE";
Request and Response Types
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.
Go keeps those shapes in pkg/types/types.go.
Python and TypeScript declare JSON request and response dicts inline in the handlers, and decode the ABI payload in a dedicated module.
- Go
- Python
- TypeScript
// 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"`
}
"""★ ABI decoding for operations whose payload is ABI-encoded rather than JSON.
SAY_GOODBYE's contract passes abi.encode((string name, string reason)), matching
SayGoodbyeMessageArg in go/pkg/types/types.go. SAY_HELLO uses plain JSON and
needs nothing here.
"""
from __future__ import annotations
from eth_abi import decode as abi_decode
# A single tuple argument, matching Solidity's abi.encode of a struct.
SAY_GOODBYE_TYPES = ["(string,string)"]
def decode_say_goodbye(data: bytes) -> tuple[str, str]:
"""Decode ABI-encoded (string name, string reason).
Raises ValueError if the payload does not match the expected layout.
"""
try:
(decoded,) = abi_decode(SAY_GOODBYE_TYPES, data)
except Exception as e: # eth_abi raises a variety of decoding errors
raise ValueError(f"ABI decode failed: {e}") from e
name, reason = decoded
return name, reason
/**
* ★ ABI decoding for operations whose payload is ABI-encoded rather than JSON.
*
* SAY_GOODBYE's contract passes abi.encode((string name, string reason)),
* matching SayGoodbyeMessageArg in go/pkg/types/types.go. SAY_HELLO uses plain
* JSON and needs nothing here.
*/
import { decodeAbiParameters, type Hex } from "viem";
/** A single tuple argument, matching Solidity's abi.encode of a struct. */
const SAY_GOODBYE_PARAMS = [
{
type: "tuple",
components: [
{ name: "name", type: "string" },
{ name: "reason", type: "string" },
],
},
] as const;
export interface SayGoodbyeMessage {
name: string;
reason: string;
}
/**
* Decode ABI-encoded (string name, string reason).
* Throws if the payload does not match the expected layout.
*/
export function decodeSayGoodbye(data: Hex): SayGoodbyeMessage {
try {
const [decoded] = decodeAbiParameters(SAY_GOODBYE_PARAMS, data);
return { name: decoded.name, reason: decoded.reason };
} catch (e) {
throw new Error(
`ABI decode failed: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
Handlers
Each handler follows the same pattern: decode, validate, execute, then return a result. Protect shared state with a mutex in Go; the Python and TypeScript frameworks serialize handler calls, so module-level state is safe.
Status values in ActionResult:
0— error (message inLog)1— success≥ 2— pending (async work; later POST final result to the node)
- Go
- Python
- TypeScript
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.
"""★ MAIN CUSTOMIZATION POINT: your extension's handlers.
Mirrors go/internal/extension/extension.go. Each handler follows the same
4-step pattern: decode, validate, execute, respond.
Handler contract:
(original_message_hex) -> (data_hex_or_None, status, error_or_None)
status 0 = error, 1 = success. See docs/extension-contract.md §4.6.
The framework serializes handler calls, so plain module-level state is safe.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Optional
from base.encoding import bytes_to_hex, hex_to_bytes
from base.types import Framework
from .abi import decode_say_goodbye
from .config import (
OP_COMMAND_SAY_GOODBYE,
OP_COMMAND_SAY_HELLO,
OP_TYPE_GREETING,
VERSION,
)
logger = logging.getLogger(__name__)
# --- Extension state ---------------------------------------------------------
# Serialized by the framework; no locking needed here.
_greeting_count = 0
_last_greeting = ""
_farewell_count = 0
_last_farewell = ""
def reset_state() -> None:
"""Reset all state. Used by tests; not part of the wire contract."""
global _greeting_count, _last_greeting, _farewell_count, _last_farewell
_greeting_count = 0
_last_greeting = ""
_farewell_count = 0
_last_farewell = ""
def register(framework: Framework) -> None:
"""Wire handlers to (opType, opCommand) pairs."""
framework.handle(OP_TYPE_GREETING, OP_COMMAND_SAY_HELLO, handle_say_hello)
framework.handle(OP_TYPE_GREETING, OP_COMMAND_SAY_GOODBYE, handle_say_goodbye)
def report_state() -> Any:
"""Snapshot returned by GET /state. Mirrors the Go State struct."""
return {
"greetingCount": _greeting_count,
"lastGreeting": _last_greeting,
"farewellCount": _farewell_count,
"lastFarewell": _last_farewell,
}
def handle_say_hello(msg: str) -> tuple[Optional[str], int, Optional[str]]:
"""GREETING/SAY_HELLO — JSON payload {"name": "..."}."""
global _greeting_count, _last_greeting
# 1. Decode
try:
raw = hex_to_bytes(msg)
except ValueError as e:
return None, 0, f"decoding request: invalid hex: {e}"
try:
req = json.loads(raw)
except (json.JSONDecodeError, ValueError) as e:
return None, 0, f"decoding request: {e}"
if not isinstance(req, dict):
return None, 0, "decoding request: expected a JSON object"
# Match Go's DisallowUnknownFields.
unknown = set(req) - {"name"}
if unknown:
return None, 0, f"decoding request: unknown field {sorted(unknown)[0]!r}"
# 2. Validate
name = req.get("name", "")
if not name:
return None, 0, "name must not be empty"
# 3. Execute
_greeting_count += 1
greeting = f"Hello, {name}! Welcome to Flare Confidential Compute."
_last_greeting = greeting
# 4. Respond
resp = {"greeting": greeting, "greetingNumber": _greeting_count}
return bytes_to_hex(json.dumps(resp, separators=(",", ":")).encode("utf-8")), 1, None
def handle_say_goodbye(msg: str) -> tuple[Optional[str], int, Optional[str]]:
"""GREETING/SAY_GOODBYE — ABI-encoded (string name, string reason)."""
global _farewell_count, _last_farewell
# 1. Decode
try:
raw = hex_to_bytes(msg)
except ValueError as e:
return None, 0, f"decoding request: invalid hex: {e}"
try:
name, reason = decode_say_goodbye(raw)
except ValueError as e:
return None, 0, f"decoding request: {e}"
# 2. Validate
if not name:
return None, 0, "name must not be empty"
# 3. Execute
_farewell_count += 1
farewell = f"Goodbye, {name}! Reason: {reason}"
_last_farewell = farewell
# 4. Respond
resp = {"farewell": farewell, "farewellNumber": _farewell_count}
return bytes_to_hex(json.dumps(resp, separators=(",", ":")).encode("utf-8")), 1, None
Wire new commands in register(), then implement handlers next to handle_say_hello / handle_say_goodbye.
Do not modify python/base/.
/**
* ★ MAIN CUSTOMIZATION POINT: your extension's handlers.
*
* Mirrors go/internal/extension/extension.go. Each handler follows the same
* 4-step pattern: decode, validate, execute, respond.
*
* Handler contract:
* (originalMessageHex) => [dataHexOrNull, status, errorOrNull]
* status 0 = error, 1 = success. See docs/extension-contract.md §4.6.
*
* The framework serializes handler calls, so plain module-level state is safe.
*/
import { bytesToHex, hexToBytes } from "../base/encoding.js";
import type { Framework, HandlerResult } from "../base/types.js";
import { decodeSayGoodbye } from "./abi.js";
import {
OP_COMMAND_SAY_GOODBYE,
OP_COMMAND_SAY_HELLO,
OP_TYPE_GREETING,
} from "./config.js";
// --- Extension state ---------------------------------------------------------
// Serialized by the framework; no locking needed here.
let greetingCount = 0;
let lastGreeting = "";
let farewellCount = 0;
let lastFarewell = "";
/** Reset all state. Used by tests; not part of the wire contract. */
export function resetState(): void {
greetingCount = 0;
lastGreeting = "";
farewellCount = 0;
lastFarewell = "";
}
/** Wire handlers to (opType, opCommand) pairs. */
export function register(framework: Framework): void {
framework.handle(OP_TYPE_GREETING, OP_COMMAND_SAY_HELLO, handleSayHello);
framework.handle(OP_TYPE_GREETING, OP_COMMAND_SAY_GOODBYE, handleSayGoodbye);
}
/** Snapshot returned by GET /state. Mirrors the Go State struct. */
export function reportState(): unknown {
return {
greetingCount,
lastGreeting,
farewellCount,
lastFarewell,
};
}
/** GREETING/SAY_HELLO — JSON payload {"name": "..."}. */
export function handleSayHello(msg: string): HandlerResult {
// 1. Decode
let raw: Uint8Array;
try {
raw = hexToBytes(msg);
} catch (e) {
return [null, 0, `decoding request: invalid hex: ${String(e)}`];
}
let req: unknown;
try {
req = JSON.parse(Buffer.from(raw).toString("utf-8"));
} catch (e) {
return [null, 0, `decoding request: ${String(e)}`];
}
if (typeof req !== "object" || req === null || Array.isArray(req)) {
return [null, 0, "decoding request: expected a JSON object"];
}
// Match Go's DisallowUnknownFields.
const unknown = Object.keys(req)
.filter((k) => k !== "name")
.sort();
if (unknown.length > 0) {
return [null, 0, `decoding request: unknown field "${unknown[0]}"`];
}
// 2. Validate
const name = (req as { name?: unknown }).name;
if (typeof name !== "string" || name === "") {
return [null, 0, "name must not be empty"];
}
// 3. Execute
greetingCount++;
const greeting = `Hello, ${name}! Welcome to Flare Confidential Compute.`;
lastGreeting = greeting;
// 4. Respond
const resp = { greeting, greetingNumber: greetingCount };
return [bytesToHex(Buffer.from(JSON.stringify(resp), "utf-8")), 1, null];
}
/** GREETING/SAY_GOODBYE — ABI-encoded (string name, string reason). */
export function handleSayGoodbye(msg: string): HandlerResult {
// 1. Decode
let hex: string;
try {
// Normalize through hexToBytes so malformed input fails here, not in viem.
hex = bytesToHex(hexToBytes(msg));
} catch (e) {
return [null, 0, `decoding request: invalid hex: ${String(e)}`];
}
let decoded: { name: string; reason: string };
try {
decoded = decodeSayGoodbye(hex as `0x${string}`);
} catch (e) {
return [
null,
0,
`decoding request: ${e instanceof Error ? e.message : String(e)}`,
];
}
// 2. Validate
if (!decoded.name) {
return [null, 0, "name must not be empty"];
}
// 3. Execute
farewellCount++;
const farewell = `Goodbye, ${decoded.name}! Reason: ${decoded.reason}`;
lastFarewell = farewell;
// 4. Respond
const resp = { farewell, farewellNumber: farewellCount };
return [bytesToHex(Buffer.from(JSON.stringify(resp), "utf-8")), 1, null];
}
Wire new commands in register(), then implement handlers next to handleSayHello / handleSayGoodbye.
Do not modify typescript/src/base/.
Deploying and Testing on Coston2
This walkthrough runs a local simulated TEE against the live Coston2 chain (chain ID 114) using Docker.
start-services.sh starts a Cloudflare tunnel to host port 6674 and writes EXT_PROXY_URL for you.
You need a funded Coston2 account for gas and registration fees — use the Coston2 faucet.
After Steps 1-3 (use-chain.sh, deployer keys, and indexer credentials), run the rest of the lifecycle in one shot:
./scripts/full-setup.sh --chain coston2 --test
Or run the same phases individually:
./scripts/pre-build.sh
./scripts/start-services.sh --chain coston2
./scripts/post-build.sh
./scripts/test.sh
Step 1: Activate simulated Coston2
./scripts/use-chain.sh coston2 --simulated
This creates .env.coston2 and .env.local.coston2, generates the Coston2 proxy TOML and Compose overlay, and copies the simulated file to .env.
It sets CHAIN, CHAIN_ID=114, CHAIN_URL, ADDRESSES_FILE, LOCAL_MODE=false, SIMULATED_TEE=true, and NORMAL_PROXY_URL for you.
Pass --language python or --language typescript to select a non-Go image.
Edit .env.local.coston2, never .env — .env is a disposable copy that use-chain.sh overwrites.
Step 2: Configure deployer keys
Edit .env.local.coston2 and set your funded Coston2 credentials:
DEPLOYMENT_PRIVATE_KEY="<your-funded-coston2-private-key-hex-no-0x>"
INITIAL_OWNER="0x<your-address>"
Optionally set TEE governance (used by the node container and by post-build):
# GOVERNANCE_SIGNERS="0xAbc...,0xDef..." # comma-separated 0x addresses
# GOVERNANCE_THRESHOLD=2
If unset, both default to the deployer (INITIAL_OWNER) as the sole signer with threshold 1 — fine for Hello World.
The node and onchain registry must agree on this set, or TEE registration reverts with InvalidGovernanceHash.
Re-activate after editing so .env picks up the changes:
./scripts/use-chain.sh coston2 --simulated
Step 3: Configure the indexer database
The local ext-proxy queries Flare's C-chain indexer for TEE events.
use-chain.sh already generated config/proxy/extension_proxy.coston2.docker.toml.
Edit the [db] block with the Coston2 indexer host and the read-only credentials provided to you:
[db]
host = "34.38.42.208"
port = 3306
database = "indexer"
username = "<your-indexer-db-username>"
password = "<your-indexer-db-password>"
log_queries = false
Chain ID 114 and the Coston2 Flare system contract addresses are already set in the generated file.
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/coston2/extension.env file.
Once the config/coston2/extension.env file exists, pre-build refuses to run again.
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
Because SIMULATED_TEE=true, this starts a Cloudflare tunnel to host port 6674, writes EXT_PROXY_URL into .env and .env.local.coston2, then waits until the public proxy answers /info.
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 with ./scripts/stop-services.sh --chain coston2.
To use ngrok instead of the bundled tunnel, start ngrok http 6674 first, set EXT_PROXY_URL in .env.local.coston2, re-run use-chain.sh, then start services.
Confirm the public URL:
source .env
curl -sf "$EXT_PROXY_URL/info" | jq .
Quick tunnels mint a new hostname on each restart.
If the URL changes after the TEE is registered, run ./scripts/update-tee-url.sh so the onchain machine record matches.
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/coston2/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 the extension's TEE governance onchain (set-governance, using GOVERNANCE_SIGNERS / GOVERNANCE_THRESHOLD from .env or the deployer defaults), then registers the TEE machine (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, avoiding the reserved
F_OPType prefix. - Update Solidity constants and send functions; regenerate Go bindings if you change the ABI (
./scripts/generate-bindings.sh). - Implement handlers and types in the language you selected.
- 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.
Stop the stack with ./scripts/stop-services.sh --chain coston2.
If a quick-tunnel URL rotated after registration, run ./scripts/update-tee-url.sh instead of re-registering the machine.
Troubleshooting
| Symptom | What to check |
|---|---|
unsupported op type / unsupported op command | Solidity bytes32("...") strings must match the config constants in your language implementation exactly. |
docker-compose.coston2.yaml not found | Run ./scripts/use-chain.sh coston2 --simulated first. |
Edits in .env have no effect | Edit .env.local.coston2, then re-run ./scripts/use-chain.sh coston2 --simulated. |
signature must be 65 bytes, got 0 | CHAIN_ID is unset on the node — use-chain.sh sets it to 114 for Coston2. |
| Proxy never becomes ready | Tunnel to 6674, indexer [db] credentials, and docker compose logs for ext-proxy. |
| Pre-build refuses to run | config/coston2/extension.env already exists — use --force only if you intend a new extension ID. |
MachineManager.TooMany() or wrong extension | Extension ID in config/coston2/extension.env does not match the TEE registered by post-build — avoid casual --force. |
pollAction timeout / /action/result 404 | Stale tunnel URL or extra active TEE machines — run ./scripts/update-tee-url.sh and ./scripts/check-tee-machines.sh --chain coston2. |
InvalidGovernanceHash | GOVERNANCE_SIGNERS / GOVERNANCE_THRESHOLD in .env.local.coston2 must match what the extension-tee container received; rebuild/restart after changing them. |
| Attestation / registration failures | Confirm LOCAL_MODE=false, SIMULATED_TEE=true, and NORMAL_PROXY_URL=https://tee-proxy-coston2-1.flare.rocks. |
Docker build fails fetching tee-node / tee-proxy | Confirm network access so Go can fetch the pinned tee-node module and Docker can build from proxy/Dockerfile. |
For issues that are not specific to this walkthrough, such as a TEE machine that never reaches production, a proxy that rejects TEE signatures, or instructions that never reach your extension, see FCC Troubleshooting.