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.
Environments
Section titled “Environments”| Environment | Chains | Base URL |
|---|---|---|
| Development | Ethereum Sepolia | https://guardian.dev.gcp.3f.xyz |
| Production | Ethereum Mainnet | https://guardian.3f.xyz |
What the Coordinator Signs
Section titled “What the Coordinator Signs”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.
Setup Plan
Section titled “Setup Plan”- Confirm the guardian signer has the Guardian role on the target Facility.
- 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.
- Generate a Guardian API key with the same signer address the coordinator will use.
- Fill the coordinator environment with the Guardian API URL, API key, signer settings, RPC URLs, and policy allowlists.
- Start
guardian-coordinatorand watch for submitted signatures.
Requirements
Section titled “Requirements”- Bun installed. The
3f-guardianrepo pins Bun throughpackageManager. - 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_P256K1key withSIGN_VERIFYusage, plus IAM permission to callkms:GetPublicKeyandkms: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.
Generate the API Key
Section titled “Generate the API Key”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:
mkdir guardian-keygencd guardian-keygenbun init -ybun add @aws-sdk/client-kms @google-cloud/kms @noble/curves@1.9.1 viemSave 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:
export GUARDIAN_API=https://guardian.dev.gcp.3f.xyzexport CHAIN_ID=11155111export FACILITY=0xYourFacilityexport GUARDIAN_SIGNER_PROVIDER=aws_kmsexport AWS_REGION=us-east-1export GUARDIAN_AWS_KMS_KEY_ID=arn:aws:kms:...
bun run generate-guardian-api-key.tsOr run it with GCP KMS:
export GUARDIAN_API=https://guardian.dev.gcp.3f.xyzexport CHAIN_ID=11155111export FACILITY=0xYourFacilityexport GUARDIAN_SIGNER_PROVIDER=gcp_kmsexport GUARDIAN_GCP_KMS_KEY_VERSION=projects/.../locations/.../keyRings/.../cryptoKeys/.../cryptoKeyVersions/1
bun run generate-guardian-api-key.tsThe 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.
Coordinator Environment
Section titled “Coordinator Environment”The policy allowlist variables are address maps — semicolon-separated chains, each with a comma-separated address list:
chainId=0xAddress,0xAddress;chainId=0xAddressGUARDIAN_CHAIN_RPC_URLS uses a different format — comma-separated chainId=url entries, one per chain:
chainId=https://rpc-a,chainId=https://rpc-bUse lower-case addresses or correctly checksummed addresses.
# Guardian API connectionCOORDINATOR_BASE_URL=https://guardian.dev.gcp.3f.xyzCOORDINATOR_API_KEY=3f_...
# Optional polling filters. Keep these set in production to avoid unrelated work.CHAIN_IDS=11155111FACILITIES=0xYourFacility
# AWS KMS signerGUARDIAN_SIGNER_PROVIDER=aws_kmsGUARDIAN_SIGNER_ADDRESS=0xYourGuardianAddressGUARDIAN_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 RPCsGUARDIAN_CHAIN_RPC_URLS=11155111=https://sepolia.example-rpc
# Request binding policyGUARDIAN_REQUEST_FACTORIES=11155111=0xRequestFactoryGUARDIAN_REQUEST_OWNERS=11155111=0xRequestOwner,0xRequestOwnerBackupGUARDIAN_REQUEST_PULLERS=11155111=0xFacilityGUARDIAN_REQUEST_CONSUMERS=11155111=0xConsumerA,0xConsumerB
# Fund binding policyGUARDIAN_ACCEPTED_FUNDS=11155111=0xFundA,0xFundBGUARDIAN_FUND_OWNERS=11155111=0xFundOwner
# Swap policyGUARDIAN_PM_FACTORIES=11155111=0xPositionManagerFactoryGUARDIAN_PM_OWNERS=11155111=0xPositionManagerOwner
# Optional tuning (values shown are the defaults)POLL_INTERVAL_MS=5000PAGE_SIZE=100GUARDIAN_MAX_DEADLINE_SECONDS_AHEAD=600 # max future deadline a request may carry; raise if your flows need more lead timeGUARDIAN_EVENT_SCAN_BLOCK_RANGE=10000GUARDIAN_EVENT_SCAN_MAX_LOOKBACK_BLOCKS=1000000GUARDIAN_SWAP_PRICE_TOLERANCE_BPS=1A 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:
GUARDIAN_SIGNER_PROVIDER=private_keyGUARDIAN_SIGNER_KEY=0x...Run the Coordinator
Section titled “Run the Coordinator”From the 3f-guardian repo:
bun installbun run --cwd packages/guardian-coordinator startFrom an installed package:
guardian-coordinatorThe 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.
Policy Checklist
Section titled “Policy Checklist”GUARDIAN_SIGNER_ADDRESSis the same address returned by the keygen script.- The signer has the Guardian role on each configured Facility.
COORDINATOR_BASE_URLandGUARDIAN_APIpoint at the Guardian API host, not the BF or LP host.CHAIN_IDSandFACILITIESmatch 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.
Related
Section titled “Related”- Roles — the on-chain Guardian role and how guardian signatures are enforced.
- Facility, Request, Fund, and Position Manager — the protocol objects the signing policies allowlist.