Architecture

System overview for the Symbiotic multi-provider template.

Core Model

  1. One active provider per running stack, selected in config/environments/<env>.json.
  2. Shared off-chain runtime:
    • OZ Monitor for ingress
    • 3 operator processes
    • 3 Symbiotic relay sidecars for BLS signatures
    • OZ Relayer for destination submission
    • Redis queue
  3. Provider-specific on-chain contracts and calldata format.

Provider Matrix

ProviderSource ingress eventDestination submit callLocalTestnetMainnet
layerzeroJobAssignedSymbioticLayerZeroDVN.submitProof(...)SupportedSupportedVerified end-to-end (operator-owned config)
chainlink_ccvCCIPMessageSentOffRamp.execute(...)SupportedSupportedNot yet

Shared Off-Chain Runtime

The provider abstraction decides:

  • which source-chain event the monitor watches
  • how operators encode the signed payload
  • which destination call the relayer submits

Merkle Batching

Messages are collected into Merkle trees so one quorum signature can cover many messages:

  1. ingress events become message records
  2. operators batch message leaves into a Merkle root
  3. the root is signed through the Symbiotic relay sidecars
  4. proofs let the destination verify individual messages against that signed root

Symbiotic Integration

Symbiotic provides the shared security layer:

  • operators register BLS public keys
  • settlement verifies quorum signatures
  • voting power and epoch rules define signature validity

Operator Internals

ModuleLocationPurpose
API Serveroperator/src/api/Axum HTTP server, webhook endpoint, debug routes
Provideroperator/src/provider/Provider trait, event decoding, message storage
SignerJoboperator/src/signer/Batches messages into Merkle trees, requests BLS signatures
RelaySubmitterJoboperator/src/relay_submitter/Submits signed proofs via OZ Relayer
Storageoperator/src/storage/redb key-value store for messages, Merkle trees, and submissions
Cryptooperator/src/crypto/Merkle tree construction, leaf hashing, signing message encoding

Production Topology

The starter kit runs everything on one machine; production needs a distributed layout. The parts that matter:

Attestation API availability

For the Chainlink CCV path, executors fetch attestations from the URLs the verifier advertises on-chain (getStorageLocations()). A single attestation node is a single point of failure that stalls the lane: messages keep verifying, but executors cannot fetch attestations, so nothing executes until the node returns.

Every operator node serves the same attestation data (GET /verifications) — all of them hold the messages and the aggregated quorum signatures. Production options, in order of preference:

  1. Load balancer over multiple operator nodes — advertise one stable URL, health-check /healthz, route to any live operator.
  2. Multiple URLs on-chaingetStorageLocations() returns a list; advertise several operators directly and let executors fail over.
  3. One designated HA node — acceptable only with real redundancy behind that name (failover, monitoring).

The advertised URL is on-chain state, not config: rotate it with the owner-gated updateStorageLocations and treat DNS you can repoint as more operable than raw hosts.

Where aggregation happens

Individual operators do not serve partial signatures. Each operator signs the Merkle root through its Symbiotic relay sidecar; the relay network aggregates BLS signatures across operators, and every operator then stores the same aggregated result. That is why any single operator can answer attestation queries — availability of the API is your concern; signature aggregation already tolerates individual signer downtime as long as quorum voting power signs.

Availability assumptions to engineer for

  • The CCIP indexer polls your attestation API — sustained unavailability means unexecuted (not lost) messages, recoverable once service returns.
  • Operators must reach source-chain RPC (event ingestion + finality gating) and the relay sidecars; run operators across failure domains.
  • The OZ Monitor→operator webhook path is at-least-once, not exactly-once; operators de-duplicate. Monitor outages are recovered by block backfill on restart — bound your max_past_blocks accordingly.

Adding a New Provider

  1. Implement the Provider trait in operator/src/provider/<provider>.rs.
#[async_trait]
pub trait Provider: Send + Sync + 'static {
    fn name(&self) -> &'static str;
    async fn handle_webhook_event(&self, event: &WebhookEvent) -> Result<(), ProviderError>;

    // Required for a functional provider: the trait ships default impls for
    // these that return an error, so the signing/submission path fails until
    // you override all three.
    fn compute_leaf_hash(&self, message: &MessageData) -> Result<B256, ProviderError>;
    fn encode_signing_message(&self, tree: &MerkleTreeData) -> Result<Vec<u8>, ProviderError>;
    fn prepare_submission(
        &self,
        message: &MessageData,
        tree: &MerkleTreeData,
        proof: &MerkleProof,
        target_address: &str,
    ) -> Result<PreparedSubmission, ProviderError>;

    // Optional overrides (sensible defaults provided):
    fn register_api_routes(&self, router: Router<AppState>) -> Router<AppState> { router }
    fn max_batch_size(&self) -> usize { usize::MAX }
    async fn acceptance_hook(
        &self,
        _msg: &MessageData,
        _context: &AcceptanceContext,
    ) -> Result<AcceptanceDecision, ProviderError> {
        Ok(AcceptanceDecision::accept())
    }
    fn verifier_result_for(
        &self,
        _id: &B256,
    ) -> Result<Option<VerifierResult>, ProviderError> {
        Ok(None)
    }
}
  1. Add provider config to operator/src/config/mod.rs.
  2. Register the provider in operator/src/provider/mod.rs.
  3. Add monitor templates under config/templates/oz-monitor/.
  4. Add docs/<provider>.mdx and update the docs index.