Architecture
System overview for the Symbiotic multi-provider template.
Core Model
- One active provider per running stack, selected in
config/environments/<env>.json. - 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
- Provider-specific on-chain contracts and calldata format.
Provider Matrix
| Provider | Source ingress event | Destination submit call | Local | Testnet | Mainnet |
|---|---|---|---|---|---|
layerzero | JobAssigned | SymbioticLayerZeroDVN.submitProof(...) | Supported | Supported | Verified end-to-end (operator-owned config) |
chainlink_ccv | CCIPMessageSent | OffRamp.execute(...) | Supported | Supported | Not 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:
- ingress events become message records
- operators batch message leaves into a Merkle root
- the root is signed through the Symbiotic relay sidecars
- 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
| Module | Location | Purpose |
|---|---|---|
| API Server | operator/src/api/ | Axum HTTP server, webhook endpoint, debug routes |
| Provider | operator/src/provider/ | Provider trait, event decoding, message storage |
| SignerJob | operator/src/signer/ | Batches messages into Merkle trees, requests BLS signatures |
| RelaySubmitterJob | operator/src/relay_submitter/ | Submits signed proofs via OZ Relayer |
| Storage | operator/src/storage/ | redb key-value store for messages, Merkle trees, and submissions |
| Crypto | operator/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:
- Load balancer over multiple operator nodes — advertise one stable URL, health-check
/healthz, route to any live operator. - Multiple URLs on-chain —
getStorageLocations()returns a list; advertise several operators directly and let executors fail over. - 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_blocksaccordingly.
Adding a New Provider
- Implement the
Providertrait inoperator/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)
}
}- Add provider config to
operator/src/config/mod.rs. - Register the provider in
operator/src/provider/mod.rs. - Add monitor templates under
config/templates/oz-monitor/. - Add
docs/<provider>.mdxand update the docs index.