Deployment

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, 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). Mainnet CCV is not yet supported.

Testnet Workflow

1. Generate keystores

cargo xtask --env testnet generate-signer \
  --name deployer --name operator-1 --name operator-2 --name operator-3

cargo xtask --env testnet generate-signer \
  --name signer-1 --name signer-2 --name signer-3

Each command prompts for a passphrase interactively (hidden input, confirmed). Use separate passphrases for operator and relayer keystores. The --env testnet flag writes keys under config/keys/testnet/, where config/environments/testnet.json expects them — without it, keys default to config/keys/local/ and make deploy ENV=testnet will not find them.

Relayer keystores (signer-1/2/3) are generated automatically by make deploy for any environment if they don't already exist (using KEYSTORE_PASSPHRASE). Generating them manually above is optional — it only lets you set the passphrase interactively instead of reading it from .env.testnet.

2. Configure environment

Copy .env.example to .env.testnet and fill in the RPC URLs (Base Sepolia source, Sepolia destination) and keystore passphrases. Signer definitions in config/environments/testnet.json reference these passphrases.

3. Deploy and run

make validate ENV=testnet
make deploy ENV=testnet
make refresh-genesis ENV=testnet   # only when validation says genesis is stale
make start ENV=testnet
make e2e ENV=testnet MSG="hello"
  • validate runs read-only checks against config, RPC reachability, deployment state, operator state, and relayer signer safety.
  • deploy deploys managed contracts and regenerates deployments/testnet.json and generated/testnet/.
  • refresh-genesis repairs stale settlement genesis without redeploying contracts.
  • start detects the environment type and starts the appropriate services.

By default, the LayerZero testnet config also deploys the starter ExampleOApp, so make e2e ENV=testnet remains part of the main workflow.

If you want provider infrastructure only, set layerzero.oapp.enabled to false in config/environments/testnet.json before deploying. validate will still pass, but make send and make e2e will be unavailable.

Required Inputs

Testnet settings live in config/environments/<env>.json. .env.<env> must provide:

DEPLOYER_PASSPHRASE=<deployer keystore passphrase>
OPERATOR_1_PASSPHRASE=<operator-1 keystore passphrase>
OPERATOR_2_PASSPHRASE=<operator-2 keystore passphrase>
OPERATOR_3_PASSPHRASE=<operator-3 keystore passphrase>
KEYSTORE_PASSPHRASE=<relayer keystore passphrase>   # used by OZ Relayer at runtime

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:

cargo xtask --env testnet-ccv generate-signer --name ccv-factory-deployer
CCV_FACTORY_DEPLOYER_PASSPHRASE=<ccv-factory-deployer keystore passphrase>
CCV_STORAGE_LOCATION_URIS=<comma-separated attestation API base URLs>

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/<env>.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:

InputWhere pinnedNotes
Factory deployer key + nonce 0ccv-factory-deployer signer per environmentDetermines the factory address
Resolver saltRESOLVER_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). 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).

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:

TagStatusNotes
1.0.1-rc4worksCurrent pin. First image where aggregation produces proofs against real mainnet.
1.0.1-rc3.0.20260507060511-...brokenStarts cleanly but the aggregator never produces proofs even with full quorum signing.
1.0.1-20260326074346-da0bce8ba949broken on non-localPanics at config init with an errors.As target-type bug when multiple chains are configured. Local works because the codepath differs.

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.

Common Deployment Failures

  • validation failed: genesis stale: run make refresh-genesis ENV=<env>.
  • insufficient funds for gas: fund the deployer account from the deployer keystore address.
  • Sidecars hit RPC rate limits: use stronger RPCs or reduce sidecar load temporarily.
  • Keys look drained immediately: do not reuse known local/dev keys on public networks.

For runtime failures after deployment, see Troubleshooting.