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

Private Key Extension

Build and deploy a Trusted Execution Environment (TEE) extension that securely stores a private key and signs arbitrary messages. This guide walks through every step — from writing the smart contract and extension handler to deploying on Coston2 and running an end-to-end test. The code for this example is available on GitHub.

New to FCC extensions?

Start with Build Your First Extension for the Hello World scaffold, OPType/OPCommand customization, and the Coston2 deploy flow. How signing works at the protocol layer is split into TEE public and private keys and Data providers and cosigners.

Demonstration only

This example is for learning purposes. Storing encrypted secrets on-chain is not advisable in production — on-chain data is public and encryption can be broken over time. A production extension should use offchain channels to deliver secrets.

New to Flare TEE?

A TEE extension is an offchain program that runs inside a Trusted Execution Environment. It receives instructions from on-chain transactions, processes them in a secure enclave, and writes the results back on-chain. The TEE framework handles attestation, key management, and message routing — you only write the business logic.

Overview​

The Private Key Manager extension demonstrates the core TEE workflow:

  1. A user sends an Elliptic Curve Integrated Encryption Scheme (ECIES) encrypted private key on-chain via the InstructionSender contract.
  2. The TEE extension decrypts and stores the key inside the secure enclave.
  3. A user sends a sign instruction with an arbitrary message.
  4. The TEE extension signs the message with the stored key and returns the signature on-chain.

We will build this in three parts:

  1. on-chain contract that sends instructions;
  2. off-chain handler that processes them;
  3. deployment tooling that ties everything together.

Architecture​

The extension stack consists of three components running as Docker services:

  • extension-tee: Your extension code (Go, Python, or TypeScript). Receives decoded instructions from the proxy and returns results.
  • ext-proxy: The TEE extension proxy. Watches the chain for new instructions targeting your extension, forwards them to your handler, and submits results back on-chain.
  • redis: In-memory store used by the proxy for internal state.

For local runs, a fourth container — cloudflared — publishes the proxy on a public HTTPS URL so Flare's infrastructure can deliver instructions to it. It lives in its own compose project (tunnel) and is shared across extensions, so start-services.sh reuses one that is already running.

Prerequisites​

Before you begin, make sure you have the following installed:

  • Docker Desktop running Linux containers.
  • Foundry, plus jq and curl, for contract compilation and the scripts.
  • Go — for deployment and registration tools in go/tools/.
  • A funded Coston2 wallet with C2FLR for gas and TEE registration fees — use the Coston2 faucet.
  • Indexer database credentials — the proxy reads TEE events from Flare's C-chain indexer (see Step 2).

Onchain Contract​

The InstructionSender contract is the onchain entry point. It interacts with two Flare system contracts:

  • TeeExtensionRegistry: Registers extensions and routes instructions to TEE machines.
  • TeeMachineRegistry: Tracks registered TEE machines and provides random selection.

Contract 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 InstructionSender (sign extension)
/// @author Acex
/// @notice On-chain entry point for sending instructions to the sign-extension TEE.
///
/// DO NOT MODIFY: constructor, setExtensionId(), _getExtensionId()
contract InstructionSender {
/// @notice Operation type for key-related actions (UPDATE, SIGN).
// forge-lint: disable-next-line(unsafe-typecast)
bytes32 public constant OP_TYPE_KEY = bytes32("KEY");

/// @notice Command for the UPDATE_KEY action (stores an encrypted private key).
// forge-lint: disable-next-line(unsafe-typecast)
bytes32 public constant OP_COMMAND_UPDATE = bytes32("UPDATE");

/// @notice Command for the SIGN action (signs a message with the stored key).
// forge-lint: disable-next-line(unsafe-typecast)
bytes32 public constant OP_COMMAND_SIGN = bytes32("SIGN");

/// @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 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 Update the stored private key by sending an encrypted key payload to the TEE.
/// @param _encryptedKey ECIES-encrypted (or otherwise sealed) private-key bytes that the
/// TEE node will decrypt before storing the inner secp256k1 private key.
function updateKey(bytes calldata _encryptedKey) 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_KEY,
opCommand: OP_COMMAND_UPDATE,
message: _encryptedKey,
cosigners: cosigners,
cosignersThreshold: 0,
claimBackAddress: msg.sender
});

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

/// @notice Request the TEE to sign a message with the stored private key.
/// @param _message Raw bytes to sign. The TEE returns the signature in the action result.
function sign(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_KEY,
opCommand: OP_COMMAND_SIGN,
message: _message,
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

The constructor takes the addresses of the two Flare system contracts. These are already deployed on Coston2 — the deployment tooling reads their addresses from config/coston2/deployed-addresses.json. This is a temporary solution because the Flare confidential compute is still in development. On release, both addresses will be available through the FlareContractRegistry contract.

Extension ID Discovery​

After the extension is registered on-chain, call setExtensionId() once to discover and cache the extension ID:

contracts/InstructionSender.sol
uint256 private constant FIRST_PUBLIC_EXTENSION_ID = 0x10000; // 65536

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.");
}

Public extension IDs start at 0x10000 (65536). IDs below are reserved for system extensions. The end-to-end test calls setExtensionId() automatically before sending instructions.

Instruction Parameters​

Every instruction sent to a TEE extension uses the TeeInstructionParams struct:

struct TeeInstructionParams {
bytes32 opType;
bytes32 opCommand;
bytes message;
address[] cosigners;
uint64 cosignersThreshold;
address claimBackAddress;
}
  • opType and opCommand: Route the instruction to the correct handler in your extension. This example uses opType = "KEY" with two commands: "UPDATE" and "SIGN".
  • message: Arbitrary payload. For updateKey, this is the ECIES-encrypted private key. For sign, this is the message to sign.
  • cosigners and cosignersThreshold: Optional multi-party signing (not used in this example).
  • claimBackAddress: Set to msg.sender so unused fees can be reclaimed.

Sending Instructions​

The updateKey function sends an encrypted private key to the TEE:

contracts/InstructionSender.sol
function updateKey(bytes calldata _encryptedKey) external payable {
address[] memory teeIds = TEE_MACHINE_REGISTRY.getRandomTeeIds(_getExtensionId(), 1);

ITeeExtensionRegistry.TeeInstructionParams memory params = ITeeExtensionRegistry.TeeInstructionParams({
opType: OP_TYPE_KEY,
opCommand: OP_COMMAND_UPDATE,
message: _encryptedKey,
cosigners: new address[](0),
cosignersThreshold: 0,
claimBackAddress: msg.sender
});

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

The flow is:

  1. Call getRandomTeeIds to select a random TEE machine registered for this extension.
  2. Build the instruction parameters with the appropriate opType and opCommand.
  3. Call sendInstructions on the TeeExtensionRegistry, forwarding the fee as msg.value.

The sign function follows the same pattern with opCommand = "SIGN":

contracts/InstructionSender.sol
function sign(bytes calldata _message) external payable {
address[] memory teeIds = TEE_MACHINE_REGISTRY.getRandomTeeIds(_getExtensionId(), 1);

ITeeExtensionRegistry.TeeInstructionParams memory params = ITeeExtensionRegistry.TeeInstructionParams({
opType: OP_TYPE_KEY,
opCommand: OP_COMMAND_SIGN,
message: _message,
cosigners: new address[](0),
cosignersThreshold: 0,
claimBackAddress: msg.sender
});

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

When building your own extension, change the opType and opCommand constants to match your use case. The same constants must appear in both the Solidity contract and your offchain handler code. opType strings beginning with F_ are reserved for Flare system operations, so do not use that prefix.

Offchain Handler​

The offchain handler is where your extension's business logic lives. This example is available in three languages, but the Go implementation uses a different architecture from Python and TypeScript.

LanguageDirectoryHandler fileConfig file
Gogo/internal/extension/extension.gogo/internal/config/config.go
Pythonpython/app/handlers.pyconfig.py
TypeScripttypescript/src/app/handlers.tsconfig.ts

Go Implementation​

The Go extension exposes an HTTP server with two endpoints:

  • GET /state — reports whether a key is stored (never exposes key material).
  • POST /action — receives TEE actions and routes them by opType / opCommand.

Constants match the Solidity contract:

go/internal/config/config.go
const (
OPTypeKey = "KEY"
OPCommandUpdate = "UPDATE"
OPCommandSign = "SIGN"
)

The processKeyUpdate handler decrypts the instruction payload via the TEE node's /decrypt endpoint and stores the secp256k1 private key:

go/internal/extension/extension.go
func (e *Extension) processKeyUpdate(action teetypes.Action, df *instruction.DataFixed) teetypes.ActionResult {
keyBytes, err := decryptViaNode(e.signPort, df.OriginalMessage)
if err != nil {
return buildResult(action, df, nil, 0, fmt.Errorf("decryption failed: %v", err))
}

privKey, err := parseSecp256k1PrivateKey(keyBytes)
if err != nil {
return buildResult(action, df, nil, 0, fmt.Errorf("invalid private key: %v", err))
}

e.mu.Lock()
e.privateKey = privKey
e.mu.Unlock()

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

The processKeySign handler signs OriginalMessage bytes directly and returns ABI-encoded (bytes, bytes):

go/internal/extension/extension.go
func (e *Extension) processKeySign(action teetypes.Action, df *instruction.DataFixed) teetypes.ActionResult {
sig, err := signECDSA(key, df.OriginalMessage)
if err != nil {
return buildResult(action, df, nil, 0, fmt.Errorf("signing failed: %v", err))
}

encoded, err := abiEncodeTwo(df.OriginalMessage, sig)
if err != nil {
return buildResult(action, df, nil, 0, fmt.Errorf("ABI encoding failed: %v", err))
}

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

The decryption uses the TEE node's built-in /decrypt endpoint on SIGN_PORT (default 7701). The caller ECIES-encrypts the private key using the TEE's public key (fetched from the proxy's /info endpoint) before sending it on-chain.

Python and TypeScript implementations​

Python and TypeScript use a Framework that registers handlers by opType/opCommand pair:

python/app/handlers.py
def register(framework: Framework) -> None:
framework.handle(OP_TYPE_KEY, OP_COMMAND_UPDATE, handle_key_update)
framework.handle(OP_TYPE_KEY, OP_COMMAND_SIGN, handle_key_sign)

Each handler receives a hex-encoded originalMessage and returns (data, status, error):

  • data: Hex-encoded return data written back on-chain, or None if no data;
  • status: 0 = error, 1 = success, >=2 = pending;
  • error: Error message string if the handler failed.

The handle_key_update handler decrypts the instruction payload via the TEE node and stores the secp256k1 private key; handle_key_sign signs the message and ABI-encodes the result — the same logic as the Go implementation, adapted to the Framework API.

Framework Utilities (Python and TypeScript)​

The base/ package in Python and TypeScript provides common utilities:

UtilityDescription
hex_to_bytes / bytes_to_hexHex encoding and decoding
FrameworkHandler registration and HTTP server
warning

The files in base/ are Framework infrastructure. You should not need to modify them when building your own extension.

Project Structure​

sign/
├── contracts/ # Solidity contracts (shared across implementations)
│ ├── InstructionSender.sol
│ └── interfaces/
│ ├── ITeeExtensionRegistry.sol
│ └── ITeeMachineRegistry.sol
├── config/
│ ├── coston2/ # Per-chain artifacts
│ │ ├── deployed-addresses.json
│ │ └── extension.env # Generated by pre-build.sh (EXTENSION_ID, INSTRUCTION_SENDER)
│ └── proxy/ # Chain-specific ext-proxy TOML configs
├── docs/ # Repository documentation
│ ├── getting-started.md
│ ├── deployment-steps.md
│ ├── cloudflared.md
│ ├── testing.md
│ └── testing-against-coston2.md
├── scripts/ # Primary deploy interface
│ ├── use-chain.sh # Scaffold + activate a chain
│ ├── full-setup.sh # pre-build → start → post-build → test
│ ├── pre-build.sh
│ ├── start-services.sh
│ ├── post-build.sh
│ ├── test.sh
│ ├── check-tee-machines.sh # Liveness of every registered machine
│ └── update-tee-url.sh # Repoint an already-registered machine
├── go/
│ ├── language.env # Language manifest (Dockerfile, test/run commands)
│ ├── internal/extension/ # Go business logic (modify these)
│ └── tools/cmd/ # All deploy CLIs (Go-only)
├── python/ # Python implementation
│ ├── app/ # Your business logic (modify these)
│ └── base/ # Framework infrastructure (do not modify)
├── typescript/ # TypeScript implementation
│ ├── src/app/ # Your business logic (modify these)
│ └── src/base/ # Framework infrastructure (do not modify)
├── proxy/ # Extension proxy (Docker build context)
├── docker-compose.yaml
├── docker-compose.cloudflared.yaml # Shared tunnel, compose project "tunnel"
├── .env.example
└── README.md

Env files and per-chain artifacts are generated, not committed: use-chain.sh creates .env.<chain> (and .env.local.<chain> for simulated runs), and pre-build.sh writes config/<chain>/extension.env. A directory counts as an extension implementation exactly when it contains a language.env manifest, which is how the scripts discover go, python, and typescript.

Deploying and Testing on Coston2​

This walkthrough deploys a local simulated TEE against the real Coston2 chain using Docker and a Cloudflare tunnel. For production deployment on a GCP Confidential Space VM, see docs/deployment-steps.md in the repository.

Quick start

After steps 0-2 (chain activation, deployer keys, indexer credentials), the whole lifecycle runs in one command:

./scripts/full-setup.sh --chain coston2 --test

This runs pre-build.sh → start-services.sh → post-build.sh → test.sh. The steps below walk the same flow one phase at a time.

Step 0: Activate the chain and language​

./scripts/use-chain.sh coston2 --simulated --language go

One command readies a chain end to end. It creates anything still missing — never overwriting — and then activates it by copying the chosen env file over .env:

FileHolds
.env.coston2the chain's env, with chain values filled in
.env.local.coston2the --simulated variant of the same
config/proxy/extension_proxy.coston2.docker.tomlproxy config — addresses filled in, [db] left to you
docker-compose.coston2.yamlcompose override for the chain

The --simulated flag sets SIMULATED_TEE=true and LOCAL_MODE=false: you run against the real Coston2 chain, and only the TEE attestation is simulated. Use --language python or --language typescript to run a different extension image.

CommandShows
./scripts/use-chain.sh --listchains that have a .env.<chain> file
./scripts/use-chain.sh --statusthe active chain, mode, language, and which config files exist
./scripts/use-chain.sh --versionsthe tee-node / tee-proxy pins in use vs. latest upstream

Every other script then reads CHAIN from .env, so ./scripts/pre-build.sh needs no arguments. Deployment artifacts live per chain under config/<chain>/, so switching chains never disturbs them.

Step 1: Configure deployer keys​

Edit .env.local.coston2 and set your funded Coston2 credentials:

.env.local.coston2
DEPLOYMENT_PRIVATE_KEY="<your-funded-coston2-private-key-hex-no-0x>"
INITIAL_OWNER="0x<your-address>"

Optional TEE governance overrides (if unset, post-build defaults to the deployer as the sole signer with threshold 1):

.env.local.coston2
# GOVERNANCE_SIGNERS="0xAbc...,0xDef..."
# GOVERNANCE_THRESHOLD=2

These must match the values passed to the TEE node container, or registration fails with InvalidGovernanceHash. Both sides read the same variables, so leaving them unset keeps them consistent.

Re-activate after editing so .env picks up the changes:

./scripts/use-chain.sh coston2 --simulated --language go

Step 2: Configure the indexer database​

The local ext-proxy queries Flare's C-chain indexer to find TEE events. The use-chain.sh script generates config/proxy/extension_proxy.coston2.docker.toml with the right chain ID and contract addresses, but the [db] block is yours to fill:

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

Without working credentials, the proxy never sees instruction responses and test.sh times out.

Flare Indexer Access

To get the indexer credentials, please get in touch with us via support or X and share what you are building.

Step 3: Deploy contract and register extension​

./scripts/pre-build.sh

This compiles Solidity, deploys InstructionSender, and registers the extension on-chain. On success, it writes EXTENSION_ID and INSTRUCTION_SENDER to config/coston2/extension.env, which every later script reads automatically.

warning

Once config/coston2/extension.env exists, the pre-build step 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 InstructionSender and registers a new extension ID on-chain. The TEE machine you already registered stays on-chain under the previous extension ID, but your local config now points to the new one. That mismatch causes the end-to-end test to fail with MachineManager.TooMany().

Step 4: Start the extension stack​

./scripts/start-services.sh

With SIMULATED_TEE=true, this starts the shared Cloudflare tunnel before any other container, reads its public URL, and writes EXT_PROXY_URL into both .env and .env.local.coston2. It then builds the extension image for your LANGUAGE and starts redis, ext-proxy, and extension-tee. A tunnel that is already running is reused and never restarted; --tunnel forces it up in live mode as well.

Wait for the proxy to become healthy locally:

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

Confirm only the extension proxy is listening on port 6674:

lsof -i :6674
curl -sf http://localhost:6674/info | jq .
The tunnel URL is written on-chain once

The EXT_PROXY_URL variable only steers your local scripts. Flare's infrastructure delivers instructions to the URL stored in the MachineManager, written once when post-build.sh registers the machine — it is not rewritten afterwards. A quick tunnel mints a new URL every time it starts, so restarting it makes a healthy TEE look dead: the on-chain call succeeds and every poll times out. Repoint an already-registered machine with ./scripts/update-tee-url.sh (one transaction, no re-attestation), or use a named Cloudflare tunnel for a stable hostname.

Switching languages

Re-run ./scripts/use-chain.sh coston2 --simulated --language python, then start-services.sh, to rebuild the extension image. Each language is a different image and therefore a different codeHash, so post-build.sh has to run again before test.sh.

Step 5: Verify the proxy​

Confirm the simulated TEE is reporting the correct extension ID:

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

For a simulated TEE, expect:

FieldExpected
codeHashthe simulated hash (0x194844cf…) — production rejects it
extensionIdmatches EXTENSION_ID in config/coston2/extension.env
initialOwnermatches your INITIAL_OWNER

Step 6: Register the TEE machine​

./scripts/post-build.sh

This runs three onchain steps:

  1. allow-tee-version: allows the code hash and platform for your extension.
  2. set-governance: registers the TEE governance signer set and threshold for the extension (defaults to INITIAL_OWNER with threshold 1 unless you set GOVERNANCE_SIGNERS / GOVERNANCE_THRESHOLD).
  3. register-tee -command rRap: pre-registers the TEE, requests fresh attestation, runs the FTDC availability check, and promotes to production.

The capital R in rRap issues a fresh attestation challenge on re-runs, avoiding Verification.ChallengeExpired. Before the availability check, the tooling compares the FDC2 proxy's signing policy against the on-chain reward epoch and fails fast with ErrPolicyOutOfSync rather than polling a round that will never appear.

Step 7: Run the end-to-end test​

./scripts/test.sh

The test performs the following sequence:

  1. Calls setExtensionId() on the deployed InstructionSender contract (idempotent).
  2. Fetches the TEE's public key from the proxy's /info endpoint.
  3. ECIES encrypts a fixed test private key using the TEE's public key.
  4. Sends an updateKey instruction on-chain with the encrypted key, and polls for the result.
  5. Sends a sign instruction on-chain with a test message, and polls for the result.
  6. ABI-decodes the result and ecrecovers the signer, asserting it matches the test key's address.

Step 6 is the real assertion: a green run means the key survived the enclave round-trip and the signature verifies.

Building your own Extension​

To create your own TEE extension using this template:

  1. Clone the repository and pick a language (go, python, or typescript).
  2. Define your instruction types — Choose opType and opCommand constants that describe your extension's operations.
  3. Modify contracts/InstructionSender.sol — Update the contract functions to use your new constants and accept the appropriate parameters.
  4. Write your handlers — Implement business logic in go/internal/extension/ (Go) or app/ (Python/TypeScript).
  5. Deploy and test — Follow the steps in this guide; deploy tooling stays in go/tools/ regardless of language.
  6. Add a language, if you need one — Drop a language.env manifest in its directory; the scripts discover it without further changes.
What to change vs. what to keep

Modify your handler directory and contracts/InstructionSender.sol. The base/ packages (Python/TypeScript) and go/tools/ deploy CLIs are framework infrastructure and should not need changes.

Troubleshooting​

Proxy won't start or DB sync error

The proxy needs access to the C-chain indexer database. Check the proxy logs and verify the DB credentials in config/proxy/extension_proxy.coston2.docker.toml:

docker compose logs ext-proxy

Transaction reverts

Ensure your wallet has enough C2FLR for gas and fees. Fund it with the Coston2 faucet.

MachineManager.TooMany() during test

The extension ID that InstructionSender is bound to does not match the on-chain TEE record — usually because pre-build.sh --force registered a new extension while an older TEE machine is still registered under the previous ID. Diagnose it read-only first:

cd go/tools
go run ./cmd/check-tee-state \
-a "../../config/coston2/deployed-addresses.json" \
-c "$CHAIN_URL" -p "$EXT_PROXY_URL" -instructionSender "$INSTRUCTION_SENDER"

It compares the proxy's /info, the on-chain TEE record, InstructionSender's bound ID, and getActiveTeeMachines, and ends with a verdict line. Then either point INSTRUCTION_SENDER at the address it prints for the TEE's ID, or run a full reset and start again from step 0.

The test passes sometimes and times out other times

More than one TEE machine is active for the extension, and instructions are load-balanced across them — the stale ones are identities from earlier relaunches (the TEE key lives only in memory, so every relaunch mints a new one).

./scripts/check-tee-machines.sh          # LIVE / STALE / UNREACHABLE / UNKNOWN per machine
./scripts/check-tee-machines.sh --pause # confirm each stale machine, then pause it

UNREACHABLE is not LIVE — a proxy that never answers cannot be compared. There is no unpause, so never pause the live machine.

Every poll times out after the tunnel restarted

The URL stored in the MachineManager is the one written at registration; a new quick-tunnel URL does not replace it.

./scripts/update-tee-url.sh   # defaults to EXT_PROXY_URL from .env

undefined: sign.InstructionSender

Contract bindings are missing, which happens in a fresh clone — they are generated, not committed:

./scripts/generate-bindings.sh

Verification.ChallengeExpired

Re-run post-build.sh.

InvalidGovernanceHash

The governance signer set used by set-governance does not match the governanceHash signed by the TEE node. Leave GOVERNANCE_SIGNERS / GOVERNANCE_THRESHOLD unset for the default deployer-only setup, or ensure both .env and the node container receive the same values, then re-run post-build.sh.

code hashes do not match

The SIMULATED_TEE variable and the container MODE environment variable disagree. For local simulated deployment, use SIMULATED_TEE=true with MODE=1 (injected by Docker Compose).

TEE registration times out

Try restarting the proxy — it may have missed a signing policy round:

docker compose restart ext-proxy

If that doesn't help, the FDC attestation flow requires active relay providers on Coston2.

Verification.TeeNotFound

The NORMAL_PROXY_URL variable points at the other chain's FDC2 proxy. For Coston2 it must be https://tee-proxy-coston2-1.flare.rocks.

Cleanup​

Stop the Docker stack

./scripts/stop-services.sh

This stops and removes all containers (redis, ext-proxy, extension-tee), and the tunnel last.

Full reset

To completely reset local state and start from scratch:

./scripts/stop-services.sh
docker compose down --rmi local
rm -f .env .env.coston2 .env.local.coston2 \
config/coston2/extension.env \
config/proxy/extension_proxy.coston2.docker.toml

The stop-services.sh script also stops the shared Cloudflare tunnel when SIMULATED_TEE=true, which rotates the URL for every extension behind it; --tunnel forces it down in live mode.

After a full reset, start again from Step 0.

To mint a new extension after a diamond redeploy without a full reset:

./scripts/pre-build.sh --force
info

On-chain state (deployed contracts, registered extensions, registered TEEs) cannot be reset. Each fresh pre-build.sh deploys a new InstructionSender contract and registers a new extension. This is fine for testing — Coston2 is a testnet.

What's next?

Read the Flare Confidential Computing (FCC) overview for more information on how to build and deploy TEE extensions on Flare.