@3flabs/guardian-coordinator
v0.3.0
Published
Lite in-process coordinator that submits @3flabs/guardian signatures to grunt-api.
Readme
@3flabs/guardian-coordinator
Lite coordinator for polling grunt-api signing requests, signing them in-process, and submitting the signatures back to grunt-api.
From this repo:
bun run --cwd packages/guardian-coordinator startFrom an installed package:
guardian-coordinatorRequired env:
COORDINATOR_BASE_URL(https://, orhttp://localhostfor local dev)COORDINATOR_API_KEYGUARDIAN_SIGNER_KEYwhenGUARDIAN_SIGNER_PROVIDERis unset orprivate_keyGUARDIAN_SIGNER_ADDRESSandGUARDIAN_REMOTE_SIGNER_URLwhenGUARDIAN_SIGNER_PROVIDER=remote_http. The remote signer URL must usehttps://, except localhost URLs may usehttp://for local development.GUARDIAN_SIGNER_ADDRESSandGUARDIAN_AWS_KMS_KEY_IDwhenGUARDIAN_SIGNER_PROVIDER=aws_kmsGUARDIAN_SIGNER_ADDRESSandGUARDIAN_GCP_KMS_KEY_VERSIONwhenGUARDIAN_SIGNER_PROVIDER=gcp_kmsGUARDIAN_CHAIN_RPC_URLSaschainId=url,chainId=urlGUARDIAN_REQUEST_FACTORIESaschainId=addr,addr;chainId=addrGUARDIAN_REQUEST_OWNERSGUARDIAN_REQUEST_PULLERSGUARDIAN_REQUEST_CONSUMERSGUARDIAN_ACCEPTED_FUNDSGUARDIAN_FUND_OWNERSGUARDIAN_PM_FACTORIESGUARDIAN_PM_OWNERS
Optional env:
POLL_INTERVAL_MSdefault5000PAGE_SIZEdefault100REQUEST_TIMEOUT_MSdefault10000; per-request timeout for grunt-api calls. A hung request aborts and surfaces as a failed poll instead of freezing the loop.CHAIN_IDScomma-separated signing-request filterFACILITIEScomma-separated signing-request filter (facility, or whitelist book forrequest_whitelisting)GUARDIAN_SIGNER_PROVIDERdefaultprivate_key; supported values areprivate_key,remote_http,aws_kms, andgcp_kmsGUARDIAN_REMOTE_SIGNER_BEARER_TOKENbearer token forremote_httpGUARDIAN_REMOTE_SIGNER_TIMEOUT_MSdefault6000; request timeout forremote_httpAWS_REGIONorAWS_DEFAULT_REGIONforaws_kmswhen the default AWS SDK chain cannot infer a regionGUARDIAN_MAX_DEADLINE_SECONDS_AHEADdefault600GUARDIAN_EVENT_SCAN_BLOCK_RANGEdefault10000GUARDIAN_EVENT_SCAN_MAX_LOOKBACK_BLOCKSdefault1000000GUARDIAN_MAX_NONCE_ABOVE_FLOORdefault100, for request-whitelisting nonce windowsGUARDIAN_FLASH_LOAN_REQUEST_FACTORIESoptionalchainId=addr,addr;chainId=addrGUARDIAN_FLASH_LOAN_REQUEST_EXECUTORSoptionalchainId=addr,addr;chainId=addrGUARDIAN_ACCEPTED_WHITELIST_BOOKSoptionalchainId=addr,addr;chainId=addrGUARDIAN_TRUSTED_REQUEST_CONTRACTSoptionalchainId=addr,addr;chainId=addr. Request contracts on this list skip factory-provenance, owner, and puller / consumer / executor role verification for bothset_requestandrequest_whitelisting, with no on-chain read at all — only the deadline check still applies. Unset (the default) validates every request contract. The Guardian will sign for a listed contract whatever its owner and role holders turn out to be, including grants made after it was listed, so keep it to contracts whose configuration is under the same control as the Guardian's own key material. Every bypass is logged at warn level.GUARDIAN_ACCEPTED_RETARGETTERSoptionalchainId=addr,addr;chainId=addr. Request contracts whoseowner()is a listed Retargetter take the §A.1 retargetter path for bothset_requestandrequest_whitelisting: factory-provenance, owner, and puller / consumer role verification are skipped (no role-events scan) and the retargetter's liveoperation()is checked instead — the request contract must be its attached operation request, with a repayment deadline at leastGUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDSahead. Unset (the default) keeps every request contract on the classic path. The path trusts the retargetter's code for provenance and role configuration, so list only retargetters deployed under the same control as the accepted factories.GUARDIAN_MIN_RETARGETTER_REPAYMENT_BUFFER_SECONDSdefault6912000(80 days, the Retargetter's on-chainMIN_DEADLINE_BUFFER); minimum runway the retargetter operation's repayment deadline must have for the retargetter path to pass.GUARDIAN_SIGN_TIMEOUT_MSdefault6000; budget for one whole validate-and-sign call, including the on-chain reads. Raise it when role-events scans over a wideGUARDIAN_EVENT_SCAN_MAX_LOOKBACK_BLOCKS— orrequest_whitelistingbatches, which scan per request contract — need longer than the default.GUARDIAN_SWAP_PRICE_TOLERANCE_BPSdefault1
Lifecycle and heartbeat lines
The CLI emits three JSON lines of its own, shaped alike:
{"level":"info","event":"guardian.startup","build":"1.2.3","chains":[1]}
{"level":"info","event":"guardian.heartbeat","ok":true,"fetched":0,"signed":0,"skipped":0,"failed":0,"durationMs":12}
{"level":"fatal","event":"guardian.fatal","err":"COORDINATOR_BASE_URL is required"}guardian.startup— stdout, once, after config and signer construction succeed. It means the config parsed and the signer was constructed — not that the signer or RPCs were exercised. The first heartbeat is the first proof of live work.guardian.heartbeat— stdout, one per poll cycle, including cycles whose poll failed (ok: false); its absence means the loop is dead or wedged. The gap between heartbeats is poll duration +POLL_INTERVAL_MS, not just the interval: a busy cycle (many requests ×GUARDIAN_SIGN_TIMEOUT_MS, plus event scans) legitimately stretches it. Size any absence alert to the worst-case cycle duration, not to the poll interval, or a burst of signing work will page you for nothing.guardian.fatal— stderr, right before the process exits with code 1, whether the failure happened at boot (bad config) or later. Carries astackfield, appended aftererr, when the thrown value has one.
The serialized shapes are a monitoring contract pinned by tests in
tests/coordinator.test.ts; changing them breaks downstream alerting.
log-contract.json at the package root is the machine-readable version of this
contract: one regex per guaranteed line (the three lines above plus the
submitted guardian signature for / guardian coordinator poll failed: /
failed guardian signing request / skipping malformed guardian signing request:
prefixes). Monitoring should build its rules from that file.
tests/log-contract.test.ts verifies it in both directions — every pattern is
emitted by a real code path, and every emitted line matches a pattern — so
adding, removing, or rewording a log line without updating the contract fails CI.
For remote_http, the coordinator sends:
{ "typedData": { "domain": {}, "types": {}, "primaryType": "...", "message": {} } }The remote signer must return either { "signature": "0x..." } or the signature
as a JSON string. The returned signature must recover to
GUARDIAN_SIGNER_ADDRESS; otherwise signing fails before submission.
For aws_kms, the KMS key must be an asymmetric ECC_SECG_P256K1 key with
SIGN_VERIFY usage. The coordinator signs the EIP-712 digest with AWS KMS
ECDSA_SHA_256 / MessageType=DIGEST.
For gcp_kms, the key version must use EC_SIGN_SECP256K1_SHA256.
For both KMS providers, the returned signature must recover to
GUARDIAN_SIGNER_ADDRESS; otherwise signing fails before submission.
Programmatic use is still available:
import {
loadCoordinatorConfig,
runGuardianCoordinator,
} from "@3flabs/guardian-coordinator";
import { buildGuardianFromEnv } from "@3flabs/guardian-coordinator/cli";
await runGuardianCoordinator({
...loadCoordinatorConfig(process.env),
guardian: buildGuardianFromEnv(process.env),
});