Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions content/symbiotic/acceptance-hooks.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
---
title: Acceptance Hooks
---

Acceptance hooks run before a message is batched for signing. They let operators enforce local policy or call an external approval service without changing provider event decoding.

## Decisions

| Decision | Meaning |
| --- | --- |
| `accept` | Message proceeds to batching. |
| `reject` | Message is marked `Rejected` and is not evaluated again. |
| `defer` | Message is held until the returned RFC 3339 `until` timestamp. |

When multiple hooks are configured, hooks run in order. The first `reject` stops evaluation. Otherwise all hooks run, and the final decision is the latest `defer` timestamp if any hook deferred, or `accept`.

## Operator Config

Hooks are configured in `config/environments/<env>.json` under `operator.acceptanceHooks`:

```json
{
"operator": {
"acceptanceHooks": [
{ "type": "native", "name": "provider" },
{
"type": "webhook",
"name": "approval",
"url": "http://approval-service:8088/",
"secret": "shared-secret",
"headers": {
"Authorization": { "type": "env", "value": "APPROVAL_HOOK_AUTHORIZATION" },
"X-Operator": "operator-a"
},
"timeout": "5s",
"errorBackoff": "30s",
"maxAttempts": 3
}
]
}
}
```

If `acceptanceHooks` is empty or omitted, the operator runs the built-in native `provider` hook for compatibility. Today the only named native hook is `provider`.

Webhook hooks may include a `headers` object for additional outbound HTTP headers. Header values can be plain strings or `{ "type": "env", "value": "ENV_VAR_NAME" }` references; env references are resolved at operator startup and fail startup if missing or empty. `Content-Type` and `X-Hook-Signature` are reserved by the framework.

## Native Hooks

Native hooks are Rust code compiled into the operator. Use them for low-latency checks that need direct access to provider types or operator-local state.

The supported native hook today is:

| Name | Where to implement | Reference |
| --- | --- | --- |
| `provider` | Override `Provider::acceptance_hook` in the active provider implementation | `operator/src/provider/layerzero.rs` has the minimal pass-through implementation |

The trait contract lives in `operator/src/provider/mod.rs`:

```rust
async fn acceptance_hook(
&self,
msg: &MessageData,
context: &AcceptanceContext,
) -> Result<AcceptanceDecision, ProviderError>;
```

Guidelines for native hook changes:

- Return `AcceptanceDecision::accept()` when the message should proceed.
- Return `AcceptanceDecision::Reject { reason }` only for terminal policy failures. The message will not be evaluated again.
- Return `AcceptanceDecision::Defer { until, reason }` for temporary holds. The operator persists the defer state and re-evaluates on or after `until`.
- Use `context.defer_count` to give up after repeated defers and return `reject` when appropriate. This is a total framework defer count, including policy defers and operator-driven defers caused by hook errors.
- Use `context.previous_defer_reason` only for diagnostics or policy continuity; do not parse it as a stable machine contract.
- Avoid long blocking work in native hooks. If policy depends on a slow external system, prefer a webhook hook.
- Do not implement retry loops in the hook. Return the current decision and let the operator re-evaluate later.
- Avoid side effects. If unavoidable, key them by `message_id` and make them idempotent.
- Return `Err` only for true implementation failures. Native hook errors are converted into terminal `reject` decisions by the framework, so transient conditions should return `defer` instead.

Minimal native accept:

```rust
async fn acceptance_hook(
&self,
_msg: &MessageData,
_context: &AcceptanceContext,
) -> Result<AcceptanceDecision, ProviderError> {
Ok(AcceptanceDecision::accept())
}
```

Native reject/defer examples are covered by the signer tests in `operator/src/signer/mod.rs` (`RejectAllProvider` and `DeferAllProvider`).

If a new named native hook is needed outside the provider hook, add a new `AcceptanceHookConfig::Native` name and wire it in `SignerJob::evaluate_acceptance_hooks`.

## Webhook Contract

The operator sends:

```http
POST <configured-url>
Content-Type: application/json
X-Hook-Signature: sha256=<hex(HMAC-SHA256(secret, body))>
```

```json
{
"message": {
"metadata": {
"source_chain": 1,
"destination_chain": 31338,
"block_number": 12345,
"message_id": "0x...",
"event_tx_hash": "0x..."
},
"data": "<base64 provider payload>"
},
"context": {
"defer_count": 0,
"previous_defer_reason": null
}
}
```

The webhook returns `200 OK` with one of:

```json
{ "decision": "accept" }
{ "decision": "reject", "reason": "amount above cap" }
{ "decision": "defer", "until": "2027-05-15T12:34:56Z", "reason": "awaiting approval" }
```

`reason` is optional. `until` is required for `defer`.

`context.defer_count` is the total number of times the framework has deferred this message. It includes successful policy defers and operator-driven defers caused by webhook errors. Webhook unreachability is handled by `maxAttempts`, so do not treat `defer_count` as policy-only history.

Webhook errors are not rejections. A non-2xx response, connection error, timeout, malformed JSON, or missing/invalid `until` auto-defers the message by `errorBackoff`. After `maxAttempts` consecutive errors for the same hook and message, the operator rejects the message with reason `approval service unreachable after N attempts`.

Webhook URLs must use `http` or `https`. For production, prefer `https` or trusted in-cluster networking because message metadata is sent in the JSON body.

Configured headers are sent with every request after startup-time validation. Use them for service-specific auth mechanisms such as `Authorization: Bearer ...` or tenant routing headers. Do not configure `Content-Type` or `X-Hook-Signature`; the operator owns those headers.

The reference FastAPI implementation lives in `examples/webhook-hook/`. It is intentionally not Rust: webhook hooks are language-agnostic, and the example demonstrates the wire contract an external service must implement.
123 changes: 123 additions & 0 deletions content/symbiotic/architecture.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
title: 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

| Provider | Source ingress event | Destination submit call | Local | Testnet | Mainnet |
| --- | --- | --- | --- | --- | --- |
| [`layerzero`](/symbiotic/layerzero) | `JobAssigned` | `SymbioticLayerZeroDVN.submitProof(...)` | Supported | Supported | Verified end-to-end (operator-owned config) |
| [`chainlink_ccv`](/symbiotic/chainlink-ccv) | `CCIPMessageSent` | `OffRamp.execute(...)` | Supported | Supported | Not yet |

## Shared Off-Chain Runtime

```mermaid
flowchart LR
subgraph source["Source Chain"]
Event["Provider Ingress Event"]
end

Event --> Monitor["OZ Monitor"]
Monitor --> Operators["Operators (x3)"]
Operators <--> Relays["Symbiotic Relays\n(BLS sidecars)"]
Operators --> Relayer["OZ Relayer"]

subgraph dest["Destination Chain"]
Submit["Provider Destination Call"]
end

Relayer --> Submit
```

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

| 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 |

## Adding a New Provider

1. Implement the `Provider` trait in `operator/src/provider/<provider>.rs`.

```rust
#[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)
}
}
```

2. Add provider config to `operator/src/config/mod.rs`.
3. Register the provider in `operator/src/provider/mod.rs`.
4. Add monitor templates under `config/templates/oz-monitor/`.
5. Add `docs/<provider>.mdx` and update [the docs index](/symbiotic).
93 changes: 93 additions & 0 deletions content/symbiotic/chainlink-ccv.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
title: Chainlink CCV
---

Symbiotic-secured Cross-Chain Verifier for Chainlink CCIP-compatible message verification.

## Overview

The CCV provider watches `CCIPMessageSent` events, builds the verifier payload, collects BLS attestations through Symbiotic relay sidecars, and submits execution calldata to the destination OffRamp path. Success is confirmed when the destination emits `MessageExecuted(messageId)`.

<Callout>

This template supports the Symbiotic CCV path only. The Chainlink auxiliary devenv stack is not required.

</Callout>

## Message Flow

```mermaid
sequenceDiagram
participant App as Source App
participant OnRamp as OnRamp (Source)
participant Monitor as OZ Monitor
participant Operators as Operators (x3)
participant Relay as Symbiotic Relay (BLS)
participant Relayer as OZ Relayer
participant OffRamp as OffRamp (Dest)
participant CCV as SymbioticCCV (Dest)

App->>OnRamp: sendMessage()
OnRamp-->>Monitor: CCIPMessageSent event
Monitor->>Operators: HMAC webhook
Operators->>Operators: build CCV payload + Merkle tree
Operators->>Relay: sign Merkle root
Relay-->>Operators: aggregated signature
Operators->>Relayer: OffRamp.execute calldata
Relayer->>OffRamp: execute(message, ccvs, verifierResults)
OffRamp->>CCV: verifyMessage(message, messageId, verifierResults)
CCV-->>OffRamp: verified
OffRamp-->>OffRamp: emit MessageExecuted(messageId)
```

## Contracts and Code

### Contracts

- `contracts/src/ccv/SymbioticCCV.sol`
- `contracts/src/ccv/interfaces/`
- `contracts/src/ccv/libraries/`
- `contracts/src/symbiotic/Settlement.sol`
- `contracts/src/symbiotic/KeyRegistry.sol`
- `contracts/src/symbiotic/Driver.sol`

### Operator

- `operator/src/provider/chainlink_ccv.rs`
- `operator/src/provider/mod.rs`

### Monitor Templates

- `config/templates/oz-monitor/monitors/ccip_message_sent.json`

## Configuration

Select CCV in `config/environments/<env>.json`:

```json
{
"activeProvider": "chainlink_ccv"
}
```

CCIP chain selectors come from `chains.source.ccipChainSelector` and `chains.destination.ccipChainSelector`, which are required for non-local environments. They can be overridden at runtime via the `CCV_SOURCE_CHAIN_SELECTOR` / `CCV_DEST_CHAIN_SELECTOR` environment variables. For local Anvil only (chainId `31337`), the selector falls back to `chainId` when `ccipChainSelector` is unset.

Address resolution order:

1. `CCV_*` environment variables
2. `deployments/<env>.json`

Available overrides:

| Variable | Description |
|----------|-------------|
| `CCV_SOURCE_ADDRESS` | SymbioticCCV on source chain |
| `CCV_DEST_ADDRESS` | SymbioticCCV on destination chain |
| `CCV_SOURCE_ONRAMP_ADDRESS` | Source OnRamp-compatible contract |
| `CCV_DEST_OFFRAMP_ADDRESS` | Destination OffRamp submit target |
| `CCV_SOURCE_CHAIN_SELECTOR` | Override the source CCIP chain selector |
| `CCV_DEST_CHAIN_SELECTOR` | Override the destination CCIP chain selector |

Local and testnet are supported (testnet uses the `testnet-ccv` environment: `config/environments/testnet-ccv.json` and `deployments/testnet-ccv.json`, run via `ENV=testnet-ccv`); mainnet is not yet supported for CCV. `make watch` only succeeds once destination `MessageExecuted(messageId)` is observed.

See [Setup](/symbiotic/setup) and [CLI & API Reference](/symbiotic/cli) for operation.
Loading