From 77c0bed7127f0a0824a684a3011d071441d90f7c Mon Sep 17 00:00:00 2001 From: Dylan Kilkenny Date: Mon, 27 Jul 2026 17:51:55 +0100 Subject: [PATCH 1/2] docs(symbiotic): sync CCV resolver/verifier split and production guidance Signed-off-by: Dylan Kilkenny --- content/symbiotic/architecture.mdx | 26 ++++++++++ content/symbiotic/chainlink-ccv.mdx | 76 ++++++++++++++++++++++++++--- content/symbiotic/cli.mdx | 22 ++++++++- content/symbiotic/deployment.mdx | 49 ++++++++++++++++++- content/symbiotic/layerzero.mdx | 6 +-- content/symbiotic/security.mdx | 73 ++++++++++++++++++++++++--- content/symbiotic/setup.mdx | 6 +++ 7 files changed, 238 insertions(+), 20 deletions(-) diff --git a/content/symbiotic/architecture.mdx b/content/symbiotic/architecture.mdx index 913f5969..9af2a5a2 100644 --- a/content/symbiotic/architecture.mdx +++ b/content/symbiotic/architecture.mdx @@ -75,6 +75,32 @@ Symbiotic provides the shared security layer: | 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: + +1. **Load balancer over multiple operator nodes** — advertise one stable URL, health-check `/healthz`, route to any live operator. +2. **Multiple URLs on-chain** — `getStorageLocations()` 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/.rs`. diff --git a/content/symbiotic/chainlink-ccv.mdx b/content/symbiotic/chainlink-ccv.mdx index 0c38ee5a..c4b9d8c8 100644 --- a/content/symbiotic/chainlink-ccv.mdx +++ b/content/symbiotic/chainlink-ccv.mdx @@ -14,6 +14,24 @@ This template supports the Symbiotic CCV path only. The Chainlink auxiliary deve +## On-Chain Architecture: Resolver + Verifier + +The CCV identity that CCIP references is split across two contracts: + +- **Resolver** — Chainlink's stock `VersionedVerifierResolver`, deployed via Chainlink's `CREATE2Factory` so it has the **same address on every supported chain**. This is the permanent address: it is what OnRamp/OffRamp configuration and the CCIP indexer register, and it never changes. It holds two registries: version prefix → inbound verifier implementation, and destination chain selector → outbound implementation. +- **Verifier implementation** — `SymbioticVerifier`, which inherits Chainlink's `BaseVerifier` and adds Symbiotic BLS quorum verification against `Settlement`. Implementations are versioned and replaceable. + +Every verifier result starts with a 4-byte version tag (`0x1a75bd93` for v1, derived from `keccak256("SymbioticCCV 1.0.0")`). Signers commit to `keccak256(versionTag ‖ messageId)`, so the tag both routes the message to the right implementation and prevents a signature produced for one implementation from being replayed against another. + +### Upgrading the verifier + +Deploying verification logic v2 never changes the address integrators use: + +1. Deploy the new `SymbioticVerifier` with a new version tag and configure its lanes. +2. Register it on the resolver: `applyInboundImplementationUpdates([{tag_v2, v2}])` — v1 and v2 now coexist, and in-flight messages signed under v1 still resolve to v1. +3. Switch outbound per lane: `applyOutboundImplementationUpdates([{selector, v2}])` — new messages carry v2's tag. +4. After v1 traffic drains, optionally retire v1's tag by registering it to `address(0)`. + ## Message Flow ```mermaid @@ -25,7 +43,8 @@ sequenceDiagram participant Relay as Symbiotic Relay (BLS) participant Relayer as OZ Relayer participant OffRamp as OffRamp (Dest) - participant CCV as SymbioticCCV (Dest) + participant Resolver as Resolver (Dest) + participant Verifier as SymbioticVerifier (Dest) App->>OnRamp: sendMessage() OnRamp-->>Monitor: CCIPMessageSent event @@ -35,18 +54,58 @@ sequenceDiagram 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->>Resolver: getInboundImplementation(verifierResults[:4]) + Resolver-->>OffRamp: SymbioticVerifier (by version tag) + OffRamp->>Verifier: verifyMessage(message, messageId, verifierResults) + Verifier-->>OffRamp: verified (RMN curse check, OffRamp auth via Router, BLS quorum) OffRamp-->>OffRamp: emit MessageExecuted(messageId) ``` +## Verification Gas Budget + +Each lane's `gasForVerification` (set via `applyRemoteChainConfigUpdates`, returned by `getFee`) must cover the full `verifyMessage` call. Its cost scales with the validator set in three ways: + +- **proof handling in the verifier** — the quorum proof embeds the entire validator set (64 bytes per validator), and the current `SymbioticVerifier` implementation copies the proof byte-by-byte before handing it to `Settlement`. At large sets this copy dominates everything else; +- **signature verification** (`SigVerifierBlsBn254Simple`) — hashes the full validator-set data against the committed set, then pays a key decompression plus curve addition for each validator that did *not* sign, plus one BLS pairing (~113k, fixed); +- **fixed base** — `BaseVerifier` ramp/RMN checks and `Settlement` epoch bookkeeping. + +Measured end to end with the repo's gas harness (real `SymbioticVerifier` → `Settlement` hop → real `SigVerifierBlsBn254Simple`, synthetic equal-power validator sets): + +```bash +cd contracts && forge test --match-contract VerificationGas -vv +``` + +| Validators | All sign | 33% non-signers (by count) | +|-----------:|---------:|---------------------------:| +| 3 | 292k | 296k | +| 10 | 407k | 421k | +| 25 | 657k | 693k | +| 50 | 1.09M | 1.16M | +| 100 | 2.00M | 2.17M | + +The 3-validator row is within ~5% of what Chainlink measured on the staging deployment (299k–312k across live messages), so treat the table as representative. Interpolate for your set size and add a **safety margin (recommend 20%)**. The staging config value of `400000` sits *below* the measured cost of a 10-validator set (407k before margin) — it is sized for the current small staging set, not a constant. (Do not benchmark against the unit tests: `SymbioticVerifier.t.sol` stubs `Settlement`, so its gas numbers exclude all of the above.) + +Two caveats when sizing the non-signer allowance: + +- Quorum is enforced on **voting power, not head count**. With unequal stake weights, far more than a third of validators (by count) can abstain while quorum still passes — and each absent validator costs decompression + addition. Derive your worst case from the actual power distribution; the table's second column is the equal-power case. +- **Re-evaluate whenever the validator set grows** — operator registration changes the set automatically at epoch boundaries, so an under-provisioned value does not fail immediately; it fails when the set outgrows it, and messages then stall at verification. Alert on validator-set size, not on failures. + +Because per-message cost grows roughly linearly with the set (~18k gas per validator end to end in the current implementation), Symbiotic's ZK verification mode — near-constant gas regardless of set size — becomes the cheaper option well before very large sets. If your set is growing past a few dozen validators, evaluate the switch; it deploys as a verifier-implementation upgrade (see above — the resolver address does not change). + +## Recommended Verifier Composition (Token Issuers) + +**Run the Chainlink committee verifier alongside the Symbiotic verifier as your default.** Two independent CCVs give defense in depth out of the box: a message executes only when both verifiers attest, so compromising either the committee or the Symbiotic validator set alone is not sufficient to forge a message. + +Configure both CCVs (the Chainlink committee verifier plus this resolver's address) in your token pool / app CCV list. Symbiotic-only operation is supported, but treat it as a **conscious, documented opt-out** — you are removing an independent verification layer, and you should record that decision and its rationale in your own security documentation. + +Token issuers can also **supply their own token as the collateral securing the verifier network** (self-securing collateral). This needs nothing CCV-specific: create a Symbiotic vault, deposit the asset, and have operators opt in — standard [Symbiotic vault creation](https://docs.symbiotic.fi/), with your token as the staked asset backing the validator set's economic security. + ## Contracts and Code ### Contracts -- `contracts/src/ccv/SymbioticCCV.sol` -- `contracts/src/ccv/interfaces/` -- `contracts/src/ccv/libraries/` +- `contracts/src/chainlink/SymbioticVerifier.sol` — verifier implementation (inherits Chainlink `BaseVerifier`) +- Resolver + factory: stock contracts from `@chainlink/contracts-ccip` (`VersionedVerifierResolver`, `CREATE2Factory`) — deployed from the package, no local source - `contracts/src/symbiotic/Settlement.sol` - `contracts/src/symbiotic/KeyRegistry.sol` - `contracts/src/symbiotic/Driver.sol` @@ -77,16 +136,17 @@ Address resolution order: 1. `CCV_*` environment variables 2. `deployments/.json` +The resolver address is the exception: it has no environment override and is always read from `deployments/.json` (`chainlinkCcv.resolver`), matching the CREATE2 determinism guarantee. + 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 | +| `CCV_STORAGE_LOCATION_URIS` | Comma-separated attestation API base URLs advertised on-chain (required for non-local deploys) | 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. diff --git a/content/symbiotic/cli.mdx b/content/symbiotic/cli.mdx index ce735454..36adc290 100644 --- a/content/symbiotic/cli.mdx +++ b/content/symbiotic/cli.mdx @@ -16,8 +16,15 @@ Send one test message through the active provider. make send make send MSG="test message" make send ENV=testnet MSG="hello" +make send ENV=local-ccv EXECUTOR=0x... FINALITY=safe ``` +| Variable | Description | +|----------|-------------| +| `MSG` | Message payload (default: "hello") | +| `EXECUTOR` | Executor address encoded into the message (CCV local mock send only). Operators self-execute only when this matches their configured executor address. | +| `FINALITY` | Source-finality requirement encoded into the message (CCV local mock send only): `finalized` (default), `safe`, or a block count `N`. | + ### `make watch` Watch a previously sent message until the destination path succeeds. @@ -44,12 +51,15 @@ Send a message, then watch it to completion. make e2e make e2e MSG="custom message" make e2e ENV=testnet MSG="hello" TIMEOUT=180 +make e2e ENV=local-ccv EXECUTOR=0x... TIMEOUT=180 ``` +`EXECUTOR` and `FINALITY` pass through exactly as in `make send`. + LayerZero starter OApp addresses are published under: ```text -deployments/.json -> layerzero.oapp.{source,destination} +deployments/.json -> {source,destination}.layerzero.exampleApp ``` ## Direct xtask Usage @@ -134,6 +144,16 @@ LayerZero operators also expose: | `POST /api/v1/layerzero/proof` | Return Merkle proofs for processed messages | | `POST /api/v1/layerzero/verify` | Verify a Merkle proof in test workflows | +### Chainlink CCV Verification Endpoint + +CCV operators expose the attestation API advertised on-chain via `getStorageLocations()` (host ports 3001-3003): + +```bash +curl "http://localhost:3001/verifications?messageID=0x..." +``` + +`messageID` is repeatable (up to 20 per request). Responses carry Chainlink-CCV-shaped verifier results whose `ccv_data` is prefixed with the verifier version tag. Requests with no `messageID`, more than 20, or a malformed id return HTTP 400. + ## Webhook Template Requirements OZ Monitor trigger templates live under `config/templates/oz-monitor/triggers/` and are copied into `generated//oz-monitor/triggers/`. diff --git a/content/symbiotic/deployment.mdx b/content/symbiotic/deployment.mdx index b1100d62..94903814 100644 --- a/content/symbiotic/deployment.mdx +++ b/content/symbiotic/deployment.mdx @@ -6,7 +6,7 @@ Deploying and operating the stack beyond local development. -Testnet supports both providers: `layerzero` (`config/environments/testnet.json`, `ENV=testnet`) and `chainlink_ccv` (`config/environments/testnet-ccv.json`, `ENV=testnet-ccv`). The workflow below uses `ENV=testnet` (LayerZero); for the CCV path substitute `ENV=testnet-ccv`. Mainnet CCV is not yet supported. +Testnet supports both providers: `layerzero` (`config/environments/testnet.json`, `ENV=testnet`) and `chainlink_ccv` (`config/environments/testnet-ccv.json`, `ENV=testnet-ccv`). The workflow below uses `ENV=testnet` (LayerZero); for the CCV path substitute `ENV=testnet-ccv`, but note the `make e2e` step is LayerZero-only (it drives the starter `ExampleOApp`) — CCV runs are confirmed with `make watch` instead (see [Chainlink CCV](/symbiotic/chainlink-ccv#configuration)). Mainnet CCV is not yet supported. @@ -63,12 +63,59 @@ KEYSTORE_PASSPHRASE= # used by OZ Relayer at runt Operator and deployer passphrases are resolved by `xtask` via the `signers` config. `KEYSTORE_PASSPHRASE` is read directly by the OZ Relayer container to decrypt relayer keystores at runtime. +CCV environments (`ENV=testnet-ccv`) additionally need the reserved factory-deployer key: + +```bash +cargo xtask --env testnet-ccv generate-signer --name ccv-factory-deployer +``` + +```bash +CCV_FACTORY_DEPLOYER_PASSPHRASE= +CCV_STORAGE_LOCATION_URIS= +``` + +The factory-deployer key must be freshly generated and stay at nonce 0 on every target chain until the deploy — it anchors the resolver's CREATE2 address parity. Fund it just enough for one factory deploy per chain. + ## Operational Notes - Local uses `make chains && make deploy && make start`; testnet uses `make deploy ENV=testnet` plus `make start ENV=testnet`. - Testnet uses `docker-compose.yml` only, not the local overlay. - Canonical deployment addresses always live in `deployments/.json`. +## CCV Deterministic Deployment (CREATE2) + +The CCV resolver must have the **same address on every chain** — the CCIP indexer registers that one address, and identical addresses give automatic discovery when you add a chain. The deployment flow that guarantees this: + +1. **Deploy Chainlink's `CREATE2Factory` once per chain from a reserved key at nonce 0.** CREATE addresses depend only on (deployer, nonce), so a dedicated key that has never transacted produces the same factory address everywhere. Locally this is the `ccv-factory-deployer` signer (Anvil account 9); on testnet/production, generate a dedicated key, use it for nothing else, and fund it just enough for one deploy per chain. The factory constructor takes an allowlist of addresses permitted to claim CREATE2 addresses through it. +2. **Deploy the resolver through the factory**: `deployResolver` loads Chainlink's *published* resolver creation bytecode from the npm package, calls `factory.createAndTransferOwnership(code, salt, owner)`, asserts the result matches `factory.computeAddress`, and completes ownership with `acceptOwnership()` from the owner. A deploy without the accept step is not finished — the resolver would still have a pending owner. +3. Deploy the `SymbioticVerifier` (plain CREATE — its address doesn't need parity), register it on the resolver, and configure lanes. + +Non-local CCV deployments also require the real RMN contract per chain: set `chainlinkCcip.rmn` in the environment config (`RMNRemote` on CCIP staging — discoverable from `OnRamp.getStaticConfig()`). The verifier's constructor requires it, and fork tests (`make test-fork`) read it from `SOURCE_CCIP_RMN_ADDRESS` / `DEST_CCIP_RMN_ADDRESS` in `.env.testnet`. + +### Address-affecting inputs + +The resolver address is a pure function of these — pin them and never change them casually: + +| Input | Where pinned | Notes | +|-------|--------------|-------| +| Factory deployer key + nonce 0 | `ccv-factory-deployer` signer per environment | Determines the factory address | +| Resolver salt | `RESOLVER_SALT` in `contracts/script/chainlink/DeployCCV.s.sol` (`keccak256("symbiotic.ccv.versioned-verifier-resolver.v1")`) | | +| Resolver creation bytecode | `@chainlink/contracts-ccip@2.0.0` published artifact (`bytecode/v2_0_0/versioned_verifier_resolver.bin`) | Using the published artifact makes the address independent of local compiler settings | + +The resolver **owner** is *not* address-affecting (the stock resolver has no constructor arguments), so ownership can differ per chain without breaking parity. + +Validate determinism any time with `make validate-resolver-address` — it deploys the full flow on two throwaway Anvil chains and asserts identical addresses and correct ownership. + +### Attestation storage locations + +`CCV_STORAGE_LOCATION_URIS` (comma-separated) is a **required** deploy input for the verifier: it becomes the on-chain `getStorageLocations()` answer that executors use to fetch attestations. There is no placeholder fallback — an empty value fails the deploy. Point it at the operator attestation API (each operator serves `GET /verifications`; put a load balancer or an HA node in front for production — see [Architecture](/symbiotic/architecture#production-topology)). Rotate later with the owner-gated `updateStorageLocations`. + +### CCIP indexer onboarding + +Each new protocol deployment is a per-deployment onboarding with Chainlink: the CCIP indexer must learn (a) your resolver address and (b) your attestation API endpoint before messages get automated execution. Messages verify without this step — but nobody executes them. Coordinate with your Chainlink contact (or Symbiotic/OpenZeppelin on your behalf) when standing up a new deployment, and again only when adding chains if you skipped deterministic addresses. + +Onboarding is **per protocol deployment, not per validator set**. Operator registration and deregistration rotate the active validator set inside the same `Settlement` and verifier contracts at epoch boundaries — the contracts, the resolver address, and the attestation API endpoint all stay the same as the set evolves. The indexer never needs re-onboarding for validator churn; keeping the advertised endpoint reachable across it is your responsibility (see [Production Topology](/symbiotic/architecture#production-topology)). + ## Sidecar Image Compatibility The Symbiotic relay sidecar (`symbioticfi/relay`) is still in release-candidate phase. The pinned version in `docker-compose.yml` and the `RELAY_IMAGE` default in `xtask/src/genesis.rs` must stay in sync. Known states observed during integration: diff --git a/content/symbiotic/layerzero.mdx b/content/symbiotic/layerzero.mdx index 1c932c8f..198a44af 100644 --- a/content/symbiotic/layerzero.mdx +++ b/content/symbiotic/layerzero.mdx @@ -41,12 +41,12 @@ sequenceDiagram ### Contracts -- `contracts/src/SymbioticLayerZeroDVN.sol` +- `contracts/src/layerzero/SymbioticLayerZeroDVN.sol` - `contracts/src/symbiotic/Settlement.sol` - `contracts/src/symbiotic/KeyRegistry.sol` - `contracts/src/symbiotic/VotingPowers.sol` - `contracts/src/symbiotic/Driver.sol` -- `contracts/src/examples/ExampleOApp.sol` +- `contracts/src/layerzero/ExampleOApp.sol` ### Operator @@ -110,7 +110,7 @@ The template also manages a starter OApp by default: - `true`: deploy and wire `ExampleOApp` on both chains - `false`: skip the starter OApp and run LayerZero in provider-only mode -Starter OApp deployments are published under `deployments/.json` at `layerzero.oapp.source` and `layerzero.oapp.destination`. +Starter OApp deployments are published under `deployments/.json` at `source.layerzero.exampleApp` and `destination.layerzero.exampleApp`. Provider validation does not require the starter OApp to exist. Disabling it only removes the `make send` and `make e2e` demo flow for that environment. diff --git a/content/symbiotic/security.mdx b/content/symbiotic/security.mdx index d5ffd3ae..327c1bed 100644 --- a/content/symbiotic/security.mdx +++ b/content/symbiotic/security.mdx @@ -15,6 +15,46 @@ Security architecture and trust assumptions for the Symbiotic template. Webhook ingress between OZ Monitor, OZ Relayer, and operators uses HMAC-SHA256 shared secrets. See [CLI & API Reference](/symbiotic/cli#http-api) for the header format. +## Key Protection + +Each operator holds three distinct key roles. They are never shared or reused across roles: + +| Key | Held by | Used for | +|-----|---------|----------| +| Operator key | Relay sidecar | BLS attestations, P2P identity, on-chain registration | +| Relayer signer | OZ Relayer | Signing on-chain submission transactions | +| Deployer key | Deploy workstation | Contract deployment and funding | + +The operator binary itself never sees the operator key: it delegates all signing to the relay sidecar over gRPC, so a compromised operator process cannot exfiltrate key material directly. + +### Operator (BLS) keys + +A leaked operator key lets an attacker sign attestations as that operator until it is deregistered, so this is the key to protect hardest. + +This template's sidecar launcher (`scripts/symbiotic-relay/start-sidecar.sh`) currently injects raw keys via the `--secret-keys` flag from environment variables — acceptable for local and test environments, not for production. The relay sidecar also supports an encrypted JKS keystore mode (`--keystore.path` + `--keystore.password`, keys managed with `relay_utils keys add/list/remove`), where raw keys never appear in container environments or process arguments and the passphrase is the only runtime secret. **Adopting it requires adapting the launcher script and verifying keystore-mode startup against your pinned relay image** — the shipped launcher does not wire it up, and sidecar images are still in release-candidate phase (see [Sidecar Image Compatibility](/symbiotic/deployment#sidecar-image-compatibility)). + +Either way: store the keystore file (or key material) and its passphrase as separate secrets, and restrict them to the sidecar container. HSM- or cloud-KMS-backed signing is **not supported by the relay sidecar** as of relay `1.0.1-rc4` — an encrypted keystore with a well-protected passphrase is the strongest supported posture on that version. + +### Relayer signer keys + +The OZ Relayer supports production signer backends beyond the local encrypted keystore, configured per signer via `signers[].type` in `config/oz-relayer/config.json`: + +| Backend | Type | Key location | +|---------|------|--------------| +| Local keystore | `local` | Encrypted JSON file (development/testing) | +| AWS KMS | `aws_kms` | AWS-managed key | +| Google Cloud KMS | `google_cloud_kms` | GCP-managed key | +| HashiCorp Vault KV2 | `vault` | Key stored in Vault secret | +| HashiCorp Vault Transit | `vault_transit` | Key never exported; Vault signs server-side | +| Turnkey | `turnkey` | Turnkey-managed key | +| Coinbase CDP | `cdp` | Coinbase-managed wallet | + +Sensitive config values reference environment variables (`{ "type": "env", "value": "..." }`) rather than being hardcoded in the JSON. + +### Deployer key + +Needed only when deploying or upgrading contracts; keep it offline between uses. For the CCV factory deployer key (CREATE2 address parity across chains), see [Production Ownership](#production-ownership-recommended). + ## Runtime API Security Operator runtime behavior that is easy to miss: @@ -52,16 +92,35 @@ The LayerZero provider trusts `SendUln302` as the only valid source-chain caller ## Chainlink CCV Security +The CCV is two contracts: the stock `VersionedVerifierResolver` (stable identity, registry of implementations) and `SymbioticVerifier` (verification logic, inherits Chainlink's `BaseVerifier`). Ramp authorization is resolved from the CCIP Router at call time — a CCIP ramp rotation needs no reconfiguration here — and both verification paths check RMN curse status first, so a cursed lane halts immediately. + ### Access Control -| Function | Caller | Purpose | -|----------|--------|---------| -| `forwardToVerifier` | OnRamp | Register a message for CCV verification | -| `verifyMessage` | OffRamp | Verify the message on the destination path | -| `getFee` | Anyone | Quote verification fee | +| Contract | Function | Caller | Purpose | +|----------|----------|--------|---------| +| Verifier | `forwardToVerifier` | OnRamp (resolved via Router) | Register a message; returns the version tag | +| Verifier | `verifyMessage` | OffRamp (resolved via Router) | Verify BLS quorum on the destination path | +| Verifier | `getFee` | Anyone | Quote fee; rejects unsupported finality configs at quote time | +| Verifier | `applyRemoteChainConfigUpdates` / `applyAllowlistUpdates` / `setAllowedFinalityConfig` / `updateStorageLocations` | Owner | Lane, allowlist, finality, and attestation-endpoint administration | +| Resolver | `getInbound/OutboundImplementation` | Anyone | Route by version prefix / lane | +| Resolver | `applyInbound/OutboundImplementationUpdates` | Owner | Register, activate, retire verifier implementations | +| Factory | `createAndCall` / `createAndTransferOwnership` | Allowlisted deployers | Claim deterministic addresses on new chains | + +All owned contracts use **two-step ownership** (`transferOwnership` + `acceptOwnership`): a mistyped transfer target cannot seize control, and the current owner keeps administering until the new owner accepts. ### Invariants 1. Verification requires a valid BLS quorum signature from Settlement. -2. The derived message ID must match the message payload being verified. -3. Settlement epoch data must be fresh, or verification reverts with `EpochTooStale`. +2. Signers commit to `keccak256(versionTag ‖ messageId)` — a signature is bound to both the message and the verifier implementation version, so results cannot be replayed across implementations or messages. +3. The resolver routes strictly by the 4-byte version prefix; an unknown prefix resolves to nothing rather than to a default implementation. +4. Settlement epoch data must be fresh, or verification reverts with `EpochTooStale`. +5. A cursed lane (RMN) refuses both quoting-side forwarding and destination verification until the curse lifts. + +### Production Ownership (Recommended) + +The quick-start kit deploys with a single EOA owner. For production, put owner roles behind real governance before value flows: + +- **Owner of resolver + verifier**: a multisig (e.g. Safe) with an appropriate threshold, ideally behind a **timelock** so implementation registrations and lane changes are publicly visible before they execute. Registering a malicious verifier implementation is equivalent to full compromise — treat the resolver owner as your root of trust. +- **Factory allowlist + owner**: same multisig, or a narrower deploy-ops key. The allowlist only gates who can claim CREATE2 addresses on new chains; it cannot touch existing deployments. +- **Factory deployer key** (address-parity key, nonce 0): needed only when adding a chain. Keep it offline between deployments; it holds no ongoing authority. +- If your protocol already has on-chain governance, point ownership at it — these contracts only need an `owner`; they impose no governance system of their own. diff --git a/content/symbiotic/setup.mdx b/content/symbiotic/setup.mdx index 85198e11..31c6e931 100644 --- a/content/symbiotic/setup.mdx +++ b/content/symbiotic/setup.mdx @@ -35,6 +35,12 @@ generated/ Signer keys are configured in `config/environments/.json` under `signers`. Three signer types are supported: + + +The flows below get a local or test environment running. For production key handling — sidecar keystores for operator BLS keys and KMS/Vault backends for relayer signers — see [Security → Key Protection](/symbiotic/security#key-protection). + + + ### Anvil (local only) Derives keys from Anvil's well-known mnemonic by index. Zero setup needed. From bab00445347af8445356be3e99286d328e72a253 Mon Sep 17 00:00:00 2001 From: Dylan Kilkenny Date: Fri, 31 Jul 2026 23:08:38 +0100 Subject: [PATCH 2/2] docs(symbiotic): sync relay 1.1.1 bump + bbolt migration to deployment/security Signed-off-by: Dylan Kilkenny --- content/symbiotic/deployment.mdx | 18 +++++++++++------- content/symbiotic/security.mdx | 4 ++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/content/symbiotic/deployment.mdx b/content/symbiotic/deployment.mdx index 94903814..7c8d0905 100644 --- a/content/symbiotic/deployment.mdx +++ b/content/symbiotic/deployment.mdx @@ -118,15 +118,19 @@ Onboarding is **per protocol deployment, not per validator set**. Operator regis ## Sidecar Image Compatibility -The Symbiotic relay sidecar (`symbioticfi/relay`) is still in release-candidate phase. The pinned version in `docker-compose.yml` and the `RELAY_IMAGE` default in `xtask/src/genesis.rs` must stay in sync. Known states observed during integration: +The pinned `symbioticfi/relay` version in `docker-compose.yml` (all three sidecar services) and the `RELAY_IMAGE` default in `xtask/src/genesis.rs` must stay in sync. The current pin is **`1.1.1`** (the latest GA release). If you bump the tag, run a full testnet e2e first and verify proofs are aggregated end-to-end. -| Tag | Status | Notes | -| --- | --- | --- | -| `1.0.1-rc4` | works | Current pin. First image where aggregation produces proofs against real mainnet. | -| `1.0.1-rc3.0.20260507060511-...` | broken | Starts cleanly but the aggregator never produces proofs even with full quorum signing. | -| `1.0.1-20260326074346-da0bce8ba949` | broken on non-local | Panics at config init with an `errors.As` target-type bug when multiple chains are configured. Local works because the codepath differs. | +### Storage backend (upgrading from 1.0.x) -If you bump the tag, run a full testnet e2e first and verify proofs are aggregated end-to-end. The `1.0.1` GA tag has not yet shipped from Symbiotic — track upstream releases before promoting any tag past `rc4`. +Relay `1.1.x` defaults the sidecar `storage-type` to **bbolt**; `1.0.x` used **BadgerDB**. A sidecar started against a `/storage` volume (the `./data/sidecar-*` bind mounts) that still contains BadgerDB files fails to boot: + +``` +storage directory "/storage" contains BadgerDB files but storage type is "bbolt"; +either remove the BadgerDB files or set storage-type: "badger" explicitly in config +``` + +- **Fresh deploys** are unaffected — an empty `/storage` initializes as bbolt. +- **Upgrading a running deployment** with existing 1.0.x state: either wipe the sidecar storage (`data/sidecar-*`; the relays re-backfill from chain) or pin `storage-type: badger` in the sidecar config. Wiping is the clean path — after the backfill, mind the `EpochTooStale` verification window until the committer catches up. ## Common Deployment Failures diff --git a/content/symbiotic/security.mdx b/content/symbiotic/security.mdx index 327c1bed..62aa78b4 100644 --- a/content/symbiotic/security.mdx +++ b/content/symbiotic/security.mdx @@ -31,9 +31,9 @@ The operator binary itself never sees the operator key: it delegates all signing A leaked operator key lets an attacker sign attestations as that operator until it is deregistered, so this is the key to protect hardest. -This template's sidecar launcher (`scripts/symbiotic-relay/start-sidecar.sh`) currently injects raw keys via the `--secret-keys` flag from environment variables — acceptable for local and test environments, not for production. The relay sidecar also supports an encrypted JKS keystore mode (`--keystore.path` + `--keystore.password`, keys managed with `relay_utils keys add/list/remove`), where raw keys never appear in container environments or process arguments and the passphrase is the only runtime secret. **Adopting it requires adapting the launcher script and verifying keystore-mode startup against your pinned relay image** — the shipped launcher does not wire it up, and sidecar images are still in release-candidate phase (see [Sidecar Image Compatibility](/symbiotic/deployment#sidecar-image-compatibility)). +This template's sidecar launcher (`scripts/symbiotic-relay/start-sidecar.sh`) currently injects raw keys via the `--secret-keys` flag from environment variables — acceptable for local and test environments, not for production. The relay sidecar also supports an encrypted JKS keystore mode (`--keystore.path` + `--keystore.password`, keys managed with `relay_utils keys add/list/remove`), where raw keys never appear in container environments or process arguments and the passphrase is the only runtime secret. **Adopting it requires adapting the launcher script and verifying keystore-mode startup against your pinned relay image** — the shipped launcher does not wire it up (see [Sidecar Image Compatibility](/symbiotic/deployment#sidecar-image-compatibility)). -Either way: store the keystore file (or key material) and its passphrase as separate secrets, and restrict them to the sidecar container. HSM- or cloud-KMS-backed signing is **not supported by the relay sidecar** as of relay `1.0.1-rc4` — an encrypted keystore with a well-protected passphrase is the strongest supported posture on that version. +Either way: store the keystore file (or key material) and its passphrase as separate secrets, and restrict them to the sidecar container. HSM- or cloud-KMS-backed signing is **not supported by the relay sidecar** as of relay `1.1.1` — an encrypted keystore with a well-protected passphrase is the strongest supported posture on that version. ### Relayer signer keys