Skip to content
3F Docs

Guardian Coordinator Setup

The guardian coordinator is the lite, in-process signer in the 3F Guardian stack. The Guardian attests privileged on-chain actions: a caller submits an intended action, the Guardian validates it against a fixed set of policy and on-chain checks, and on success returns an EIP-712 signature the caller submits alongside its transaction. The coordinator automates the operator side of that loop — it polls the Guardian API for open signing requests, validates each one against local policy and on-chain state, signs the valid ones with your guardian key, and submits the signature back to the API.

Running a coordinator is one of two ways to produce guardian signatures. The coordinator signs in-process from a single guardian key; the admin path assembles a quorum of signatures from multiple guardians out of band. This page covers the in-process coordinator. For the on-chain Guardian role itself, see Roles.

Environment Chains Base URL
Development Ethereum Sepolia https://guardian.dev.gcp.3f.xyz
Production Ethereum Mainnet https://guardian.3f.xyz

The Guardian API exposes three kinds of signing request. Each is validated against a different group of policy allowlists before the coordinator will sign it.

Kind Authorizes Policy allowlists
set_request Binding a Request to a Facility GUARDIAN_REQUEST_FACTORIES, GUARDIAN_REQUEST_OWNERS, GUARDIAN_REQUEST_PULLERS, GUARDIAN_REQUEST_CONSUMERS
set_fund Binding a Fund to a Facility GUARDIAN_ACCEPTED_FUNDS, GUARDIAN_FUND_OWNERS
swap A Position Manager swap GUARDIAN_PM_FACTORIES, GUARDIAN_PM_OWNERS, GUARDIAN_SWAP_PRICE_TOLERANCE_BPS

Before signing, the coordinator also confirms each request’s chainId and facility match the signed body and your CHAIN_IDS / FACILITIES filters, and skips any request it has already submitted a signature for.

  1. Confirm the guardian signer has the Guardian role on the target Facility.
  2. Pick the signer backend. AWS KMS and GCP KMS are the recommended production paths because the raw key never leaves the HSM. A remote HTTP signer delegates to an external signing service, and a raw private key is for local development only.
  3. Generate a Guardian API key with the same signer address the coordinator will use.
  4. Fill the coordinator environment with the Guardian API URL, API key, signer settings, RPC URLs, and policy allowlists.
  5. Start guardian-coordinator and watch for submitted signatures.
  • Bun installed. The 3f-guardian repo pins Bun through packageManager.
  • A signer that matches an on-chain Guardian for the Facility.
  • An HTTPS Guardian API base URL. Localhost may use HTTP for local development.
  • RPC access for every chain the coordinator will sign on.
  • Policy allowlists from 3F for Request factories, Request owners, Request pullers, Request consumers, Funds, Fund owners, Position Manager factories, and Position Manager owners.
  • For AWS KMS: an asymmetric ECC_SECG_P256K1 key with SIGN_VERIFY usage, plus IAM permission to call kms:GetPublicKey and kms:Sign.
  • For GCP KMS: a key version using EC_SIGN_SECP256K1_SHA256, plus permission to read the public key and sign with that key version.
  • For a remote HTTP signer: a signing service reachable over HTTPS (localhost may use HTTP) that returns an EIP-712 signature recovering to your guardian address.

The API key is generated by signing a short-lived GuardianAdminRequest typed-data message and posting it to POST /v1/generate-key on the Guardian API host. The recovered signer must equal guardianAddress, and that address must currently have the Guardian role for facility on chainId.

Create a one-off keygen directory:

Terminal window
mkdir guardian-keygen
cd guardian-keygen
bun init -y
bun add @aws-sdk/client-kms @google-cloud/kms @noble/curves@1.9.1 viem

Save this as generate-guardian-api-key.ts:

import { createPublicKey, randomBytes } from "node:crypto";
import { GetPublicKeyCommand, KMSClient, SignCommand } from "@aws-sdk/client-kms";
import { KeyManagementServiceClient } from "@google-cloud/kms";
import { secp256k1 } from "@noble/curves/secp256k1";
import {
getAddress,
hashTypedData,
hexToBytes,
keccak256,
recoverAddress,
stringToBytes,
type Hex,
} from "viem";
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`${name} is required`);
return value;
}
type BytesLike = Uint8Array | string;
type KmsTypedDataSigner = {
getAddress(): Promise<Hex>;
signTypedData(parameters: Parameters<typeof hashTypedData>[0]): Promise<Hex>;
};
function bytesLikeToUint8Array(value: BytesLike): Uint8Array {
if (value instanceof Uint8Array) return value;
return Uint8Array.from(Buffer.from(value, "base64"));
}
function base64UrlToBytes(value: string): Uint8Array {
return Uint8Array.from(Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64"));
}
function publicKeyToGuardianAddress(publicKey: Uint8Array | string): Hex {
const key = createPublicKey(
typeof publicKey === "string" ? publicKey : { key: publicKey, format: "der", type: "spki" },
);
const jwk = key.export({ format: "jwk" });
if (!jwk.x || !jwk.y) throw new Error("KMS public key did not contain secp256k1 coordinates");
const xy = new Uint8Array([...base64UrlToBytes(jwk.x), ...base64UrlToBytes(jwk.y)]);
return getAddress(`0x${keccak256(xy).slice(-40)}`) as Hex;
}
async function ethereumSignatureFromDer(args: {
digest: Hex;
derSignature: Uint8Array;
guardianAddress: Hex;
}): Promise<Hex> {
const compact = secp256k1.Signature.fromDER(args.derSignature).normalizeS().toCompactHex();
for (const yParity of [0, 1] as const) {
const signature = `0x${compact}${(27 + yParity).toString(16)}` as Hex;
const recovered = await recoverAddress({ hash: args.digest, signature });
if (recovered.toLowerCase() === args.guardianAddress.toLowerCase()) return signature;
}
throw new Error("KMS signature did not recover to the KMS public key address");
}
function awsKmsSigner(): KmsTypedDataSigner {
const region = process.env.AWS_REGION?.trim() || process.env.AWS_DEFAULT_REGION?.trim();
if (!region) throw new Error("AWS_REGION or AWS_DEFAULT_REGION is required");
const keyId = process.env.KMS_KEY_ID?.trim() || required("GUARDIAN_AWS_KMS_KEY_ID");
const client = new KMSClient({ region });
let guardianAddress: Promise<Hex> | undefined;
const getGuardianAddress = () =>
(guardianAddress ??= client.send(new GetPublicKeyCommand({ KeyId: keyId })).then((res) => {
if (!res.PublicKey) throw new Error("AWS KMS GetPublicKey response missing PublicKey");
return publicKeyToGuardianAddress(res.PublicKey);
}));
return {
getAddress: getGuardianAddress,
async signTypedData(parameters) {
const digest = hashTypedData(parameters);
const res = await client.send(
new SignCommand({
KeyId: keyId,
Message: hexToBytes(digest),
MessageType: "DIGEST",
SigningAlgorithm: "ECDSA_SHA_256",
}),
);
if (!res.Signature) throw new Error("AWS KMS Sign response missing Signature");
return ethereumSignatureFromDer({
digest,
derSignature: res.Signature,
guardianAddress: await getGuardianAddress(),
});
},
};
}
function gcpKmsSigner(): KmsTypedDataSigner {
const keyVersionName = required("GUARDIAN_GCP_KMS_KEY_VERSION");
const client = new KeyManagementServiceClient();
let guardianAddress: Promise<Hex> | undefined;
const getGuardianAddress = () =>
(guardianAddress ??= client.getPublicKey({ name: keyVersionName }).then(([res]) => {
if (!res.pem) throw new Error("GCP KMS getPublicKey response missing pem");
return publicKeyToGuardianAddress(res.pem);
}));
return {
getAddress: getGuardianAddress,
async signTypedData(parameters) {
const digest = hashTypedData(parameters);
const [res] = await client.asymmetricSign({
name: keyVersionName,
digest: { sha256: hexToBytes(digest) },
});
if (!res.signature) throw new Error("GCP KMS asymmetricSign response missing signature");
return ethereumSignatureFromDer({
digest,
derSignature: bytesLikeToUint8Array(res.signature),
guardianAddress: await getGuardianAddress(),
});
},
};
}
function kmsSigner(): KmsTypedDataSigner {
const provider =
process.env.GUARDIAN_SIGNER_PROVIDER?.trim() ||
(process.env.GUARDIAN_GCP_KMS_KEY_VERSION?.trim() ? "gcp_kms" : "aws_kms");
if (provider === "aws_kms") return awsKmsSigner();
if (provider === "gcp_kms") return gcpKmsSigner();
throw new Error("GUARDIAN_SIGNER_PROVIDER must be aws_kms or gcp_kms");
}
const guardianApi = required("GUARDIAN_API").replace(/\/+$/, "");
const chainId = Number(required("CHAIN_ID"));
if (!Number.isInteger(chainId) || chainId <= 0) {
throw new Error("CHAIN_ID must be a positive integer");
}
const signer = kmsSigner();
const guardianAddress = await signer.getAddress();
const body = {
chainId,
facility: required("FACILITY"),
guardianAddress,
label: process.env.GUARDIAN_API_KEY_LABEL?.trim() || "guardian-kms",
};
const rawBody = JSON.stringify(body);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 300);
const nonce = `0x${randomBytes(32).toString("hex")}` as `0x${string}`;
const signature = await signer.signTypedData({
domain: { name: "grunt-api", version: "1" },
types: {
GuardianAdminRequest: [
{ name: "method", type: "string" },
{ name: "path", type: "string" },
{ name: "bodyHash", type: "bytes32" },
{ name: "deadline", type: "uint256" },
{ name: "nonce", type: "bytes32" },
],
},
primaryType: "GuardianAdminRequest",
message: {
method: "POST",
path: "/v1/generate-key",
bodyHash: keccak256(stringToBytes(rawBody)),
deadline,
nonce,
},
});
const res = await fetch(`${guardianApi}/v1/generate-key`, {
method: "POST",
headers: {
authorization: `Bearer ${signature}`,
"x-guardian-admin-deadline": deadline.toString(),
"x-guardian-admin-nonce": nonce,
"content-type": "application/json",
},
body: rawBody,
});
const text = await res.text();
if (!res.ok) throw new Error(`generate-key failed with HTTP ${res.status}: ${text}`);
console.log(text);

Run it with AWS KMS:

Terminal window
export GUARDIAN_API=https://guardian.dev.gcp.3f.xyz
export CHAIN_ID=11155111
export FACILITY=0xYourFacility
export GUARDIAN_SIGNER_PROVIDER=aws_kms
export AWS_REGION=us-east-1
export GUARDIAN_AWS_KMS_KEY_ID=arn:aws:kms:...
bun run generate-guardian-api-key.ts

Or run it with GCP KMS:

Terminal window
export GUARDIAN_API=https://guardian.dev.gcp.3f.xyz
export CHAIN_ID=11155111
export FACILITY=0xYourFacility
export GUARDIAN_SIGNER_PROVIDER=gcp_kms
export GUARDIAN_GCP_KMS_KEY_VERSION=projects/.../locations/.../keyRings/.../cryptoKeys/.../cryptoKeyVersions/1
bun run generate-guardian-api-key.ts

The response includes guardianAddress and apiKey. Store apiKey as COORDINATOR_API_KEY; it is shown once. Use the returned guardianAddress as GUARDIAN_SIGNER_ADDRESS in the coordinator environment.

The policy allowlist variables are address maps — semicolon-separated chains, each with a comma-separated address list:

chainId=0xAddress,0xAddress;chainId=0xAddress

GUARDIAN_CHAIN_RPC_URLS uses a different format — comma-separated chainId=url entries, one per chain:

chainId=https://rpc-a,chainId=https://rpc-b

Use lower-case addresses or correctly checksummed addresses.

Terminal window
# Guardian API connection
COORDINATOR_BASE_URL=https://guardian.dev.gcp.3f.xyz
COORDINATOR_API_KEY=3f_...
# Optional polling filters. Keep these set in production to avoid unrelated work.
CHAIN_IDS=11155111
FACILITIES=0xYourFacility
# AWS KMS signer
GUARDIAN_SIGNER_PROVIDER=aws_kms
GUARDIAN_SIGNER_ADDRESS=0xYourGuardianAddress
GUARDIAN_AWS_KMS_KEY_ID=arn:aws:kms:...
AWS_REGION=us-east-1
# GCP KMS signer instead of the AWS block above
# GUARDIAN_SIGNER_PROVIDER=gcp_kms
# GUARDIAN_SIGNER_ADDRESS=0xYourGuardianAddress
# GUARDIAN_GCP_KMS_KEY_VERSION=projects/.../locations/.../keyRings/.../cryptoKeys/.../cryptoKeyVersions/1
# Remote HTTP signer instead of a KMS block above
# GUARDIAN_SIGNER_PROVIDER=remote_http
# GUARDIAN_SIGNER_ADDRESS=0xYourGuardianAddress
# GUARDIAN_REMOTE_SIGNER_URL=https://signer.internal.example
# GUARDIAN_REMOTE_SIGNER_BEARER_TOKEN=... # optional
# GUARDIAN_REMOTE_SIGNER_TIMEOUT_MS=6000 # optional, default 6000
# Chain RPCs
GUARDIAN_CHAIN_RPC_URLS=11155111=https://sepolia.example-rpc
# Request binding policy
GUARDIAN_REQUEST_FACTORIES=11155111=0xRequestFactory
GUARDIAN_REQUEST_OWNERS=11155111=0xRequestOwner,0xRequestOwnerBackup
GUARDIAN_REQUEST_PULLERS=11155111=0xFacility
GUARDIAN_REQUEST_CONSUMERS=11155111=0xConsumerA,0xConsumerB
# Fund binding policy
GUARDIAN_ACCEPTED_FUNDS=11155111=0xFundA,0xFundB
GUARDIAN_FUND_OWNERS=11155111=0xFundOwner
# Swap policy
GUARDIAN_PM_FACTORIES=11155111=0xPositionManagerFactory
GUARDIAN_PM_OWNERS=11155111=0xPositionManagerOwner
# Optional tuning (values shown are the defaults)
POLL_INTERVAL_MS=5000
PAGE_SIZE=100
GUARDIAN_MAX_DEADLINE_SECONDS_AHEAD=600 # max future deadline a request may carry; raise if your flows need more lead time
GUARDIAN_EVENT_SCAN_BLOCK_RANGE=10000
GUARDIAN_EVENT_SCAN_MAX_LOOKBACK_BLOCKS=1000000
GUARDIAN_SWAP_PRICE_TOLERANCE_BPS=1

A remote HTTP signer (GUARDIAN_SIGNER_PROVIDER=remote_http) delegates signing to an external service. The coordinator POSTs { "typedData": { ... } } to GUARDIAN_REMOTE_SIGNER_URL and expects { "signature": "0x..." } in return (a bare JSON signature string is also accepted); the returned signature must recover to GUARDIAN_SIGNER_ADDRESS or signing fails before submission. The URL must use HTTPS unless it points at localhost.

For local development only, a raw private key can replace the AWS KMS block:

Terminal window
GUARDIAN_SIGNER_PROVIDER=private_key
GUARDIAN_SIGNER_KEY=0x...

From the 3f-guardian repo:

Terminal window
bun install
bun run --cwd packages/guardian-coordinator start

From an installed package:

Terminal window
guardian-coordinator

The coordinator polls GET /v1/guardian/signing-requests, signs valid open requests, and submits them with PUT /v1/guardian/signing-requests/:id/submission. A successful run logs submitted guardian signature for <request-id>.

graph LR
    Poll[Poll open<br/>signing-requests] --> Done{Already<br/>submitted?}
    Done -->|Yes| Poll
    Done -->|No| Valid{Passes policy and<br/>on-chain checks?}
    Valid -->|No| Skip[Log and skip]
    Valid -->|Yes| Sign[Sign with<br/>guardian key]
    Sign --> Submit[Submit signature]
    Submit --> Poll
    Skip --> Poll

The loop runs continuously: a failed poll or a single bad request is logged and the coordinator keeps going, so it is safe to leave running as a long-lived service.

  • GUARDIAN_SIGNER_ADDRESS is the same address returned by the keygen script.
  • The signer has the Guardian role on each configured Facility.
  • COORDINATOR_BASE_URL and GUARDIAN_API point at the Guardian API host, not the BF or LP host.
  • CHAIN_IDS and FACILITIES match the chains and facilities this coordinator should sign for.
  • Every policy address came from 3F deployment data, not from user input or an auction payload.
  • The API key and signer permissions (KMS or remote signer) are scoped to this coordinator runtime.