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.

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 for secret delivery.

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.

Prerequisites

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

  • Docker Desktop.
  • Foundry for contract compilation.
  • Go — for deploy and registration tools in go/tools/.
  • ngrok — to expose your local proxy to the internet (this guide uses ngrok; any HTTPS tunnel to port 6674 works, such as Cloudflare Tunnel).
  • A funded Coston2 wallet with C2FLR for gas and TEE registration fees — use the Coston2 faucet.

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;

import { ITeeExtensionRegistry } from "./interfaces/ITeeExtensionRegistry.sol";
import { ITeeMachineRegistry } from "./interfaces/ITeeMachineRegistry.sol";

contract InstructionSender {
bytes32 public constant OP_TYPE_KEY = bytes32("KEY");
bytes32 public constant OP_COMMAND_UPDATE = bytes32("UPDATE");
bytes32 public constant OP_COMMAND_SIGN = bytes32("SIGN");

ITeeExtensionRegistry public immutable TEE_EXTENSION_REGISTRY;
ITeeMachineRegistry public immutable TEE_MACHINE_REGISTRY;

uint256 private _extensionId;

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");
TEE_EXTENSION_REGISTRY = _teeExtensionRegistry;
TEE_MACHINE_REGISTRY = _teeMachineRegistry;
}

// ... setExtensionId, updateKey, sign, _getExtensionId
}
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
function setExtensionId() external {
require(_extensionId == 0, "Extension ID already set.");

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

The end-to-end test calls this 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.

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/
│ ├── extension.env # Generated by pre-build.sh (EXTENSION_ID, INSTRUCTION_SENDER)
│ ├── coston2/ # Deployed contract addresses
│ │ └── deployed-addresses.json
│ └── proxy/ # Chain-specific ext-proxy TOML configs
├── scripts/ # Primary deploy interface
│ ├── use-chain.sh
│ ├── pre-build.sh
│ ├── start-services.sh
│ ├── post-build.sh
│ └── test.sh
├── go/
│ ├── 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
├── .env.example
├── .env.local.coston2 # Local simulated Coston2 template
└── README.md

Deploying and Testing on Coston2

This walkthrough deploys a local simulated TEE against the real Coston2 chain using Docker and an ngrok tunnel. For production deployment on a GCP Confidential Space VM, see DEPLOYMENT_STEPS.md in the repository.

Quick start

After completing steps 0-5 (chain selection, deployer keys, ngrok tunnel, contract deploy, and indexer TOML), run:

./scripts/start-services.sh
./scripts/post-build.sh
./scripts/test.sh

Step 0: Activate local simulated mode

Select the local simulated Coston2 environment and extension language:

./scripts/use-chain.sh local coston2 go

This copies .env.local.coston2 to .env, setting SIMULATED_TEE=true and LOCAL_MODE=false (you are on the real Coston2 chain; only the TEE attestation is simulated).

Use python or typescript instead of go to run a different extension image. Run ./scripts/use-chain.sh --list to see available chains and languages.

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>"

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

./scripts/use-chain.sh local coston2 go

Step 2: Reserve a public proxy URL

post-build.sh, start-services.sh, and test.sh all read EXT_PROXY_URL from .env. Set it before deploying the contract or starting Docker services.

Security — read before exposing port 6674

The ngrok http 6674 command makes your local ext-proxy public. Port 6674 exposes the proxy HTTP API, and anyone with the URL can call it.

Use ngrok only for Coston2 testnet, stop the tunnel when finished.

In a separate terminal, start the tunnel to the proxy's public port (the proxy is not running yet — ngrok will forward traffic once Step 5 starts ext-proxy):

ngrok http 6674

Copy the HTTPS URL from ngrok's Forwarding line and set it in .env.local.coston2:

.env.local.coston2
EXT_PROXY_URL="https://<your-ngrok-domain>"

Reactivate the environment:

./scripts/use-chain.sh local coston2 go
info

The ngrok free tier provides one reserved domain, so this URL normally stays stable across restarts.
Only update EXT_PROXY_URL if the domain changes.

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/extension.env.

warning

Once config/extension.env exists, the pre-build step 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 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: Configure the indexer database

The local ext-proxy queries Flare's C-chain indexer to find TEE events. Create the Coston2 proxy config from the bundled example:

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

Edit the [db] block in config/proxy/extension_proxy.coston2.docker.toml with the Coston2 indexer host and the read-only credentials provided to you (not published in this guide):

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

The Coston2 indexer is reachable without a VPN. Coston uses different credentials and requires a VPN — see DEPLOYMENT_STEPS.md in the repository if deploying to Coston instead.

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 5: Start the extension stack

./scripts/start-services.sh

This builds the extension image for your LANGUAGE, then starts redis, ext-proxy, and extension-tee. The start-services.sh script waits for EXT_PROXY_URL/info — with ngrok already running from Step 2, that check goes through your public tunnel to the local proxy.

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 .
Switching languages

Change LANGUAGE in .env.local.coston2, re-run use-chain.sh, then re-run start-services.sh to rebuild the extension image.

Step 6: 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
codeHashSimulated hash (0x194844cf…)
extensionIdMatches EXTENSION_ID in config/extension.env
initialOwnerMatches your INITIAL_OWNER

Step 7: Register the TEE machine

./scripts/post-build.sh

This runs two onchain steps:

  1. allow-tee-version: allows the code hash and platform for your extension.
  2. 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. Set SIMULATED_TEE=true in your .env — it pairs with MODE=1 injected by Docker Compose for simulated attestation.

Step 8: Run the end-to-end test

./scripts/test.sh

The test performs the following sequence:

  1. Calls setExtensionId() on the InstructionSender contract to discover and store the extension ID.
  2. Fetches the TEE's public key from the proxy's /info endpoint.
  3. ECIES encrypts a test private key using the TEE's public key.
  4. Sends an updateKey instruction on-chain with the encrypted key.
  5. Waits for the TEE to process the instruction and store the key.
  6. Sends a sign instruction on-chain with a test message.
  7. Verifies the returned ECDSA signature matches the test private key.

If the test passes, your extension is fully operational.

Port Reference

ServiceContainer portHost port
ext-proxy internal66636673
ext-proxy external66646674
redis63796382

The ngrok tunnel exposes host port 6674 (ext-proxy external) to the internet. The internal port (6673) is used for communication between the extension container and the proxy within the Docker network.

Only bind ext-proxy to 6674 during this walkthrough — do not run other services on that port while the tunnel is active.

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.
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 in config/extension.env does not match the on-chain TEE record — usually because pre-build.sh --force registered a new extension. In contrast, an older TEE machine is still registered under the previous extension ID. Run a full reset and start again from step 0, or keep the existing config/extension.env and re-run only post-build.sh and test.sh.

Verification.ChallengeExpired

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.

ngrok URL changed

  1. Update EXT_PROXY_URL in .env.local.coston2 and re-run use-chain.sh.
  2. Restart the ngrok tunnel if needed (ngrok http 6674).
  3. Restart the Docker stack: ./scripts/stop-services.sh && ./scripts/start-services.sh.
  4. Re-run post-build.sh and test.sh.

Cleanup

Stop the Docker stack

./scripts/stop-services.sh

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

Full reset

To completely reset local state and start from scratch:

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

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.