Skip to content

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 schemes
  • KeyRegistry - 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 maintenance
  • Settlement - 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 validators
    • ZKVerifier - 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:

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
MyValSetDriver.sol
// 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
MySettlement.sol
// 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 register
  • OperatorsBlacklist - blacklisted operators are unregistered and are forbidden to return back
  • OperatorsJail - operators can be jailed for some amount of time and register back after that
  • SharedVaults - shared (with other networks) vaults (like the ones with NetworkRestakeDelegator) can be added
  • OperatorVaults - vaults that are attached to a single operator can be added
  • MultiToken - possible to add new supported tokens on the go
  • OpNetVaultAutoDeploy - 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
MyVotingPowerProvider.sol
// 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
MyVotingPowerProvider.sol
// 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:

Examples

Chainlink-Priced Stake with Token-/Vault-Specific Weights
MyVotingPowerProvider.sol
// 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 ValVetDriver
  • relay-deploy.sh - orchestrates per-contract multi-chain deployments (uses Python inside to parse toml file)

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 toml file
  • you are provided with additional helpers such as getCore(), getKeyRegistry(), getVotingPowerProvider(), etc. (see full list in RelayDeploy.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.:

bash
./script/relay-deploy.sh ./script/examples/MyRelayDeploy.sol ./script/examples/my-relay-deploy.toml --broadcast --ledger

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

MyApp.sol
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.:

MyApp.sol
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
    );
}

Next Steps

Relay Off-Chain

Proceed to the the development of your protocol's off-chain part using Relay.