Relay On-Chain
The Symbiotic Relay system implements a complete signature aggregation workflow from validator set derivation through on-chain commitment of the ValSetHeader data structure. This enables provable attestation checks on any chain for arbitrary data signed by the validator set quorum.
The system provides a modular smart contract framework that lets you manage validator sets dynamically, handle cryptographic keys, aggregate signatures, and commit cross-chain state.
Architecture
Symbiotic provides a set of predefined smart contracts representing the following modules:
VotingPowerProvider- provides basic data about operators, vaults, and their voting power, enabling various onboarding schemesKeyRegistry- verifies and manages operators' keys; currently, these key types are supported:ValSetDriver- used by the off-chain part of Symbiotic Relay for validator set derivation and maintenanceSettlement- requires committing a compressed validator set (header) each epoch, but allows verifying signatures made by the validator set; currently supports the following verification mechanics:SimpleVerifier- requires the whole validator set as an input during verification, but in a compressed and efficient way, making it the best choice for up to 125 validatorsZKVerifier- uses ZK verification made with gnark, allowing larger validator sets with an almost constant verification gas cost
Permissions
Relay contracts have three ready-to-use permission models:
OzOwnable- allows setting an owner address that can perform permissioned actions- Based on OpenZeppelin's
Ownablecontract
- Based on OpenZeppelin's
OzAccessControl- enables role-based permissions for each action- Based on OpenZeppelin's
AccessControlcontract - Roles can be assigned to function selectors using
_setSelectorRole(bytes4 selector, bytes32 role)
- Based on OpenZeppelin's
OzAccessManaged- controls which callers can access specific functions
To use these permission models, inherit one of the above contracts and add the checkPermission modifier to functions that require access control.
Examples
ValSetDriver with OzOwnable
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {ValSetDriver} from "../src/modules/valset-driver/ValSetDriver.sol";
import {OzOwnable} from "../src/modules/common/permissions/OzOwnable.sol";
import {IEpochManager} from "../src/interfaces/modules/valset-driver/IEpochManager.sol";
import {IValSetDriver} from "../src/interfaces/modules/valset-driver/IValSetDriver.sol";
contract MyValSetDriver is ValSetDriver, OzOwnable {
function initialize(
ValSetDriverInitParams memory valSetDriverInitParams,
address owner
) public virtual initializer {
__ValSetDriver_init(valSetDriverInitParams);
__OzOwnable_init(ozOwnableInitParams);
}
}Settlement with OzAccessControl
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {Settlement} from "../src/modules/settlement/Settlement.sol";
import {OzAccessControl} from "../src/modules/common/permissions/OzAccessControl.sol";
import {ISettlement} from "../src/interfaces/modules/settlement/ISettlement.sol";
contract MySettlement is Settlement, OzAccessControl {
bytes32 public constant SET_SIG_VERIFIER_ROLE = keccak256("SET_SIG_VERIFIER_ROLE");
bytes32 public constant SET_GENESIS_ROLE = keccak256("SET_GENESIS_ROLE");
function initialize(
SettlementInitParams memory settlementInitParams,
address defaultAdmin
) public virtual initializer {
__Settlement_init(settlementInitParams);
_grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin);
_setSelectorRole(ISettlement.setSigVerifier.selector, SET_SIG_VERIFIER_ROLE);
_setSelectorRole(ISettlement.setGenesis.selector, SET_GENESIS_ROLE);
}
}VotingPowerProvider Extensions
Multiple voting power extensions can be combined to achieve different properties of the VotingPowerProvider:
OperatorsWhitelist- only whitelisted operators can registerOperatorsBlacklist- blacklisted operators are unregistered and are forbidden to return backOperatorsJail- operators can be jailed for some amount of time and register back after thatSharedVaults- shared (with other networks) vaults (like the ones with NetworkRestakeDelegator) can be addedOperatorVaults- vaults that are attached to a single operator can be addedMultiToken- possible to add new supported tokens on the goOpNetVaultAutoDeploy- enable auto-creation of the configured by you vault on each operator registration- Ready bindings are also available for slashing and rewards
Examples
Single-Operator Vaults Added by Owner
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {VotingPowerProvider} from "../src/modules/voting-power/VotingPowerProvider.sol";
import {OzOwnable} from "../src/modules/common/permissions/OzOwnable.sol";
import {EqualStakeVPCalc} from "../src/modules/voting-power/common/voting-power-calc/EqualStakeVPCalc.sol";
import {OperatorVaults} from "../src/modules/voting-power/extensions/OperatorVaults.sol";
contract MyVotingPowerProvider is VotingPowerProvider, OzOwnable, EqualStakeVPCalc, OperatorVaults {
constructor(address operatorRegistry, address vaultFactory) VotingPowerProvider(operatorRegistry, vaultFactory) {}
function initialize(
VotingPowerProviderInitParams memory votingPowerProviderInitParams,
OzOwnableInitParams memory ozOwnableInitParams
) public virtual initializer {
__VotingPowerProvider_init(votingPowerProviderInitParams);
__OzOwnable_init(ozOwnableInitParams);
}
}Shared Vaults Whitelisted under AccessControl
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {VotingPowerProvider} from "../src/modules/voting-power/VotingPowerProvider.sol";
import {OzAccessControl} from "../src/modules/common/permissions/OzAccessControl.sol";
import {EqualStakeVPCalc} from "../src/modules/voting-power/common/voting-power-calc/EqualStakeVPCalc.sol";
import {SharedVaults} from "../src/modules/voting-power/extensions/SharedVaults.sol";
import {OperatorsWhitelist} from "../src/modules/voting-power/extensions/OperatorsWhitelist.sol";
import {ISharedVaults} from "../src/interfaces/modules/voting-power/extensions/ISharedVaults.sol";
contract MyVotingPowerProvider is VotingPowerProvider, OzAccessControl, EqualStakeVPCalc, SharedVaults, OperatorsWhitelist {
bytes32 public constant REGISTER_SHARED_VAULT = keccak256("REGISTER_SHARED_VAULT");
bytes32 public constant UNREGISTER_SHARED_VAULT = keccak256("UNREGISTER_SHARED_VAULT");
bytes32 public constant SET_WHITELIST_STATUS = keccak256("SET_WHITELIST_STATUS");
bytes32 public constant WHITELIST_OPERATOR = keccak256("WHITELIST_OPERATOR");
bytes32 public constant UNWHITELIST_OPERATOR = keccak256("UNWHITELIST_OPERATOR");
constructor(address operatorRegistry, address vaultFactory) VotingPowerProvider(operatorRegistry, vaultFactory) {}
function initialize(
VotingPowerProviderInitParams memory votingPowerProviderInitParams,
address defaultAdmin
) public virtual initializer {
__VotingPowerProvider_init(votingPowerProviderInitParams);
_grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin);
_setSelectorRole(ISharedVaults.registerSharedVault.selector, REGISTER_SHARED_VAULT);
_setSelectorRole(ISharedVaults.unregisterSharedVault.selector, UNREGISTER_SHARED_VAULT);
_setSelectorRole(ISharedVaults.setWhitelistStatus.selector, SET_WHITELIST_STATUS);
_setSelectorRole(ISharedVaults.whitelistOperator.selector, WHITELIST_OPERATOR);
_setSelectorRole(ISharedVaults.unwhitelistOperator.selector, UNWHITELIST_OPERATOR);
}
function _registerOperatorImpl(
address operator
) internal virtual override(VotingPowerProvider, OperatorsWhitelist) {
super._registerOperatorImpl(operator);
}
}VotingPowerProvider Power Calculators
VotingPowerProvider always inherits a virtual VotingPowerCalculators contracts that has to be implemented in the resulting contract.
Symbiotic provides several stake-to-votingPower conversion mechanisms you can separately or combine:
- EqualStakeVPCalc - voting power is equal to stake
- NormalizedTokenDecimalsVPCalc - all tokens' decimals are normalized to 18
- PricedTokensChainlinkVPCalc - voting power is calculated using Chainlink price feeds
- WeightedTokensVPCalc - voting power is affected by configured weights for tokens
- WeightedVaultsVPCalc - voting power is affected by configured weights for vaults
Examples
Chainlink-Priced Stake with Token-/Vault-Specific Weights
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {VotingPowerProvider} from "../src/modules/voting-power/VotingPowerProvider.sol";
import {PricedTokensChainlinkVPCalc} from
"../src/modules/voting-power/common/voting-power-calc/PricedTokensChainlinkVPCalc.sol";
import {OzOwnable} from "../src/modules/common/permissions/OzOwnable.sol";
import {WeightedTokensVPCalc} from "../src/modules/voting-power/common/voting-power-calc/WeightedTokensVPCalc.sol";
import {WeightedVaultsVPCalc} from "../src/modules/voting-power/common/voting-power-calc/WeightedVaultsVPCalc.sol";
import {VotingPowerCalcManager} from "../src/modules/voting-power/base/VotingPowerCalcManager.sol";
contract MyVotingPowerProvider is VotingPowerProvider, OzOwnable, PricedTokensChainlinkVPCalc, WeightedTokensVPCalc, WeightedVaultsVPCalc {
constructor(address operatorRegistry, address vaultFactory) VotingPowerProvider(operatorRegistry, vaultFactory) {}
function initialize(
VotingPowerProviderInitParams memory votingPowerProviderInitParams,
OzOwnableInitParams memory ozOwnableInitParams
) public virtual initializer {
__VotingPowerProvider_init(votingPowerProviderInitParams);
__OzOwnable_init(ozOwnableInitParams);
}
function stakeToVotingPowerAt(
address vault,
uint256 stake,
bytes memory extraData,
uint48 timestamp
)
public
view
override(VotingPowerCalcManager, PricedTokensChainlinkVPCalc, WeightedTokensVPCalc, WeightedVaultsVPCalc)
returns (uint256)
{
return super.stakeToVotingPowerAt(vault, stake, extraData, timestamp);
}
function stakeToVotingPower(
address vault,
uint256 stake,
bytes memory extraData
)
public
view
override(VotingPowerCalcManager, PricedTokensChainlinkVPCalc, WeightedTokensVPCalc, WeightedVaultsVPCalc)
returns (uint256)
{
return super.stakeToVotingPower(vault, stake, extraData);
}
}Deployment
The deployment tooling is in the script/ folder. It consists of RelayDeploy.sol Foundry script template and relay-deploy.sh bash script (Relay smart contracts use external libraries, so it's not currently possible to use solely Foundry script for multi-chain deployment).
RelayDeploy.sol- abstract base that wires common Symbiotic Core helpers and exposes four deployment hooks: KeyRegistry, VotingPowerProvider, Settlement, and ValSetDriverrelay-deploy.sh- orchestrates per-contract multi-chain deployments (uses Python to parsetomlfile)
The script deploys Relay modules under OpenZeppelin's TransparentUpgradeableProxy using CreateX, providing better control for production deployments and simpler approaches for development.
Configure on-chain deployment
Implement your MyRelayDeploy.sol (see example):
- Include the deployment configuration of your Relay modules
- Implement all virtual functions of
RelayDeploy.sol - In the constructor, input the path of the
tomlfile - Use additional helpers such as
getCore(),getKeyRegistry(),getVotingPowerProvider(), etc. (see full list inRelayDeploy.sol)
Choose multi-chain setup
Implement your my-relay-deploy.toml (see example):
- Include RPC URLs needed for deployment and specify which modules to deploy on which chains
- Do not replace [1234567890] placeholder with endpoint_url = ""
- Contracts are deployed in this order: 1. KeyRegistry 2. VotingPowerProvider 3. Settlement 4. ValSetDriver
Integrate
Symbiotic Relay provides comprehensive tooling that works independently, so you can focus on your stake-backed application logic.
Verify Message
Your application contract can verify any message using a validator set at any point in time via:
import {ISettlement} from "@symbioticfi/relay-contracts/src/interfaces/modules/settlement/ISettlement.sol";
function verifyMessage(bytes calldata message, uint48 epoch, bytes calldata proof) public returns (bool) {
return ISettlement(SETTLEMENT).verifyQuorumSigAt(
abi.encode(keccak256(message)),
15, // default key tag - BN254
(uint248(1e18) * 2) / 3 + 1, // default quorum threshold - 2/3 + 1
proof,
epoch,
new bytes(0)
);
}Use Validator Set Data
Your application contract can use the validator set at any point in time using SSZ proof verification, for example:
import {ValSetVerifier} from "@symbioticfi/relay-contracts/src/libraries/utils/ValSetVerifier.sol";
import {Math} from "openzeppelin-contracts/contracts/utils/math/Math.sol";
function verifyOperatorVotingPower(
ValSetVerifier.SszProof calldata validatorRootProof,
uint256 validatorRootLocalIndex,
bytes32 validatorSetRoot,
ValSetVerifier.SszProof calldata operatorProof,
address operator,
ValSetVerifier.SszProof calldata votingPowerProof,
uint256 votingPower
) public returns (bool) {
return operatorProof.leaf == bytes32(uint256(uint160(operator)) << 96)
&& ValSetVerifier.verifyOperator(
validatorRootProof, validatorRootLocalIndex, validatorSetRoot, operatorProof
) && votingPowerProof.leaf == bytes32(votingPower << (256 - (Math.log2(votingPower) / 8 + 1) * 8))
&& ValSetVerifier.verifyVotingPower(
validatorRootProof, validatorRootLocalIndex, validatorSetRoot, votingPowerProof
);
}