Layer LogoLayer AVS Docs
Guides

Custom verifier contracts

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:

  1. 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.
  2. 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
info

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>
info

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

  1. 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.

  1. 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,
}
  1. 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,
}
  1. Next, define a const with which we can refer to the item in storage, like this:
pub const CONFIG: Item<Config> = Item::new("config");
  1. 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 storage
let 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.

  1. 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!()
}
  1. And inside msg.rs we have:
#[cw_serde]
#[derive(cw_orch::ExecuteFns)]
#[cw_orch(disable_fields_sorting)]
pub enum ExecuteMsg {}
  1. 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 from
task_queue_contract: String,
// id of the task
task_id: TaskId,
// reported result
result: String,
},
}
  1. 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),
}
}
  1. 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 vote
Ok(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.

  1. 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!()
}
  1. 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 {}
  1. 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 config
Config {},
// to fetch some results
TaskInfo { task_contract: String, task_id: TaskId },
// to get some details for a vote
OperatorVote { task_contract: String, task_id: TaskId, operator: String },
}
  1. 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,
}
  1. 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),
}
}
  1. 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.

  1. 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)
where
C: CwEnv + AltSigner,
C::Sender: Addressable,
{
// #1: setting up mock operators
let operator1 = chain.alt_signer(3);
let operator2 = chain.alt_signer(4);
// `voting_power` must equal 100
let 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 quorum
required_percentage: 100,
};
let verifier = setup(chain.clone(), msg);
// #2: creating a task in the queue
let 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 vote
verifier.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 verifications
let result = status.result.unwrap();
assert_eq!(result, json!({"price": "100"}));
}
  1. 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);
}
  1. 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.

On this page