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 allows for provable attestation checks on any chain of an arbitrary data signed by the validator set quorum. The system provides a modular smart contract framework that enables networks to manage validator sets dynamically, handle cryptographic keys, aggregate signatures, and commit cross-chain state
Architecture
Symbiotic provides a set of predefined smart contracts, in general, representing the following modules:
VotingPowerProvider- provides the basic data regarding operators, vaults and their voting power, it allows constructing various onboarding schemesKeyRegistry- verifies and manages operators' keys; currently, these key types are supported:ValSetDriver- is used by the off-chain part of the Symbiotic Relay for validator set deriving and maintenanceSettlement- requires a compressed validator set (header) to be committed each epoch, but allows verifying signatures made by the validator set; currently, it supports the following verification mechanics:SimpleVerifier- requires the whole validator set to be inputted on the verification, but in a compressed and efficient way, so that it is the best choice to use up to around 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, developers must 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
There are 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- Also, there are ready bindings 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 can be found at script/ folder. It consists of RelayDeploy.sol Foundry script template relay-deploy.sh bash script (the Relay smart contracts use external libraries for their implementations, so that 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 the four deployment hooks: KeyRegistry, VotingPowerProvider, Settlement, and ValVetDriverrelay-deploy.sh- orchestrates per-contract multi-chain deployments (uses Python inside to parsetomlfile)
The script deploys Relay modules under OpenZeppelin's TransparentUpgradeableProxy using CreateX (it provides better control for production deployments and more simplified approaches for development).
Configure on-chain deployment
Implement your MyRelayDeploy.sol (see example)
- this Foundry script should include the deployment configuration of your Relay modules
- you need to implement all virtual functions of
RelayDeploy.sol - in constructor, need to input the path of the
tomlfile - you are provided with 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)
- this configuration file should include RPC URLs that will be needed for the deployment, and which modules should be deployed on which chains
- do not replace [1234567890] placeholder with endpoint_url = ""
- the contracts are deployed in such order: 1. KeyRegistry 2. VotingPowerProvider 3. Settlement 4. ValSetDriver
Run the deployment
Execute the deployment script, e.g.:
./script/relay-deploy.sh ./script/examples/MyRelayDeploy.sol ./script/examples/my-relay-deploy.toml --broadcast --ledgerAt the end, your toml file will contain the addresses of the deployed Relay modules.
Integrate
The Symbiotic Relay provides you a comprehensive tooling working on its own, so that you don't care about anything except only your stake-backed application logic.
Verify Message
Your application contract is able to verify any message using a validator set at any point of time needed 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 is able to use the validator set at any point of time using SSZ proof verification via, e.g.:
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
);
}