TEE relay client is a connector between smart contracts on Flare's C-chain and TEE clients.
It listens to TeeInstructionsSent events emitted by the FlareTeeManager smart contract, processes them, and forwards them to the TEE nodes.
Build and run the relay client:
go build -o tee-relay ./cmd/main
./tee-relayThe binary expects config.toml to be present in the working directory.
A reachable C-chain indexer database is required: the relay connects to it at startup and exits if it cannot.
No image is published. Build with the provided Dockerfile, which can be used as is:
docker build -t tee-relay .
docker run -d --name tee-relay \
-v /etc/tee-relay/config.toml:/app/config.toml:ro \
--env-file /etc/tee-relay/relay.env \
tee-relayThe runtime stage is debian:trixie, to which the build adds the binary and CA certificates. It sets WORKDIR /app and
runs as uid 10001.
- Config — mount at
/app/config.toml. Must be readable by uid 10001, or startup panics withpermission denied. - Key —
PRIVATE_KEYis mandatory; pass it by env file or secret store, never in the image. - Contract address —
FLARE_TEE_MANAGER_CONTRACT_ADDRESSsuppliesflare_tee_manager, so the address can come from the deployment environment instead of the mounted config. Set in both places, the two must agree (see FlareTeeManager address). - Logs —
/appis not writable by uid 10001, sologger.fileneeds a mounted writable directory. Otherwise keepconsole = trueand readdocker logs. - Ports — none, and no health endpoint; liveness comes from the logs.
- Stopping —
SIGTERMis handled, but in-flight instructions are not drained, and there is no durable cursor. They are re-collected after restart only if[collector] start_intervalis greater than zero and the instruction's block is still inside that window (see Collector); queued FDC work is lost. To recover reliably, restart before the window moves past the affected blocks.
The configuration is read from the config.toml file.
Copy the example to get started:
cp config.toml.example config.tomlTEE relay client can be run in provider or cosigner mode, controlled by the is_cosigner field.
For Flare entities included in the current signing policy. The relay client processes all instructions.
is_cosigner = false # defaultFor cosigners defined by TEE protocols that are not in the signing policy. The relay client processes only instructions that include the address corresponding to the configured private key among the cosigners.
is_cosigner = trueRequired. Address of the FlareTeeManager smart contract to listen to:
flare_tee_manager = "0xdE25c06982Ab8e4b6B4F910896E3f93Ac77FB44d"It can be set by the FLARE_TEE_MANAGER_CONTRACT_ADDRESS environment variable instead, in the same
0x-prefixed form. If both sources are used they must agree — a conflict fails startup rather than
picking a winner, since the address decides which contract's instructions the relay signs.
chain_id has no environment counterpart: it stays in the config file and must match the
network this address is deployed on.
Required. The chain the relay signs for — it is part of every signature the relay produces, so it must match the network
the flare_tee_manager address is deployed on. Startup fails if it is unset or zero.
It must also equal the Relay contract's sourceChainId, which is what the chain hashes into the FDC2 signature digest.
The relay has no RPC connection and cannot read it, so the value is trusted as configured; the two always agree because
FDC2 is only deployed alongside a home Relay, where sourceChainId is forced to the network's own chain id.
chain_id = 14 # Flare mainnetOptional. The first reward epoch whose FDC2 attestation responses are signed with the chain-bound digest — see FDC2 for what the two digest forms are.
[relay_cutover]
starting_reward_epoch = 5451| value | effect |
|---|---|
omitted, or 0 |
every reward epoch is chain-bound |
N > 0 |
pre-cutover digest below N, chain-bound from N on |
-1 |
pre-cutover digest at every reward epoch |
Omitting the block means the cutover has already happened, so a chain still awaiting it must say so with -1 until its
epoch is announced. Only the epoch is configured: the new Relay's address is not, because the relay reads no Relay
contract. The form in force is logged at startup, and -1 is logged as a warning.
[collector]
start_interval = 100 # defaults to 100 when omittedstart_interval is how many blocks below the indexer's last block the initial log scan starts.
Because the relay keeps no durable cursor, this window is rescanned on every restart: a larger
value recovers instructions missed while the relay was down, at the cost of reprocessing (re-signing
and re-sending) everything else in the window. Set it to 0 to start immediately after the last
block and never look back — the scan bound is exclusive, so the last block itself is not rescanned.
Must not be negative. A value larger than the indexer's current block height starts the scan at the earliest block the indexer still retains, which reprocesses every instruction in it.
Credentials for the C-chain indexer database:
[db]
host = "localhost"
port = 3306
database = "flare_ftso_indexer"
username = "root"
password = "root"
log_queries = falseThe database should be operated by C-chain indexer connected to desired chain.
The indexer should index TeeInstructionsSent events emitted by the FlareTeeManager diamond contract.
Connection-pool limits are optional and default to whatever database/sql uses. They live in a nested [db.pool] table —
max_open_conns, max_idle_conns, conn_max_lifetime, conn_max_idle_time. The relay queries the indexer from a single
goroutine, so tuning them is rarely useful. Note that placing these keys directly under [db] instead of [db.pool] is
rejected at startup as an unknown field.
The relay client requires access to a private key — the signing policy key for providers, or the designated cosigning key for cosigners. The key is used to:
- sign instructions
- identify and decrypt packages for key recovery
- (cosigner mode only) identify relevant instructions
Two modes are defined, but the external signer is not implemented yet — use the local signer.
The private key is held by the relay client itself, read from an environment variable at startup.
[signer]
local = true
private_key_variable = "PRIVATE_KEY" # name of the env var; defaults to "PRIVATE_KEY"Set the environment variable to a 0x-prefixed 32-byte hex string:
export PRIVATE_KEY=0x<64 hex chars>Not implemented yet. External signing is not available in this release: no signer service is deployed or operated for it, and the path is untested end to end. Run with
signer.local = true. The endpoints below specify the interface a future service must satisfy.
The private key is held by an external signer service (typically the FSP client).
[signer]
local = false
url = "https://signer-host:port"
key_name = "X-API-KEY"
key = "<api-key>"The external signer URL is operator-controlled and may point to a local address.
Use https for any non-loopback host: the API key and the /decrypt plaintext
(key-split secret material) would otherwise transit in cleartext. http is
acceptable only for a loopback address.
The signer service must expose three endpoints:
POST /sign
Request:
{ "hashes": ["<0x-prefixed 32-byte hex>", ...] }Response:
{ "signatures": ["<0x-prefixed 65-byte hex>", ...] }The j-th signature is the ECDSA personal signature (ERC-191 version 0x45) of the j-th hash.
POST /decrypt
Request:
{ "cipher": "<0x-prefixed hex ciphertext>" }Response:
{ "plain": "<0x-prefixed hex plaintext>" }plain is the ECIES decryption of cipher.
GET /id
Response:
{ "x": "<0x-prefixed 32-byte hex>", "y": "<0x-prefixed 32-byte hex>" }Returns the secp256k1 public key coordinates of the key used for signing and decryption.
A prototype of such a service exists in go-flare-common/pkg/tee/signer. It is not part of a supported deployment.
One of the protocols operated on Flare TEEs is FDC2 (Flare Data Connector). FDC2 instructions require additional processing — the relay client must query designated verifier servers to obtain attestation responses.
A verifier must be configured for each supported (attestation type, source) pair. To avoid overloading servers, each verifier is backed by a queue. Multiple verifiers can share a queue when they point to the same server.
The relay signs each attestation response with the Relay Mode-2 digest of the reward epoch the instruction carries. From the configured starting reward epoch on, that digest binds the chain id — keccak256(chain_id ‖ 0x010000000000 ‖ messageHash) — because the new Relay recovers signatures against it; before that epoch the pre-cutover form, without the chain id, is used.
The boundary comes from [relay_cutover] (see Relay cutover), not from the binary.
TEE machines verify this signature inside the enclave before adding their own, and they compute the chain-bound digest unconditionally — they carry no boundary of their own. The configured epoch is therefore only correct if it is the one the chain's contract batch and TEE fleet swap land in: below it the pre-cutover fleet serves the chain, at and above it the new one. A configured epoch that does not match the swap rejects every response on one side of it.
[fdc.queues.exampleQueue]
max_dequeues_per_second = 100 # zero for unlimited
max_workers = 50 # zero for unlimited
max_attempts = 3
time_off = "2s"Unlike max_dequeues_per_second and max_workers above, max_attempts has no
zero-means-unlimited reading: it must be at least 1, and startup fails on 0 rather
than treating it as "no retries"; 1 means a single attempt with no retry. time_off
must be positive when max_attempts is greater than 1; it is unconstrained at
max_attempts = 1.
[fdc.verifiers.example]
type = "AttestationTypeExampleName"
source = "ExampleSource"
queue = "exampleQueue"
server.url = "https://verifier-host/path/to/endpoint"
server.key_name = "X-API-KEY"
server.key = "exampleKey"Verifier server URLs are operator-controlled and may point to local addresses.
Use https for any non-loopback host so the API key and request/response bodies
are not sent in cleartext; http is acceptable only for a loopback address.
Startup fails if server.url is empty, fails to parse, has a scheme other than
http or https (this is what rejects a bare host:port like localhost:8080,
which parses with scheme localhost), or has an empty host. server.key_name may
be empty only when server.key is empty; once server.key is set, key_name is
required and must be a valid HTTP header name (letters, digits, and
!#$%&'*+-.^_`|~), and key must not contain CR or LF. Startup also fails if
a verifier's type or source is empty or longer than 32 bytes, or if two
verifiers share the same type and source.
The relay client queries a verifier with a single endpoint:
POST <server.url>
Request:
{
"attestationType": "<0x-prefixed 32-byte hex>",
"sourceId": "<0x-prefixed 32-byte hex>",
"requestBody": "<0x-prefixed hex>"
}Response (HTTP 200):
{
"status": "<VERIFIED | RETRY | REJECTED>",
"responseBody": "<0x-prefixed hex; only with status VERIFIED>",
"message": "<reason; only with status RETRY or REJECTED>"
}status is the verifier's verdict on the request:
VERIFIED— the request is confirmed.responseBodycarries the ABI-encoded attestation response and must be nonempty and at most 100 KiB (the instruction size limit enforced by the TEEs);messagemust be empty.RETRY— the request cannot be decided yet (e.g. the queried data is not yet final);messagesays why andresponseBodymust be empty. The client re-enqueues the instruction: the queue retries it aftertime_off, up tomax_attemptsin total, then drops it.REJECTED— the request is invalid or unconfirmable;messagesays why andresponseBodymust be empty. The client drops the instruction without retrying.
An empty responseBody may also be encoded as JSON null or omitted entirely. Any other status, a VERIFIED response with an empty or oversized responseBody, or an undecodable body is a protocol error; the client treats it like RETRY.
A non-200 status means the request was not processed. Within a single query the client retries 408, 429, 5xx, and transport failures (3 attempts, 5 s apart); any other status fails the query at once, and the failed query is again retried through the queue. A non-200 response can carry a diagnostic reason in its body, but only with Content-Type: text/plain — bodies of any other content type are discarded. Responses are read up to 1 MiB; the client truncates message to 1 KiB.
[logger]
level = "INFO" # DEBUG, INFO, WARN, ERROR (DPANIC, PANIC and FATAL are also accepted)
console = true # write logs to stdout
file = "" # path to log file; empty disables file logging
max_file_size = 10 # max log file size in MB before rotation
max_backups = 10 # number of rotated files to keep
max_age_days = 30 # days to keep rotated files| Variable | Required | Description |
|---|---|---|
PRIVATE_KEY |
When signer.local = true |
Private key for local signing. Name is configurable via signer.private_key_variable. Must be a 0x-prefixed 32-byte hex string. |
FLARE_TEE_MANAGER_CONTRACT_ADDRESS |
When flare_tee_manager is unset |
Address of the FlareTeeManager contract, 0x-prefixed. Startup fails if the value is not an address, is zero, or contradicts flare_tee_manager in the config file. |
ALLOW_UNSAFE_URLS |
No | Set to true to disable SSRF protection on backup and TEE sender URLs. Intended for local end-to-end testing only. A warning is logged at startup when enabled. |