Custom verifier contracts
Before following this guide, make sure you have completed the getting started guide.
What is an AVS verifier contract?
Verifier contracts are integral components of the Actively Validated Services (AVS) framework. They are used to validate and aggregate results from tasks submitted by AVS nodes. These contracts ensure that results submitted by operators follow the consensus rules established in the contract and that only valid tasks are completed.
What are verifier contracts used for?
Verifier contracts play a critical role in the AVS network for:
- On-Chain Result Aggregation: They collect and aggregate task results from AVS nodes.
- Task Validation: They ensure that tasks submitted by operators meet quorum and that validators have correctly signed off on the task.
- Quorum Checking: Verifiers ensure that the correct percentage of operator voting power has been used before marking a task as completed.
Required APIs
To function, verifier contracts interact with other contracts:
- The Operators Contract: You need to provide a contract matching DAO interface to query the voting power and validate which operators are allowed to vote.
- The Task Queue Contract: While sending a task to be executed, you need to pass the task queue contract's address to check if a task is still open and to submit completed tasks once quorum is reached.
Writing verifier contracts
Let's walk through the process of writing verifier contracts based on our needs.
Prerequisites
Before we dive into how to write smart-contracts for Lay3r we need to make sure that we have all the dependencies. This article assumes you already have Rust and wasm32 installed.
After that you can just run the following:
cargo install cargo-generate --features vendored-openssl
A good, but non-mandatory read would be the CosmWasm
book, as it covers the fundamentals of WASM contract development nicely.
Template
To get you fast to speed we will use the verifier-simple template: https://github.com/Lay3rLabs/avs-toolkit/tree/main/contracts/verifier-simple.
To check it out locally, run the following:
cargo generate \--git https://github.com/Lay3rLabs/cw-template.git \-b slay3r \-d minimal=true \--name <your-contract-name-here>
Note: This assumes you're building the contract inside an already existing repository, so changes will be required to the contract's .toml
file to handle the dependencies.
A few additional notes: using the -b
argument will select the layr3
branch of the repository, and with -d
, we invoke the generation script with the minimal required configurations.
Structure
If everything has gone well so far, you should now have a smart contract with your desired name, following this structure:
your-contract-name-here/│├── src/│ ├── bin/│ │ └── schema.rs # Responsible for generating the schema for the smart contract's interaction messages.│ ├── tests/│ │ ├── common.rs # Common functions and/or structs for use in tests.│ │ ├── golem.rs # Test cases that will run with [Golem](more about Golem here). (Note: This require an extra feature enabled - )│ │ ├── mod.rs # Module file.│ │ ├── multi.rs # Test cases and/or functions for running [multi-test](https://github.com/CosmWasm/cw-multi-test).│ ├── contract.rs # Contract logic, containing execute, instantiate, query and helper functions.│ ├── error.rs # Contract errors go here, so we can define our own custom error types.│ ├── interface.rs # Holds the definition of an interface for interacting with the contract (deployment, execution, and querying). This file makes use of the [cw-orch library](https://docs.rs/cw-orch/latest/cw_orch/).│ ├── lib.rs # Module management.│ ├── msg.rs # Message types, including ExecuteMsg, InstantiateMsg and QueryMsg for interacting with the contract.│ ├── state.rs # Data structures for storing contract state.│├── Cargo.toml # Configuration file.└── README.md # Your docs go here.
Making it your own
Next you'll need to make the necessary changes to the contract, so it abides any logic you want. This will cover the 3 distinct functions: Instantiate
, Execute
and Query
.
Instantiate
- The template starts with the following
Instatiate
msg:
pub fn instantiate(deps: DepsMut,_env: Env,_info: MessageInfo,_msg: InstantiateMsg,) -> Result<Response, ContractError> {set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;Ok(Response::new())}
That definitely won't suffice for our purposes, so here we will modify InstantiateMsg
and use its fields to initialize our contract state.
This automatically implies that now is the time for us to create a struct inside state.rs
that will hold the state of our contract.
- For the sake of illustrating the instantiate function, we are going to initialize our contract with parameters, like the operator address and required voting percentage:
#[cw_serde]pub struct InstantiateMsg {pub operator_contract: String,pub required_percentage: u32,}
- For our state we can create a new struct inside
state.rs
that looks something like this:
#[cw_serde]pub struct Config {pub operator_contract: String,pub required_percentage: u32,}
- Next, define a
const
with which we can refer to the item in storage, like this:
pub const CONFIG: Item<Config> = Item::new("config");
- Circling back to
contract.rs
we end up with something like this, that will actually glue everything said so far:
#[cfg_attr(not(feature = "library"), entry_point)]pub fn instantiate(deps: DepsMut,_env: Env,_info: MessageInfo,msg: InstantiateMsg,) -> Result<Response, ContractError> {// any `msg` verification logic can happen here// ..snip..// saving the contract configuration to storagelet config = Config {operator_contract: msg.operator_contract,required_percentage: msg.required_percentage,};CONFIG.save(deps.storage, &config)?;Ok(Response::new())}
Execute
Now that our contract state is initialized, we can move to arguably the most important message: the ExecuteMsg
. This is the next entry point of the smart contract. This message will modify the state.
- The template inside
contract.rs
provided us with a very simple structure to work on:
#[cfg_attr(not(feature = "library"), entry_point)]pub fn execute(_deps: DepsMut,_env: Env,_info: MessageInfo,_msg: ExecuteMsg,) -> Result<Response, ContractError> {unimplemented!()}
- And inside
msg.rs
we have:
#[cw_serde]#[derive(cw_orch::ExecuteFns)]#[cw_orch(disable_fields_sorting)]pub enum ExecuteMsg {}
- Now, let's get our hands dirty! We should define our custom logic by first defining the different enum types and the input parameters they'll be using. Based on our simple storage, we can make the first variant look like the following:
#[cw_serde]#[derive(ExecuteFns)]pub enum ExecuteMsg {ExecutedTask {// address of the contract from which the task came fromtask_queue_contract: String,// id of the tasktask_id: TaskId,// reported resultresult: String,},}
- Now we should get back to
contract.rs
and satisfy the compiler:
#[cfg_attr(not(feature = "library"), entry_point)]pub fn execute(deps: DepsMut,env: Env,info: MessageInfo,msg: ExecuteMsg,) -> Result<Response, ContractError> {match msg {ExecuteMsg::ExecutedTask {task_queue_contract,task_id,result,} => execute::execute_task(deps, env, info, task_queue_contract, task_id, result),}}
- Inside mod execute we actually handle the verification:
fn execute_task(deps: DepsMut,env: Env,info: MessageInfo,task_queue_contract: String,task_id: TaskId,result: String,) -> Result<Response, ContractError> {// some sample logic to validate the task and handle the result// #1: check if the operator is legit and can vote// #2: query the task queue to check if the task is still open// #3: handle the voteOk(Response::new().add_attribute("action", "executed_task"))}
Query
So far, we have covered the instantiation and functions that alter the state of our contract. Time to write something that will query it.
- For that purpose we can change the already present lines in
contract.rs
:
#[cfg_attr(not(feature = "library"), entry_point)]pub fn query(_deps: Deps, _env: Env, _msg: QueryMsg) -> StdResult<Binary> {unimplemented!()}
- Change
msg.rs
to add the queries that will be available for our contract:
#[cw_serde]#[derive(cw_orch::QueryFns)]#[cw_orch(disable_fields_sorting)]#[derive(QueryResponses)]pub enum QueryMsg {}
- We start by expanding the
QueryMsg
. In our example we will be adding some useful for our use case variants:
#[cw_serde]#[derive(cw_orch::QueryFns)]#[cw_orch(disable_fields_sorting)]#[derive(QueryResponses)]pub enum QueryMsg {// to return the our contract's configConfig {},// to fetch some resultsTaskInfo { task_contract: String, task_id: TaskId },// to get some details for a voteOperatorVote { task_contract: String, task_id: TaskId, operator: String },}
- In
state.rs
we can also make sure to use a separate Struct for the responses to make the code transparent, i.e.:
#[cw_serde]pub struct ConfigResponse {pub operator_contract: String,pub required_percentage: u32,}
- Time to satifsy the compiler with changing
contract.rs
:
#[cfg_attr(not(feature = "library"), entry_point)]pub fn query(deps: Deps,_env: Env,msg: QueryMsg,) -> StdResult<Binary> {match msg {QueryMsg::Config {} => {let config = CONFIG.load(deps.storage)?;to_binary(&config)},QueryMsg::TaskInfo { task_contract, task_id } => query::query_task_info(deps, task_contract, task_id),QueryMsg::OperatorVote { task_contract, task_id, operator } => query::query_operator_vote(deps, task_contract, task_id, operator),}}
mod query
can look a little something like this:
mod query {fn query_task_info(deps: Deps, task_contract: String, task_id: TaskId) -> StdResult<Binary> {let task_info = TASKS.load(deps.storage, (task_contract, task_id))?;to_binary(&task_info)}}
Testing
Now that we have instantiation, execution and querying in our contract it's time for us to test it. Layer provides you with a useful setup that makes it easier for us to add more robust testing.
In layman's terms, we can extend the testing logic inside tests/common.rs
and then decide which tests to include inside gollem.rs
or multi.rs
.
- To provide an example of what can be done with
common.rs
we can add this simple test:
pub fn test_execute_task<C>(chain: C)whereC: CwEnv + AltSigner,C::Sender: Addressable,{// #1: setting up mock operatorslet operator1 = chain.alt_signer(3);let operator2 = chain.alt_signer(4);// `voting_power` must equal 100let operators = vec![InstantiateOperator { addr: operator1.addr().to_string(), voting_power: 60u32 },InstantiateOperator { addr: operator2.addr().to_string(), voting_power: 40u32 },];let mock_operators = setup_mock_operators(chain.clone(), operators);let msg = InstantiateMsg {operator_contract: mock_operators.addr_str().unwrap(),// we require %100 quorumrequired_percentage: 100,};let verifier = setup(chain.clone(), msg);// #2: creating a task in the queuelet tasker = setup_task_queue(chain.clone(), &verifier.addr_str().unwrap());let task_id = make_task(&tasker, "Get Price Task", None, &json!({"action": "get_price"}));// #3: operators can voteverifier.call_as(&operator1).executed_task(tasker.addr_str().unwrap(),task_id,r#"{"price": "100"}"#.to_string(),).unwrap();verifier.call_as(&operator2).executed_task(tasker.addr_str().unwrap(),task_id,r#"{"price": "100"}"#.to_string(),).unwrap();// #4: we can do some verificationslet result = status.result.unwrap();assert_eq!(result, json!({"price": "100"}));}
- Next we would have to add this test from
common.rs
to both?golem.rs
:
#[test]fn set_name() {let chain = construct_golem();super::common::test_execute_task(chain);}
- And inside
multi.rs
#[test]fn happy_path_works() {let chain = MockBech32::new(BECH_PREFIX);super::common::test_execute_task(chain);}
You should now have a basic understanding of how to customize a verifier contract for your AVS.