Introduction

Introduction

CoopHive is pioneering generic marketplaces built on three fundamental primitives: the exchange of bundles of assets, a series of credible commitments, and agent-to-agent negotiation. These primitives serve as the cornerstone for creating a diversity of decentralized marketplaces, including for compute, storage, data, bandwidth, and energy. Understanding the motivation behind these choices and how they were carefully selected is valuable for understanding the evolutionary path CoopHive has taken.

Motivation

The original goal was to design a distributed computing network (DCN) with optimistic verification of compute results, where they are assumed correct unless contested. Based on an improvement of prior research, we settled on a sequence of deals, results, and (optional) mediations on-chain. Each element of the sequence consists of an IPFS CID plus necessary on-chain data, minimizing the amount of data on-chain by having pointers to (almost) arbitrarily large data off-chain.

To accommodate various verifiable computing methods, CoopHive introduced pluggable mediation protocols, allowing participants in the network to select their preferred verification strategies. The protocol’s market-making was initially managed by a solver, which matched job offers with resource availability via an off-chain orderbook. Payments were facilitated through a single ERC20 token.

Problems

While the initial design had its merits, several challenges emerged:

  1. Single Token Limitation: The reliance on a single ERC20 token for payments proved restrictive, as it hampered adoption by limiting the flexibility of payment options.

  2. Decision-Making Process: It became unclear how agents would effectively accept or reject deals proposed by the solver, highlighting the need for a more structured decision-making process.

  3. Modular Lifecycle Necessity: The challenges associated with implementing optimistic verification underscored the need for a more modular job lifecycle, where various types of collateral could be added to any part of the job lifecycle.

To learn more about the motivation and problems, see the first version of the whitepaper, the documentation on verifiable computing, and the corresponding codebase.

Solutions

To address these challenges, CoopHive decided to evolve its protocol in the following ways:

  • Expanded Payment Options: Rather than paying in a single ERC20 token, it made sense to allow paying in any token, to incentivize networks that already have their own tokens to use the protocol. Supporting any ERC20 logically led to supporting any token standard (ERC721, ERC1155, ERC6909), allowed for the representation and exchange of nearly any object or asset, including items from Web2 marketplaces like eBay or Amazon, real-world assets, and even energy credits.

  • Bundles of Assets: The capability to handle payments in any token standard naturally expanded to include the exchange of bundles of tokens. This improvement unlocks numerous use cases, enabling drawing inspiration from decades of game theory research in the exchange of bundles of assets, which provides further utility to protocol participants.

  • Utility Maximization: The question of whether to accept or reject a deal was cast in terms of utility maximization, a core concept in game theory. This in turn allowed reframing the problem in terms of traditional scheduling problems and peer-to-peer negotiation, providing a more rational basis for decision-making.

  • Verifiable Computing and Collateralization: The problem of verifiable computing was also revisited through the lens of utility maximization. Since most verifiable computing strategies require collateral from at least one involved party (at least when computations are being checked by a mediator/validator), this reframing helped position protocol actors as rational, self-interested parties that cheat and/or collude if they believe it would benefit them. After training agents to maximize their utilities, we can then plug in anti-cheating mechanisms in a modular manner to see their impact on individual agents, and the network as a whole.

  • Modular Commitments: As described above, the need to prevent cheating within the network led to the development of a modular system for making different types of commitments. In this system, collateral is placed in escrow, and depending on the outcome of some process (e.g. a transaction, computation, off-chain oracle, etc.), the collateral may be retained, or released to one of the parties. This approach also integrates well with the exchange of different types and bundles of assets, enabling the modeling of various kinds of marketplaces beyond just computing.

  • Autonomous Agent Interaction: Since these marketplaces are designed for interaction by autonomous agents, particularly for pricing and scheduling in the computing context, the modular series of credible commitments allows agents to make and enforce commitments to each other (and themselves) programmatically. Agent-to-agent negotiation, a key primitive in multi-agent systems, is facilitated by this structure. From this new perspective, solvers-as-market-makers should emerge organically from scalability needs, rather than imposed on protocol agents from the outset.

Conclusion

This represents one of the first attempts to put these primitives into practice. As it evolves, market feedback will play a crucial role in identifying the strengths and weaknesses of each iteration. By remaining adaptable and responsive to real-world usage, we aim to refine and optimize the protocol, driving the development of generic, machine-actionable marketplaces.

Alkahest

Alkahest is an open-source public good for on-chain peer-to-peer agreements, built on EAS.

Everything is dissolving.

Goods and services become increasingly discrete and non-fungible as their provision is decentralized. One seller at a particular time does not demand nor offer exactly the same as another selling a similar thing, and negotiation must become case-specific and fine-grained.

At the same time, the atomization of provision means that aspects of negotiation, scheduling, settlement, and quality-control formerly unilaterally and opaquely controlled by sell-side monopolies can now be shared within an open ecosystem.

Alkahest is a toolkit for dissolving consolidated economies and creating atomic ones, providing a set of lightweight and composable units for common processes in peer-to-peer negotiation and exchange. Solve et coagula.

Get Started

Asides

Why EAS?

Barter is back. Fungible currency allowed us to reduce the complexity of trading by always selling for and buying with the same thing, but its adoption is not free. Even in today's highly efficient markets, there is always a cost to exchanging X for Y for Z, over exchanging X for Z directly - usually collected by traders of Y. The cost is non-trivial anywhere Y has poor liquidity or inconvenient means of exchange, including most blockchain programs.

In a barter economy, we want to facilitate the exchange of anything for anything else, since there is no longer a standard "fungible currency" that everything can be exchanged for. The Ethereum Attestation Service is a good option for representing obligation fulfillments in this system, since the fundamental abstraction of EAS is "A saying X about B", which could be a party making a claim that they've fulfilled an obligation, or a trusted facilitator (e.g. an on-chain contract or an AVS) making a claim that someone fulfilled an obligation through it.

Many of the abstractions that EAS provides are directly relevant to validated exchange:

  • schemas, to have structured Statements and Validations
  • referenced attestations
    • a fulfillment offer can reference the offer it fulfills
    • a validation can reference the statement it's about
  • expiry/revocation, for time limits and cancelation conditions on deals

Why Not Tokens?

One way to implement on-chain peer-to-peer exchange would be to rely on the existing infrastructure for token trading, such as DEXs and NFT markets like OpenSea, and to make contracts that create a mechanical association between a token and other underlying assets to be exchanged.

However, this introduces unnecessary overhead compared to using EAS attestations to represent Statements, since the creation of an EAS attestation, even according to a new schema, is more lightweight than deploying a new token contract. More importantly, EAS is already designed to represent links between attestations and real-world facts, whereas token standards like ERC-20, ERC-721, ERC-1155, and ERC-6909 are not.

Building out a system of composable and modular interconnected token contracts representing obligations and validations would still require reimplementation of much of the abstraction work that's already handled by EAS, such as relationships between attestations.

It's relatively simple to make Statements that represent token deposits, as demonstrated in ERC20PaymentStatement, so token-centric architectures can still be implemented, for example when tokens with existing functions (e.g. NFT-gating or DeFi derivatives) already exist.

Why Not Specialized Networks?

Alkahest's Components For Exchange are designed as a public good without a protocol-level governance or fee token. The goal is to enable a truly composable and extensible ecosystem, where new Statements and Validations can be made to connect any existing good or service, whether on-chain or off-chain, with or without distributed validation, within an open network for peer-to-peer exchange.

Many existing DePIN protocols require a network-level token for use and participation. This makes composing services across different DePIN networks more difficult, since users must hold a different token (and possibly participate in different token mechanics) for each protocol that they're using.

Also, many protocols use a network-level token to provide network-wide guarantees in proof of stake type systems. This is often very performance inefficient relative to untrusted service provision, and leads to security bottlenecks at the least-staked node of a system.

We envision a much more fragmented ecosystem, where concerns about validity are either local to a deal, in which case security collaterals are also local (and token-agnostic) rather than network-wide, and provision is unencumbered by network-level guarantees, or network validity is secured by large pools like EigenLayer.

Special Statements or Validations can and will be developed to guarantee that a service is provided by a token-secured network, but the ecosystem we are facilitating does not require its own network token, since it's more like a community standard than a crypto-economic network.

For Exchange

We take the peer-to-peer agreement as the basic unit of exchange, where party A agrees to X in exchange for party B's Y. We call X and Y statements and represent them by EAS attestations - usually produced by resolver contracts - which could represent an on-chain token payment, another on-chain action, a direct reference to an off-chain action (e.g. some data's URI), an oracle's guarantee of an off-chain action, or anything else.

Statements can be tested by reusable and modular validators, which produce validation attestations that deal parties can use in smart contracts to trustlessly finalize on-chain actions (e.g. payment) if and only if the counterparty fulfills their part of the agreement.

Pasted image 20240902222721.png

Statements

Statements represent the fulfillment of a party's obligation in an agreement.

For example, the sample ERC20PaymentStatement is an EAS resolver contract with a function

    struct StatementData {
        address token;
        uint256 amount;
        address arbiter;
        bytes demand;
    }
    ...
    makeStatement(StatementData calldata data, uint64 expirationTime, bytes32 refUID)

requiring the attester to deposit an amount of an ERC20 token, and producing an attestation stating they've done so. It has another function collectPayment(bytes32 _payment, bytes32 _fulfillment), which the counterparty can call to collect the payment, passing in an attestation from the specified arbiter address, which could be a statement, validator, or even a user address. In practice, the contract should be extended so that the payment maker can specify in the statement what they're demanding from the counterparty, and check within collectPayment whether it was actually fulfilled.

Statements can directly reference other statement attestations and interact with other statement contracts, sometimes enabling complete negotiation flows even without dedicated agreement contracts or validators.

Validations

Validations represent properties of statements which are difficult to determine or cannot be determined via their raw data, and are often produced by third parties. They can be used for conditional finalization of terms in agreements.

For example, the sample OptimisticStringValidator for StringResultStatement statements has a function

	struct ValidationData {
        string query;
        uint64 mediationPeriod;
    }
    ...
	startValidation(bytes32 resultUID, ValidationData calldata validationData)

which produces an attestation referencing the specified statement after a certain amount of time unless the counterparty calls mediate(bytes32 validationUID). If the counterparty requests mediation, a trusted oracle retries the job and produces the requested attestation only if it gets the same result as specified in the original statement.

If specified as the arbiter in an ERC20PaymentStatement, the attestation from OptimisticStringValidator can be passed into collectPayment to claim payment for agreements. In this case, collectPayment should be extended to check if each validation's underlying StringResultStatement actually fulfills the specified payment via the job and counterparty fields of the statement's attestation schema.

Agreements

How does the buy-side in our example so far know when it should request mediation? It could listen to events from the OptimisticStringValidator it specified, filtering by those referring to statements it made, and requesting mediation on unsatisfactory results. Indeed, in many cases, Agreements can be an informal concept enacted through interactions with other contracts.

However, more complex negotiations may sometimes require dedicated contracts to manage the relationship between statements and validations. One use for agreement contracts is as a component of multi-step processes consisting of several atomic agreement steps.

Statements

Statements represent the fulfillment of a party's obligation in an agreement. They have three main parts - an initialization function, parametrized checks, and term finalizations.

Initialization

Initializing a statement means doing all on-chain actions necessary to fulfill the obligation represented by the statement, and getting an attestation from the statement contract stating that you've done so. For example, in ERC20PaymentStatement, the initialization function makeStatement requires a deposit of an ERC20 token which the counterparty is eligible to collect by producing a statement representing their side of the agreement.

Generally, the attestation produced by a statement will have the form {offerData, arbiter, demand}, where offerData has a particular type per statement contract (e.g. address token, uint amount in ERC20PaymentStatement). The other fields specify the demand the counterparty must fulfill to finalize the statement. Statements made in fulfillment of an existing statement can reference that statement in its refUID, and implement its finalization functions to check for this as a special case.

Checks

Statements implement IArbiter, including the abstract function checkStatement(Attestation memory statement, bytes memory demand, bytes32 counteroffer), used to verify if a statement meets the specified demand. It checks if an attestation follows the correct schema, if it's still valid and not revoked, and if it meets the demands specified as demand, which for ERC20PaymentStatement is address token, uint256 amount, address arbiter, bytes demand, demanding at least a certain amount of a certain ERC20 token, as well as that a particular fulfillment is demanded, which will be usable to collect the payment.

A call to IArbiter(address).checkStatement on a trusted arbiter contract should be sufficient to determine that any untrusted attestation is actually a valid statement fulfilling a specified demand. Sometimes, the statement contract itself will not be able to sufficiently guarantee the validity of statements produced from itself, and counterparties will need to rely on Validations from other validators. In these cases, the statement's implementation of checkStatement is often still useful to validator contracts, which could guarantee additional properties on top of the statement contract's intrinsic guarantees.

Finalizations

Finalizations can be thought of as asynchronous terms of statements, represented as functions from conditions to effects. Their conditions are typically represented as attestations, especially from contracts implementing IArbiter - i.e., Statements or Validations. Conditions can vary per statement by depending on arbiter and demand, or interpretation of its EAS parameters, and common conditions like only being fulfillable by a specific counterparty are implemented as utilities in IStatement itself (* coming soon).

For example, ERC20PaymentStatement has the finalization function collectPayment(bytes32 payment, bytes32 fulfillment), which takes the UIDs of a payment and a fulfillment attestation. It checks if the either the payment attestation explicitly specifies the fulfillment attestation (by its refUID), or the fulfillment attestation fulfills the demands specified in the payment statement's arbiter and demand fields, and transfers the payment collateral to the caller if either condition holds.

Validations

Validations represent properties of statements which are difficult to determine or cannot be determined via their raw data, and are often produced by third parties. They can be used for conditional finalization of terms in agreements.

Generally, validations are requested on Statements or other Validations by the underlying statement creator, and returned asynchronously in an event emission.

Requests

Validation requests don't have a consistent abstract interface, because they can vary a lot based on what the validation is for. Often, they will accept statement or other other validation attestations to be validated, along with additional parameters specifying the properties to be validated if they aren't already defined in the base attestations.

Checks

Validators implement IArbiter, and their implementation of checkStatement(Attestation memory statement, bytes memory demand, bytes32 counteroffer) should be interpreted as checking a validation according to parametrized demands. The counteroffer UID is explicitly passed in because a demand is often specified in a counteroffer attestation, but it's impossible to know the UID of an attestation before it's created.

It's good practice to call IArbiter(statement).checkStatement inside a statement validator's implementation of checkStatement, so that Statements can specify a single arbiter as the source of truth inside finalization clauses.

Emission

Often, validations must be produced asynchronously via a function call by an off-chain oracle or another contract. One way to implement this, as demonstrated in OptimisticStringValidator, is optimistic mediation, where checkStatement is implemented to return valid after a given mediation period unless mediation is requested, in which case the validation attestation is revoked if it's invalid.

Another way is to produce the attestation asynchronously, only after validation actually happens. An event should be emitted when validations are produced if this architecture is used, but the event is not defined in IValidator (*subject to change), because architectural details vary too much per validation scheme.

Examples

  • Oracle-based validators can have off-chain entities produce a validation attestation on request. E.g., to retry a deterministic compute job and verify its correctness
  • Reputation validators can accept collateral and produce positive validations for a particular entity's statements by default, while allowing authority oracles or a network of anonymous informers to revoke validations and slash collateral for entities that misbehave
  • Combination validators can accept several other validations and combine them into a single one - e.g., indicating that all of them are valid, or that their sum fulfills some condition

Agreements

Dedicated agreement contracts are useful for coordinating and providing a single interface to many Statements and Validations during complex exchanges, or for providing abstract units of agreement that can be composed into composite processes.

For Negotiation

In this section we conceptualize the high-level design choices and primitive definitions of the protocol, with regards to its multi-agent systems, potential data-driven optimal control, agent-to-agent negotiation and scheduling. It serves as the reference point to define the building blocks of the schemes and agents marketplace and their APIs to both on-chain and other off-chain modules of the protocol.

The negotiation framework is defined for validatable, terminable tasks with collateral transfer after validation. In this context, we talk about "stateless" tasks to stress their inner reproducibility (their lack of dependence against client-specific state variables). The presence of agent-based modeling (whose policy is potentially data-driven, tapping into ML/RL), is motivated by the need to orchestrate a decentralized network of agents in a way that leads to competitive pricing/scheduling, from the user perspective. At the same time, the use of blockchain is motivated by the trustless and automatic transfer of collateral after validation.

Schemes

Schemes define the structured interaction framework between Agents. They consist of a set of messages and rules that dictate how agents can interact with each other and participate in negotiations. The schemes outline the negotiation protocol, ensuring that all agents operate under the same set of rules, fostering a standardized and trustless environment for task negotiation and execution.

Agents

The existence of distributed and heterogenous hardwares and the potential construction of data-driven policies for optimal decision making do not motivate by themselves the usage of an agent-based perspective in the modeling of the system.

The case for Agent-based modeling resides in the fact that nodes participating in the network keep agency over themselves, i.e. they are continuously able to accept or reject jobs, and this capability is never delegated to a central entity.

Moreover, an agent-based perspective can be used to relax conventional assumptions in standard models and, in the spirit of complex systems theory, to view macro phenomena as emergent properties of the atomic units of behavior. This perspective has the potential to avoid the suboptimality following the choice of a misspecified macro model.

Finally, in the spirit of barter, the line between buy and sell side gets less well defined, beside the actions order in a given negotiation, and it's possible to have every player in the protocol be defined by the same, universal modes of behavior.

Agents

Agents are the participants in the negotiation games defined by Schemes. Generally, they can be characterized by an (effectful) function (message, context) => message, where message represents a phase of a scheme, and context consists of the agent's role (e.g. buyer or seller), the scheme state including previous messages, on-chain state, and any-other scheme-specific contextual information. The agent listens for messages, and replies with a message to advance the scheme state when it's responsible to do so.

Since schemes correspond with a real-world process (an actual exchange), agents are also responsible for performing the in-world actions that the scheme state is supposed to represent, as well as for independently confirming the veracity of real world state represented by scheme messages from other (untrusted) parties.

Actions

Agents are not only responsible for sending scheme-conformant messages. The negotiation and scheduling of tasks requires interaction with the Components For Exchange, and with the real world. We call this set of interactions actions.

In general, it's the agent's responsibility to execute actions when a scheme specifies that a message represents a real-world state change (e.g. a registered EAS attestation), as well as to verify other agents' messages corresponding to real world states. However, scheme clients could be implemented to perform actions common between many agents participating in a scheme, to lighten the implementation load for agents.

When actions aren't handled by the scheme client, they will still often be packaged in a modular way, when there are many actions in common between different agents of the same role. For ease of use by different agents, wrappers might be necessary for different development contexts (e.g. programming languages), but complex functions could be developed and provided in a common library.

Policy

The generic interface and scope of agents is in the implementation of policies, whose goal is to update the state \( p \) of the state machine (associated with a schema) interacting with a set of \( N \) agents.

\[ \begin{bmatrix} p_{t+1} \ a_{i, t+1} \end{bmatrix} = f_i(p_t, h_{i, t}, b_{i, t}), \quad \text{for each } i \in {1, \ldots, N} \]

Please note the agent-independence of the state \( p \) and the agent-dependence of all the other variables. In particular, we represent with \( a \) all the additional Actions performed by agents, as discussed in the previous section; we represent with \( h \) additional variables used to inform agents actions, with \( b \) a set of parameters defining the inner state of agents' policies and with \( f \) the functional form relating all these variables.

The dimensions of the state space can be categorized in different ways. One way is distinguishing between both local states (i.e., variables associated with agents themselves) and global information (i.e., global, environmental variables which are not a function of the agent). In this perspective, we can break further break down the policy equation for agent \( i \):

\[ \begin{bmatrix} p_{t+1} \ a_{i, t+1} \end{bmatrix} = f_i(p_t, h_{i, t}, Z_{i, t}, b_{i, t}), \quad \text{for each } i \in {1, \ldots, N} \]

In other terms, we can further break down the state of the system in:

  • \( p \), state of the state machine, the same for every agent and associated with schemas;
  • \( h \), additional state components of the environment. These components are defined by each agent, and are characterized by variables which are hidden to all the other \( N-1 \) agents in the system. We call the components of \( h \) local states.
  • \( Z \), additional state components of the environment. While these components are still defined by each agent, they are characterized by variables which are shared by all agents. We call the components of \( Z \) global states.

This distinction aligns with the general framework of partially observable Markov games, characterizing the protocol.

Markets

Markets are concrete instantiations of Agents negotiating according to Schemes.

Schemes

A scheme is a definition of a negotiation game in which Agents notify each other of their intents and actions via a sequence of messages. Schemes are defined according to a set of typed messages communicated via pubsub channels, and a set of rules that define:

  • what messages are valid (either mandatory or optional) responses from what kinds of agents, under what contexts;
  • what real-world (i.e. out-of-band) state particular messages represent, and what kinds of checks are available to agents to verify messages that represent out-of-band facts;
  • what messages are available to what agents under what contexts, and guidelines for what messages agents should be interested in, and when they should start/stop listening to particular topics;
  • what conditions represent termination of a negotiation.

Schemes can be thought of as all the information that agents in a negotiation game have access to, other than the information they get from local observations. Relatedly, they also represent all the universally shared state within a negotiation other than on-chain state and other publicly accessible real-world state.

Messages

Messages are the individual phases of a scheme as a state machine, and the public actions of a scheme as a multi-agent game. Generally, messages either represent a claim about agent intent (e.g. an offer in a negotiation), or a notification about out-of-band state, which other interested agents are then responsible for verifying.

A scheme is responsible for defining what messages are part of a negotiation game, what messages agents in particular roles and contexts should be interested in, and what messages agents in particular roles and contexts are allowed or required to send. It also defines what out-of-band state messages are supposed to represent, though it's the responsibility of interested agents to locally validate the correspondence between messages representing out-of-band state and the state they're supposed to represent.

Topics

Messages are communicated in topics following a pubsub architecture. A scheme defines the organization of messages into topics, and what special kinds of messages indicate interest (subscription) or disinterest (unsubscription) in a particular topic.

Messages within a topic have guaranteed order between agents, while messages across different topics don't. As such, topics often represent independent negotiations within a market where multiple simultaneous negotiations can happen. A series of (scheme-conforming) messages within a topic can be interpreted as a branch of a game tree, or a realization of a Markov game.

Rules

A scheme is responsible for defining not just a set of typed messages, but a protocol in which they communicate intent and action in a negotiation process. This protocol can be interpreted as a state machine or a multi-agent game. As a state machine, the scheme rules define its complete evolution. As a game, the scheme rules define the valid action space for each agent, conditioned on the scheme state.

Scheme rules can be seen a constraint on the Agents policy function (message, context) => message, defining what messages are valid in response to what messages in what contexts. It's also the scheme's responsibility to describe what out-of-band actions agents are responsible for realizing to maintain the correspondence between the scheme and the real-world process it's intended to represent.

Hello World - Tokens for Strings

Let's demonstrate the use of statements and validations by building an example marketplace where users buy string manipulations for ERC20 tokens. Buyers will be able to submit a token payment in an arbitrary ERC20 token, demanding a particular string to be capitalized. Sellers who submit a valid capitalization of the buyer's string, verified optimistically directly in a smart contract in our example, will be able to claim the buyer's token payment.

The components we implement along the way will also allow paying for any other task in ERC20 tokens, or selling on-chain validated string capitalizations for anything else, only requiring an implementation of the relevant counterparty component. The example optimistic mediation validator should also be extensible to support verification of much more complex tasks.

Paying Tokens

Statements represent a party's fulfillment of one side of an agreement. The most basic exchange requires just two statements - one for the ask and one for the bid. We'll implement this first, then modify the implementation to support a pluggable validator contract.

Statements have three main parts - an initialization function, inherent validity checks, and term finalization functions. The initialization function is called by the party offering what the statement represents, and performs all on-chain actions necessary to enact its finalization terms, as well as for any relevant validators to validate the statement. Finalization functions represent individual conditions of an agreement, and can be called by the agreement counterparty, passing in the host contract statement and a statement or validation representing their side of the deal. Checks are what finalization functions use to parametrically assess validity of other statements. Let's make this clearer with an example.

To make a statement contract for ERC20 payments, we need the statement creator to deposit tokens into escrow, which are then available to be claimed by a valid counterparty. When other statements require ERC20 payments from a counterparty, they'll want to check if at least a specified amount of a specified token is available for collection.

Initialization

We'll first implement statement creation via depositing an ERC20 token and specifying what's demanded from the counterparty.

contract ERC20PaymentStatement is IStatement {
    struct StatementData {
        address token;
        uint256 amount;
        address arbiter;
        bytes demand;
    }
    
    error InvalidPayment();

    string public constant SCHEMA_ABI = "address token, uint256 amount, address arbiter, bytes demand";
    bool public constant IS_REVOCABLE = true;

    constructor(IEAS _eas, ISchemaRegistry _schemaRegistry)
        IStatement(_eas, _schemaRegistry, SCHEMA_ABI, IS_REVOCABLE)
    {}

    function makeStatement(StatementData calldata data, uint64 expirationTime, bytes32 refUID)
        public
        returns (bytes32)
    {
        if (!IERC20(data.token).transferFrom(msg.sender, address(this), data.amount)) {
            revert InvalidPayment();
        }

        return eas.attest(
            AttestationRequest({
                schema: ATTESTATION_SCHEMA,
                data: AttestationRequestData({
                    recipient: msg.sender,
                    expirationTime: expirationTime,
                    revocable: true,
                    refUID: refUID,
                    data: abi.encode(data),
                    value: 0
                })
            })
        );
    }

    function getSchemaAbi() public pure override returns (string memory) {
        return SCHEMA_ABI;
    }
}

Let's walk through it part by part.

struct StatementData is the data specific to each statement made from this contract. In this case, each statement represents depositing an amount of an arbitrary ERC20 token, specified by its contract address. arbiter and demand together represent what's demanded from the counterparty to collect payment, which we'll explain in more detail when implementing the checkStatement function required by IArbiter. arbiter is the contract whose implementation of checkStatement we accept as a source of truth on counterparty validity, and demand is passed into IArbiter(arbiter).checkStatement to allow parametrized demands.

SCHEMA_ABI is the ABI form of StatementData, and is returned from getSchemaAbi, which is a virtual function defined in IStatement. SCHEMA_ABI and StatementData themselves aren't actually part of IStatement, but getSchemaAbi must return the statement data ABI as a string, because the EAS attestations that statement contracts produce will encode the data as bytes, which other contracts will sometimes want to decode.

The constructor is just a call to IStatement's constructor with specialized parameters. It registers the statement schema with EAS and sets the schema UID as a public parameter on the contract called ATTESTATION_SCHEMA. EAS schemas specify if attestations are revokable or not, and in this case, they are, with revocation meaning the cancelation of an unfinished deal.

makeStatement is the statement's initialization function. Callers specify a token and amount, a demand for the counterparty, optionally an expiration time (0 if none), and optionally a refUID if the statement is fulfilling the demand of another specific existing statement. The function transfers the specified amount of the specified token from the caller to the contract, produces an on-chain attestation with EAS containing the StatementData passed in, and returns the bytes32 UID of the attestation.

Checks

The main thing counterparties will want to check about ERC20 payment statements is whether at least a specific amount of a specific token is deposited, with a specific demand. We'll implement checkStatement to enable this.

contract ERC20PaymentStatement is IStatement {

	...
    string public constant DEMAND_ABI = "address token, uint256 amount, address arbiter, bytes demand";    
	...

    function checkStatement(Attestation memory statement, bytes memory demand, bytes32 counteroffer)
        public
        view
        override
        returns (bool)
    {
        if (!_checkIntrinsic(statement)) {
            return false;
        }

        StatementData memory payment = abi.decode(statement.data, (StatementData));
        StatementData memory demandData = abi.decode(demand, (StatementData));

        return payment.token == demandData.token && payment.amount >= demandData.amount
            && payment.arbiter == demandData.arbiter && keccak256(payment.demand) == keccak256(demandData.demand);
    }

	...
	
    function getDemandAbi() public pure override returns (string memory) {
        return DEMAND_ABI;
    }
}

DEMAND_ABI for ERC20 payment statements is the same as SCHEMA_ABI, which will often but not always be the case. Here, counterparties want to know that a statement represents at least an amount of a token for a specific counteroffer, but DEMAND_ABI - or more precisely, the return of getDemandAbi - just represents parameters passed into checkStatement.

checkStatement checks that the statement is produced by this contract and active (not expired nor revoked), and that it contains at least the demanded amount of the demanded token, available for collection by the demanded fulfillment (i.e., arbiter and demand).

Finalization

To complete our ERC20 payment statement, we'll add functions for collecting payments and cancelling statements.

contract ERC20PaymentStatement is IStatement {
	...
	error InvalidPaymentAttestation();
    error InvalidFulfillment();
    error UnauthorizedCall();
	...

    function collectPayment(bytes32 _payment, bytes32 _fulfillment) public returns (bool) {
        Attestation memory payment = eas.getAttestation(_payment);
        Attestation memory fulfillment = eas.getAttestation(_fulfillment);

        if (!_checkIntrinsic(payment)) revert InvalidPaymentAttestation();

        StatementData memory paymentData = abi.decode(payment.data, (StatementData));

        // Check if the fulfillment is valid
        if (!_isValidFulfillment(payment, fulfillment, paymentData)) {
            revert InvalidFulfillment();
        }

        eas.revoke(
            RevocationRequest({schema: ATTESTATION_SCHEMA, data: RevocationRequestData({uid: _payment, value: 0})})
        );
        return IERC20(paymentData.token).transfer(fulfillment.recipient, paymentData.amount);
    }

    function cancelStatement(bytes32 uid) public returns (bool) {
        Attestation memory attestation = eas.getAttestation(uid);
        if (msg.sender != attestation.recipient) revert UnauthorizedCall();

        eas.revoke(RevocationRequest({schema: ATTESTATION_SCHEMA, data: RevocationRequestData({uid: uid, value: 0})}));

        StatementData memory data = abi.decode(attestation.data, (StatementData));
        return IERC20(data.token).transfer(msg.sender, data.amount);
    }
    
    function _isValidFulfillment(
        Attestation memory payment,
        Attestation memory fulfillment,
        StatementData memory paymentData
    ) internal view returns (bool) {
        // Special case: If the payment references this fulfillment, consider it valid
        if (payment.refUID == fulfillment.uid) {
            return true;
        }

        // Regular case: check using the arbiter
        return IArbiter(paymentData.arbiter).checkStatement(fulfillment, paymentData.demand, payment.uid);
    }
}

We add the three errors InvalidPaymentAttestation, InvalidFulfillment, and UnauthorizedCall as possible failure modes during payment collection or cancellation.

collectPayment is called by the counterparty to claim a payment. First, it checks if the payment is actually issued by this contract, and still active (i.e. not expired, canceled or collected). It then checks if the payment is for a specific counteroffer (via its refUID), and whether the attestation provided corresponds to that counteroffer. Finally, it checks if the fulfillment attestation fulfills the payments demands, and if so, revokes the payment attestation and transfers the token collateral deposited when the payment was made to the caller.

cancelStatement allows a payment statement creator to revoke their statement, reclaiming their tokens and ending an agreement prematurely. Note that this basic implementation of cancelStatement shouldn't be directly used in production, since there are no protection checks or collateral to ensure that a buyer can't cancel their payment after a seller has already provided them with a benefit, but before the seller has had time to claim their payment.

For cases where the seller's obligation can be finalized on-chain in one block, including this example, this can be mitigated by bundling sell-side statement creation and payment collection into a single transaction, but for more complex exchanges, a more robust protection and collateral system is recommended.

See the final contract at ERC20PaymentStatement.

Submitting Strings

To complement our ERC20 payment statement, we'll implement a statement contract for submitting string results. This will allow sellers to provide uppercased strings in response to buyers' queries. The string result statement will be non-revocable and non-expiring, as the result, once recorded on-chain, is available indefinitely.

Initialization

We'll start by implementing statement creation for submitting string results:

contract StringResultStatement is IStatement {
    struct StatementData {
        string result;
    }

    struct DemandData {
        string query;
    }

    string public constant SCHEMA_ABI = "string result";
    string public constant DEMAND_ABI = "string query";
    bool public constant IS_REVOCABLE = false;

    constructor(IEAS _eas, ISchemaRegistry _schemaRegistry)
        IStatement(_eas, _schemaRegistry, SCHEMA_ABI, IS_REVOCABLE)
    {}

    function makeStatement(StatementData calldata data, bytes32 refUID)
        public
        returns (bytes32)
    {
        return eas.attest(
            AttestationRequest({
                schema: ATTESTATION_SCHEMA,
                data: AttestationRequestData({
                    recipient: msg.sender,
                    expirationTime: 0,
                    revocable: false,
                    refUID: refUID,
                    data: abi.encode(data),
                    value: 0
                })
            })
        );
    }
}

Let's break it down:

struct StatementData contains only the result string, which is the capitalized version of the query.

struct DemandData represents the demand structure, which only includes the query string to be capitalized.

SCHEMA_ABI and DEMAND_ABI are simplified to reflect the reduced data structures.

IS_REVOCABLE is set to false, as string results, once recorded on-chain, cannot be meaningfully revoked.

The constructor remains similar, registering the statement schema with EAS.

makeStatement is the initialization function for creating a string result statement. It creates a non-revocable, non-expiring on-chain attestation with EAS containing the StatementData and returns the attestation's UID.

Checks

For string result statements, we'll implement checkStatement to verify if a submitted result is the correctly capitalized version of the query:

contract StringResultStatement is IStatement {
    function checkStatement(
        Attestation memory statement,
        bytes memory demand, /* (string query) */
        bytes32 counteroffer
    ) public view override returns (bool) {
        if (!_checkIntrinsic(statement)) {
            return false;
        }

        // Check if the statement is intended to fulfill the specific counteroffer
        if (statement.refUID != bytes32(0) && statement.refUID != counteroffer) {
            return false;
        }

        StatementData memory result = abi.decode(statement.data, (StatementData));
        DemandData memory demandData = abi.decode(demand, (DemandData));

        return _isCapitalized(demandData.query, result.result);
    }

    function _isCapitalized(string memory query, string memory result) internal pure returns (bool) {
        bytes memory queryBytes = bytes(query);
        bytes memory resultBytes = bytes(result);

        if (queryBytes.length != resultBytes.length) {
            return false;
        }

        for (uint256 i = 0; i < queryBytes.length; i++) {
            if (queryBytes[i] >= 0x61 && queryBytes[i] <= 0x7A) {
                // If lowercase, it should be capitalized in the result
                if (uint8(resultBytes[i]) != uint8(queryBytes[i]) - 32) {
                    return false;
                }
            } else {
                // If not lowercase, it should remain the same
                if (resultBytes[i] != queryBytes[i]) {
                    return false;
                }
            }
        }

        return true;
    }
}

checkStatement verifies that:

  1. The statement is produced by this contract.
  2. If the statement has a non-zero refUID, it matches the provided counteroffer.
  3. The submitted result is the correctly capitalized version of the query.

The _isCapitalized function performs a character-by-character comparison to ensure the result is the correctly capitalized version of the query. We use explicit type coercion to uint8 when comparing character values to ensure correct arithmetic operations.

Finalization

As before, the string result statement doesn't require a separate finalization step. Once a statement is created, it's immediately available for validation and use by counterparties. The non-revocable and non-expiring nature of these statements means that they persist indefinitely on-chain.

This simplified implementation of StringResultStatement complements the ERC20PaymentStatement, allowing for a straightforward exchange system where users can pay in ERC20 tokens for uppercased strings. In the next section on validation, we'll modify this implementation to perform a simpler check (like comparing string lengths) and defer the full capitalization check to an external validator.

See the final contract at StringResultStatement (*note that we modify checkStatement later in this tutorial, when adding an external validator to the system).

In Practice (Solidity)

Let's walk through a practical example of how users would interact with the ERC20PaymentStatement and StringResultStatement contracts to facilitate a trade of ERC20 tokens for an uppercased string.

Setting Up the Trade

  1. The buyer (Alice) wants to pay 10 USDC for the uppercased version of the string "hello world".
  2. Alice creates an ERC20 payment statement:
ERC20PaymentStatement.StatementData memory paymentData = ERC20PaymentStatement.StatementData({
    token: address(USDC),
    amount: 10 * 10**6, // Assuming 6 decimal places for USDC
    arbiter: address(stringResultStatement), // The StringResultStatement contract acts as the arbiter
    demand: abi.encode(StringResultStatement.DemandData({
        query: "hello world"
    }))
});

bytes32 paymentUID = erc20PaymentStatement.makeStatement(paymentData, 0, bytes32(0));

This creates an on-chain attestation representing Alice's offer to pay 10 USDC for the uppercased version of "hello world".

Fulfilling the Trade

  1. The seller (Bob) sees Alice's offer and decides to fulfill it. Bob creates a string result statement:
StringResultStatement.StatementData memory resultData = StringResultStatement.StatementData({
    result: "HELLO WORLD"
});

bytes32 resultUID = stringResultStatement.makeStatement(resultData, paymentUID);

This creates an on-chain attestation representing Bob's fulfillment of Alice's request. Note that the refUID (the second parameter of makeStatement) is set to paymentUID, linking this result to Alice's specific payment offer.

Completing the Exchange

  1. Bob can now complete the exchange by calling collectPayment on the ERC20PaymentStatement contract:
erc20PaymentStatement.collectPayment(paymentUID, resultUID);

This function will:

  • Verify that the payment statement is valid and hasn't been collected.
  • Check that the result statement matches the payment's demand (correct query).
  • Use the StringResultStatement contract (specified as the arbiter) to validate that the result is correctly capitalized.
  • If all checks pass, transfer the USDC from the contract to Bob.

Key Points

  • The arbiter in the payment statement is set to the StringResultStatement contract address. This means the ERC20PaymentStatement contract will use the StringResultStatement's checkStatement function to validate the result.
  • The demand field in the payment statement contains the encoded DemandData from the StringResultStatement. This specifies exactly what string needs to be capitalized.
  • The refUID in the result statement is set to the payment statement's UID. This creates a direct link between the offer and its fulfillment, ensuring that a result can only be used to collect the payment it was intended for.
  • Only Bob (the creator of the result statement) can collect the payment. This is enforced by the collectPaymentfunction checking if msg.sender is the recipient of the fulfillment attestation.
  • The StringResultStatement doesn't need to specify an arbiter or demand, as it's not responsible for finalizing any other statements.

This system provides a trustless way for users to exchange ERC20 tokens for specific string manipulations, with on-chain verification of the results. The use of EAS attestations for both the payment and the result provides a standardized and extensible foundation for more complex exchanges.

In the next section, we'll explore how to replace the direct use of StringResultStatement as an arbiter with a separate validator contract, demonstrating the pluggable nature of arbiters in this system.

In Practice (TypeScript + viem)

Let's walk through how users would interact with the ERC20PaymentStatement and StringResultStatement contracts using viem's contract instances API and TypeScript to facilitate a trade of ERC20 tokens for an uppercased string.

Setting Up the Environment

First, we set up our environment with viem:

import { createPublicClient, createWalletClient, http, parseAbi, getContract } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { mainnet } from 'viem/chains';

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http()
});

const walletClient = createWalletClient({
  chain: mainnet,
  transport: http()
});

// Replace with actual private keys (never hardcode in production!)
const aliceAccount = privateKeyToAccount('0xalice_private_key');
const bobAccount = privateKeyToAccount('0xbob_private_key');

// Contract addresses (replace with actual deployed addresses)
const ERC20_PAYMENT_STATEMENT_ADDRESS = '0x...' as `0x${string}`;
const STRING_RESULT_STATEMENT_ADDRESS = '0x...' as `0x${string}`;
const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';

const statementAbi = parseAbi([
  'function getDemandAbi() public view returns (string)',
  'function getSchemaAbi() public view returns (string)',
  'function makeStatement(tuple data, uint64 expirationTime, bytes32 refUID) public returns (bytes32)',
  'function collectPayment(bytes32 payment, bytes32 fulfillment) public returns (bool)',
]);

const erc20PaymentStatement = getContract({
  address: ERC20_PAYMENT_STATEMENT_ADDRESS,
  abi: statementAbi,
  publicClient,
  walletClient,
});

const stringResultStatement = getContract({
  address: STRING_RESULT_STATEMENT_ADDRESS,
  abi: statementAbi,
  publicClient,
  walletClient,
});

Setting Up the Trade

  1. Alice wants to pay 10 USDC for the uppercased version of the string "hello world".
  2. Alice creates an ERC20 payment statement:
async function createPaymentStatement() {
  const stringResultDemandAbi = await stringResultStatement.read.getDemandAbi();
  const paymentSchemaAbi = await erc20PaymentStatement.read.getSchemaAbi();

  const demandData = encodeAbiParameters(
    parseAbi([stringResultDemandAbi]),
    [{ query: 'hello world' }]
  );

  const paymentData = encodeAbiParameters(
    parseAbi([paymentSchemaAbi]),
    [{
      token: USDC_ADDRESS,
      amount: 10_000_000n, // 10 USDC (6 decimals)
      arbiter: STRING_RESULT_STATEMENT_ADDRESS,
      demand: demandData,
    }]
  );

  const paymentUID = await erc20PaymentStatement.write.makeStatement(
    [paymentData, 0n, '0x' + '0'.repeat(64)],
    { account: aliceAccount }
  );

  console.log('Payment statement created:', paymentUID);

  return paymentUID;
}

Fulfilling the Trade

  1. Bob sees Alice's offer and decides to fulfill it. Bob creates a string result statement:
async function createResultStatement(paymentUID: `0x${string}`) {
  const stringResultSchemaAbi = await stringResultStatement.read.getSchemaAbi();

  const resultData = encodeAbiParameters(
    parseAbi([stringResultSchemaAbi]),
    [{ result: 'HELLO WORLD' }]
  );

  const resultUID = await stringResultStatement.write.makeStatement(
    [resultData, paymentUID],
    { account: bobAccount }
  );

  console.log('Result statement created:', resultUID);

  return resultUID;
}

Completing the Exchange

  1. Bob can now complete the exchange by calling collectPayment on the ERC20PaymentStatement contract:
async function collectPayment(paymentUID: `0x${string}`, resultUID: `0x${string}`) {
  const success = await erc20PaymentStatement.write.collectPayment(
    [paymentUID, resultUID],
    { account: bobAccount }
  );

  console.log('Payment collected:', success);
}

Putting It All Together

async function trade() {
  const paymentUID = await createPaymentStatement();
  const resultUID = await createResultStatement(paymentUID);
  await collectPayment(paymentUID, resultUID);
}

trade().catch(console.error);

Validators

In our initial implementation of the exchange system, we used statement contracts to represent both offers and results. While this approach works for simple exchanges, it has several limitations when dealing with more complex scenarios:

  1. Coupling of Logic: The validation logic is tightly coupled with the statement creation logic. This makes it difficult to update or change the validation process without affecting existing statements.

  2. Limited Flexibility: Simple statement contracts can't easily handle complex validation scenarios, such as those requiring multiple steps, off-chain data, or time-delayed challenges.

  3. Scalability Issues: As validation logic becomes more complex, statement contracts can become bloated and expensive to deploy and interact with.

  4. Lack of Reusability: Validation logic implemented directly in statement contracts can't be easily reused across different types of exchanges.

  5. Difficulty in Upgrading: Once deployed, the validation logic in a statement contract can't be easily upgraded or modified.

To address these limitations, we introduce the concept of validator contracts. Validators act as an intermediary between offer statements and result statements, providing a flexible and extensible way to implement complex validation logic.

Validators: Bridging Statements and Arbiters

Validators extend the concept of arbiters, providing more complex and flexible validation mechanisms. They act as a bridge between offer statements (like ERC20PaymentStatement) and result statements (like StringResultStatement), allowing for sophisticated validation logic without complicating the base statement contracts.

Let's first recap the roles of statements and arbiters in our system to better understand how validators fit into the picture.

Recap: Statements and Arbiters

Statements represent a party's fulfillment of one side of an agreement. They have three main components:

  1. Initialization: Functions that set up the statement, often involving on-chain actions like token transfers.
  2. Checks: Functions that assess the validity of other statements or validations.
  3. Finalization: Functions that complete the agreement, often transferring assets or updating state.

Arbiters, on the other hand, are contracts that can check the validity of statements. Both statement contracts and validator contracts implement the IArbiter interface, allowing them to be used interchangeably in many contexts.

Validator Structure

While validators can vary significantly in their implementation, they generally include the following components:

  1. Initialization:

    • Functions to start the validation process, often creating a new attestation or record.
    • May involve setup steps like staking tokens or registering with external services.
  2. Validation Logic:

    • The core logic for checking the validity of a result.
    • Can range from simple on-chain checks to complex multi-step processes involving off-chain data.
  3. Finalization:

    • Functions to conclude the validation process.
    • May involve releasing stakes, updating state, or triggering further on-chain actions.
  4. Arbitration Interface:

    • Implementation of the IArbiter interface, allowing the validator to be used as an arbiter in statement contracts.

Types of Validators

Validators can implement various validation strategies, including:

  1. Optimistic Validation:

    • Results are considered valid unless challenged within a specific time frame.
    • Includes a challenge mechanism and often involves staking.
  2. Oracle-based Validation:

    • Relies on external data providers (oracles) to verify results.
    • May involve paying for the oracle service.
  3. Multi-step Validation:

    • Requires multiple validation steps or approvals from different parties.
  4. Threshold Validation:

    • Requires a certain number or percentage of validators to approve the result.
  5. Computation Validation:

    • Performs complex on-chain computations to verify results.
    • May use zero-knowledge proofs for efficiency.

By introducing validators, we can overcome the limitations of bare statements:

  1. Decoupling of Logic: Validation logic is separated from statement creation, allowing for more flexible and upgradeable systems.
  2. Increased Flexibility: Validators can implement complex validation scenarios that would be impractical in simple statement contracts.
  3. Improved Scalability: Complex logic is moved to separate contracts, keeping statement contracts lean and efficient.
  4. Enhanced Reusability: Validation logic in validator contracts can be easily reused across different types of exchanges.
  5. Easier Upgrades: Validator contracts can be designed to be upgradeable, allowing for improvements over time without affecting existing statements.

Let's implement an example of an optimistic validator to see how these concepts come together in practice.

Implementing an Optimistic Validator

We'll create an OptimisticStringValidator that implements optimistic validation with a challenge period. This validator will check if a string has been correctly capitalized, allowing for a period during which the result can be challenged.

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.26;

import {Attestation} from "@eas/Common.sol";
import {
    IEAS, AttestationRequest, AttestationRequestData, RevocationRequest, RevocationRequestData
} from "@eas/IEAS.sol";
import {ISchemaRegistry} from "@eas/ISchemaRegistry.sol";
import {IArbiter} from "./IArbiter.sol";
import {IValidator} from "./IValidator.sol";
import {StringResultStatement} from "./StringResultStatement.sol";

contract OptimisticStringValidator is IValidator {
    struct ValidationData {
        string query;
        uint64 mediationPeriod;
    }

    event ValidationStarted(bytes32 indexed validationUID, bytes32 indexed resultUID, string query);
    event MediationRequested(bytes32 indexed validationUID, bool success_);

    error InvalidValidationSchema();
    error MediationPeriodExpired();
    error InvalidStatementSchema();
    error StatementRevoked();
    error QueryMismatch();
    error MediationPeriodMismatch();

    string public constant SCHEMA_ABI = "string query, uint64 mediationPeriod";
    string public constant DEMAND_ABI = "string query, uint64 mediationPeriod";
    bool public constant IS_REVOCABLE = true;

    address public immutable BASE_STATEMENT;

    constructor(IEAS _eas, ISchemaRegistry _schemaRegistry, address _baseStatement)
        IValidator(_eas, _schemaRegistry, SCHEMA_ABI, IS_REVOCABLE)
    {
        BASE_STATEMENT = _baseStatement;
    }

    function startValidation(bytes32 resultUID, ValidationData calldata validationData)
        external
        returns (bytes32 validationUID_)
    {
        validationUID_ = eas.attest(
            AttestationRequest({
                schema: ATTESTATION_SCHEMA,
                data: AttestationRequestData({
                    recipient: msg.sender,
                    expirationTime: uint64(block.timestamp) + validationData.mediationPeriod,
                    revocable: true,
                    refUID: resultUID,
                    data: abi.encode(validationData),
                    value: 0
                })
            })
        );

        emit ValidationStarted(validationUID_, resultUID, validationData.query);
    }

    function mediate(bytes32 validationUID) external returns (bool success_) {
        Attestation memory validation = eas.getAttestation(validationUID);
        if (validation.schema != ATTESTATION_SCHEMA) revert InvalidValidationSchema();

        ValidationData memory data = abi.decode(validation.data, (ValidationData));
        if (block.timestamp > validation.time + data.mediationPeriod) revert MediationPeriodExpired();

        Attestation memory resultAttestation = eas.getAttestation(validation.refUID);
        StringResultStatement.StatementData memory resultData =
            abi.decode(resultAttestation.data, (StringResultStatement.StatementData));
        success_ = _isCapitalized(data.query, resultData.result);

        if (!success_) {
            eas.revoke(
                RevocationRequest({
                    schema: ATTESTATION_SCHEMA,
                    data: RevocationRequestData({uid: validationUID, value: 0})
                })
            );
        }

        emit MediationRequested(validationUID, success_);
    }

    function checkStatement(Attestation memory statement, bytes memory demand, bytes32 counteroffer)
        public
        view
        override
        returns (bool)
    {
        if (statement.schema != ATTESTATION_SCHEMA) revert InvalidStatementSchema();
        if (statement.revocationTime != 0) revert StatementRevoked();

        ValidationData memory demandData = abi.decode(demand, (ValidationData));
        ValidationData memory statementData = abi.decode(statement.data, (ValidationData));

        if (keccak256(bytes(statementData.query)) != keccak256(bytes(demandData.query))) revert QueryMismatch();
        if (statementData.mediationPeriod != demandData.mediationPeriod) revert MediationPeriodMismatch();

        if (block.timestamp <= statement.time + statementData.mediationPeriod) {
            return false;
        }

        return IArbiter(BASE_STATEMENT).checkStatement(
            eas.getAttestation(statement.refUID),
            abi.encode(StringResultStatement.DemandData({query: statementData.query})),
            counteroffer
        );
    }

    function _isCapitalized(string memory query, string memory result) internal pure returns (bool) {
        bytes memory queryBytes = bytes(query);
        bytes memory resultBytes = bytes(result);

        if (queryBytes.length != resultBytes.length) {
            return false;
        }

        for (uint256 i = 0; i < queryBytes.length; i++) {
            if (queryBytes[i] >= 0x61 && queryBytes[i] <= 0x7A) {
                // If lowercase, it should be capitalized in the result
                if (uint8(resultBytes[i]) != uint8(queryBytes[i]) - 32) {
                    return false;
                }
            } else {
                // If not lowercase, it should remain the same
                if (resultBytes[i] != queryBytes[i]) {
                    return false;
                }
            }
        }

        return true;
    }

    function getSchemaAbi() public pure override returns (string memory) {
        return SCHEMA_ABI;
    }

    function getDemandAbi() public pure override returns (string memory) {
        return DEMAND_ABI;
    }
}

This validator demonstrates the key components we discussed:

  1. Initialization: The startValidation function initializes the validation process.
  2. Validation Logic: The checkStatement function implements the core validation logic.
  3. Finalization: The mediate function allows for challenges during the mediation period.
  4. Arbitration Interface: The contract implements IStatement, which extends IArbiter.

Using Validators in the Exchange System

To use a validator in our exchange system:

  1. Deploy the validator contract.
  2. When creating an offer statement (e.g., ERC20PaymentStatement), specify the validator as the arbiter:
ERC20PaymentStatement.StatementData memory paymentData = ERC20PaymentStatement.StatementData({
    token: address(USDC),
    amount: 10 * 10**6,
    arbiter: address(optimisticStringValidator),
    demand: abi.encode(OptimisticStringValidator.ValidationData({
        query: "hello world",
        mediationPeriod: 1 days
    }))
});

bytes32 paymentUID = erc20PaymentStatement.makeStatement(paymentData, 0, bytes32(0));
  1. When submitting a result, start the validation process:
bytes32 resultUID = stringResultStatement.makeStatement(resultData, paymentUID);
bytes32 validationUID = optimisticStringValidator.startValidation(resultUID, "hello world", 1 days);
  1. To complete the exchange, use the validation UID when collecting the payment:
erc20PaymentStatement.collectPayment(paymentUID, validationUID);

Beyond Optimistic Validation

While we've implemented an optimistic validator here, other types of validators might work differently. For example:

  • An oracle-based validator might require payment to cover the cost of off-chain verification.
  • A multi-step validator might require approvals from multiple parties before considering a result valid.
  • A computation validator might perform complex on-chain calculations or verify zero-knowledge proofs.

The flexibility of this system allows for a wide range of validation strategies to be implemented and used interchangeably, depending on the specific requirements of each exchange.

By separating validators from base statements, we've created a modular and extensible system that can adapt to various validation needs while keeping the core exchange logic simple and consistent.

See the final contracts at

Hello World - Tokens for Tokens

We would like to trade one ERC20PaymentStatement for another. Naively, we might try to specify the same contract as the arbiter when creating a statement, but we run into an issue.

Let's say Alice wants to pay 100 * 10 ** 18 of TKA for 200 * 10 ** 18 of TKB.

tka = new MockERC20("Token A", "TKA");
tkb = new MockERC20("Token B", "TKB");

paymentStatement = new ERC20PaymentStatement(eas, schemaRegistry);
ERC20PaymentStatement.StatementData memory paymentData =
	ERC20PaymentStatement.StatementData({
        token: address(tka),
        amount: 100 * 10 ** 18,
        arbiter: address(paymentStatement),
        demand: abi.encode(ERC20PaymentStatement.StatementData({
	        token: address(tkb),
	        amount: 200 * 10 ** 18,
	        arbiter: address(paymentStatement),
	        demand: ???
        }))
    });

Alice wants to demand that Bob is demanding the statement she's making right now, because she would like to use the attestation she's making to claim Bob's token payment (see Hello World - Tokens for Strings for more detail on how this works). But this leads to an infinitely recursive definition - she can't fully specify the demand she expects Bob to make, because it has to refer to her statement, which has to refer to Bob's statement, ad infinitum.

System Requirements

First, let's clarify the requirements of the system we'd like.

  • Alice wants to make a payment deposit in TKA, demanding an amount of TKB
  • Bob can claim Alice's payment if he makes a payment deposit for the amount of TKB that Alice set as her price, which Alice is able to claim
  • If Bob can claim Alice's payment, then Alice and only Alice can claim Bob's payment
  • The order in which Alice and Bob collect the other's payment shouldn't matter
  • Each payment should only be able to be collected once

There are a few things about ERC20PaymentStatement that we have to be careful about.

  • Payment attestations are revoked after they're collected, which means that checkStatement still return false when called on collected payments, since checkStatement calls _checkIntrinsic, which includes a check to if the attestation is revoked.
  • The attestation used to collect a payment is not revoked after collecting the payment. The same payment can't be collected more than once, since the payment is revoked after collection, but the same attestation could be used to collect multiple payments.

Note that Statements and Validations contracts only have authority over attestations they produce themselves, which means that contracts can't revoke attestations made by other contracts. If we want to implement a mechanism where a certain attestation can only be used once before being revoked, this has to be implemented as a function on the contract making the use-once attestation, where its implementation of checkStatement only returns valid while the attestation is being used from that special function, which revokes the attestation afterwards.

However, we don't actually need such a mechanism for this case, since payment attestations being revoked after collection already prevents double spending as long as we can ensure that the attestations able to collect a payment are actually eligible to. In other words, we only have to ensure that

  • Bob can claim Alice's payment if he makes a payment deposit for the amount of TKB that Alice set as her price, which Alice is able to claim
  • If Bob can claim Alice's payment, then Alice and only Alice can claim Bob's payment

The case of requiring a specific existing attestation as the fulfillment to your payment attestation (i.e. paying for a specific already-existing attestation) is common enough to have a special case in ERC20PaymentStatement.

    function _isValidFulfillment(
        Attestation memory payment,
        Attestation memory fulfillment,
        StatementData memory paymentData
    ) internal view returns (bool) {
        // Special case: If the payment references this fulfillment, consider it valid
                if (payment.refUID != 0) return payment.refUID == fulfillment.uid;


        // Regular case: check using the arbiter
        return IArbiter(paymentData.arbiter).checkStatement(fulfillment, paymentData.demand, payment.uid);
    }

Before using the demanded arbiter to check the fulfillment attestation, we check if if the payment attestation explicitly references an attestation in its refUID. If so, we return true if the fulfillment is the explicitly specified one and false otherwise. This ensures that Alice and only Alice is able to collect Bob's payment, even after Alice's attestation has been revoked.

PaymentFulfillmentValidator

Now we just need to ensure that Bob is able to collect Alice's payment. We can resolve the infinite mutual recursion mentioned at the beginning of this discussion by creating a new Validations contract which Alice specifies as the arbiter to her payment, rather than the ERC20PaymentStatement itself. Like the statement contract, this validator will check for a token address and amount, but will check for an explicit refUID rather than a demand. In other words, a payment attestation of Alice's demanded token and amount, which explicitly specifies Alice's payment attestation as its fulfillment, will be able to collect Alice's payment.

    contract ERC20PaymentFulfillmentValidator is IValidator {
    struct ValidationData {
        address token;
        uint256 amount;
        bytes32 fulfilling;
    }

    struct DemandData {
        address token;
        uint256 amount;
    }

    event ValidationCreated(bytes32 indexed validationUID, bytes32 indexed paymentUID);

    error InvalidStatement();
    error InvalidValidation();

    string public constant SCHEMA_ABI = "address token, uint256 amount, bytes32 fulfilling";
    string public constant DEMAND_ABI = "address token, uint256 amount";
    bool public constant IS_REVOCABLE = true;

    ERC20PaymentStatement public immutable paymentStatement;

    constructor(IEAS _eas, ISchemaRegistry _schemaRegistry, ERC20PaymentStatement _baseStatement)
        IValidator(_eas, _schemaRegistry, SCHEMA_ABI, IS_REVOCABLE)
    {
        paymentStatement = _baseStatement;
    }

    function createValidation(bytes32 paymentUID, ValidationData calldata validationData)
        external
        returns (bytes32 validationUID)
    {
        Attestation memory paymentAttestation = eas.getAttestation(paymentUID);
        if (paymentAttestation.schema != paymentStatement.ATTESTATION_SCHEMA()) revert InvalidStatement();
        if (paymentAttestation.revocationTime != 0) revert InvalidStatement();
        if (paymentAttestation.recipient != msg.sender) revert InvalidStatement();

        if (paymentAttestation.refUID != validationData.fulfilling) revert InvalidValidation();

        if (
            !paymentStatement.checkStatement(
                paymentAttestation,
                abi.encode(
                    ERC20PaymentStatement.StatementData({
                        token: validationData.token,
                        amount: validationData.amount,
                        arbiter: address(0),
                        demand: ""
                    })
                ),
                0
            )
        ) revert InvalidStatement();

        validationUID = eas.attest(
            AttestationRequest({
                schema: ATTESTATION_SCHEMA,
                data: AttestationRequestData({
                    recipient: msg.sender,
                    expirationTime: paymentAttestation.expirationTime,
                    revocable: paymentAttestation.revocable,
                    refUID: paymentUID,
                    data: abi.encode(validationData),
                    value: 0
                })
            })
        );

        emit ValidationCreated(validationUID, paymentUID);
    }

    function checkStatement(Attestation memory statement, bytes memory demand, bytes32 counteroffer)
        public
        view
        override
        returns (bool)
    {
        if (!_checkIntrinsic(statement)) return false;

        ValidationData memory validationData = abi.decode(statement.data, (ValidationData));
        DemandData memory demandData = abi.decode(demand, (DemandData));

        return validationData.fulfilling == counteroffer && validationData.token == demandData.token
            && validationData.amount >= demandData.amount;
    }

    function getSchemaAbi() public pure override returns (string memory) {
        return SCHEMA_ABI;
    }

    function getDemandAbi() public pure override returns (string memory) {
        return DEMAND_ABI;
    }
}

Notice a few points

  • createValidation only creates an attestation if the conditions are valid, and does so immediately. checkStatement just checks that the validation attestation's properties match the demand's properties, without looking at the underlying statement, since the necessary checks were already done to create the validation.
  • createValidation uses the underlying statement's implementation of checkStatement to validate the token type and amount, passing in 0 for the demanded demand, since arbiter and demand should be unused when specifying an explicit fulfillment.

In practice, Bob should bundle creating his payment attestation, creating his validation attestation, and collecting Alice's payment all into a single transaction, because we haven't actually ensured that only Bob specifically can collect Alice's payment. Somebody else could meet Alice's demands before Bob, and Alice would still be able to collect Bob's payment as well as whoever else fulfilled her demand. Bundling everything into one transaction ensures that Bob never creates a payment that Alice can collect unless he actually collects Alice's payment.

See the final contracts at

Tokens for Docker

We would like to trade ERC20 tokens for Docker jobs. The simplest representation of a Docker job is actually quite similar to the StringResultStatement we made in Hello World - Tokens for Strings. We can represent both Docker queries and results as single strings - note that the interpretation of these strings happens off-chain, or on-chain but outside of the base DockerResultStatement contract if Validations are implemented. For example,

  • The query could be
    1. A Dockerfile as a raw string
    2. Some intermediate JSON/YAML job spec as a raw string
    3. A URL/IPFS CID pointing to a Dockerfile or intermediate job spec
    4. A URL/IPFS CID pointing to a folder archive containing a Dockerfile and build dependencies
  • The result could be
    1. The contents of stdout after building and running the Docker container, as a raw string
    2. A URL/IPFS CID pointing to the contents of stdout after running the container
    3. A URL/IPFS CID pointing to a folder archive containing the contents of a special volume mounted to the container, plus special files for stdout and stderr

To keep the base statement implementation general enough to accommodate all these possible interpretations, we'll only have checkStatement validate whether the statement was intended to fulfill a particular counteroffer. Further validation checks, which could rely on a more specific interpretation of the job and query strings, should be implemented as Validations.

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.26;

import {Attestation} from "@eas/Common.sol";
import {IEAS, AttestationRequest, AttestationRequestData} from "@eas/IEAS.sol";
import {ISchemaRegistry} from "@eas/ISchemaRegistry.sol";
import {IStatement} from "../IStatement.sol";

contract DockerResultStatement is IStatement {
    struct StatementData {
        string resultCID;
    }

    struct DemandData {
        string queryCID;
    }

    error InvalidResultAttestation();
    error InvalidDemand();

    string public constant SCHEMA_ABI = "string resultCID";
    string public constant DEMAND_ABI = "string queryCID";
    bool public constant IS_REVOCABLE = false;

    constructor(IEAS _eas, ISchemaRegistry _schemaRegistry)
        IStatement(_eas, _schemaRegistry, SCHEMA_ABI, IS_REVOCABLE)
    {}

    function makeStatement(StatementData calldata data, bytes32 refUID) public returns (bytes32) {
        return eas.attest(
            AttestationRequest({
                schema: ATTESTATION_SCHEMA,
                data: AttestationRequestData({
                    recipient: msg.sender,
                    expirationTime: 0,
                    revocable: false,
                    refUID: refUID,
                    data: abi.encode(data),
                    value: 0
                })
            })
        );
    }

    function checkStatement(
        Attestation memory statement,
        bytes memory demand, /* (string queryCID) */
        bytes32 counteroffer
    ) public view override returns (bool) {
        if (!_checkIntrinsic(statement)) return false;

        // Check if the statement is intended to fulfill the specific counteroffer
        if (statement.refUID != bytes32(0) && statement.refUID != counteroffer) return false;

        StatementData memory result = abi.decode(statement.data, (StatementData));
        DemandData memory demandData = abi.decode(demand, (DemandData));

        return true;
    }

    function getSchemaAbi() public pure override returns (string memory) {
        return SCHEMA_ABI;
    }

    function getDemandAbi() public pure override returns (string memory) {
        return DEMAND_ABI;
    }
}

Buyers and sellers using this statement as one side of a trade will have to agree on the interpretation of resultCID and queryCID. This negotiation could be at the level of a marketplace where all deals use a particular interpretation, or at the level of an off-chain negotiation schema where buyers and sellers communicate how the result and query are to be interpreted in addition to the statement schema fields themselves.

Apiary is an implementation of a simple marketplace where queryCID is interpreted as a Lighthouse IPFS CID pointing to a Dockerfile and resultCID is a CID pointing to the results of stdout after building and running that Dockerfile.

Notice the layers of abstraction. The Rust bindings to ERC20PaymentStatement and DockerResultStatement in interaction with each other depend on these particular statements - for example, trading an ERC721 token rather than an ERC20 token for a Docker job would require a new contract and new bindings - but are still agnostic to the interpretation of the result and query in DockerResultStatement.

The interpretation of the result and query strings as Lighthouse IPFS CIDS referring to a Dockerfile and a string output from stdout happens in the buyer and seller Agents, which can import the smart contract client opaquely. Neither the smart contracts nor the Rust SDK would have to be changed to reinterpret the result and query CIDS - say as archives, or Nix flakes.

See the final contracts at

and the example end-to-end market implementation at

Compute Marketplace

coming soon

Cooperative MapReduce

coming soon

Decentralized SaaS Provision

coming soon

Decentralized Vector Database

coming soon

Compute Marketplace

In this section we demonstrate a possible use of schemes and agents by building a compute marketplace where users' hardware is associated with Agents autonomously negotiating and scheduling compute tasks.

In this marketplace, each agent embodies a node that can accept or reject compute jobs based on a defined policy. The fundamental structure of the main modules and their interfaces are constrained, but the actual contents of schemes and agents depend on the specificities of the marketplace and user design choices. In this tutorial, we provide an architecture for the internal logic of an agent.

Market Definition through Agents and Schemes

Following our market definition, agents interact with each other through schemes, following the functional model represented by (message, context) => message.

In this model, an agent receives a message within a certain context and is responsible for responding with a subsequent message updating the negotiation state. In addition to the current scheme state, the context includes, for example, the agent's role (buyer or seller), previous negotiation messages, on-chain states.

In a compute marketplace, in particular, we can imagine a scenario where a resource provider agent (Agent A) offers compute power, and a client agent (Agent B) seeks to utilize that compute power to perform a predefined task:

  • Message: Agent B sends a request message, including details like the amount of compute power required, duration constraints, proposed price and validation scheme.

  • Context: The context includes Agent A’s local states and global states (see below for a detailed discussion). It also includes the current state of the negotiation, such as previous offers or counter-offers, and parallel negotiations associated with other client and resource provider agents.

  • Response Message: Agent A receives the request message and, combining it with the context according to a specific policy, evaluates whether it can fulfill the request profitably and how: it might send a counter-offer modifying the values in the original message, accept or reject the deal. This evaluation involves both local states and global states.

  • Validation and Finalization: If Agent B accepts the offer, it sends an accept message, recording an on-chain attestation, triggering Agent A to perform a set of actions, such as recording an on-chain commitment and starting the compute job.

Sequential Decision-making Primitives

In the implementation of Agents, interacting with the state machine defined by the Scheme, a natural design choice following the usage of Sequential Decision-making Primitives. Given the absence of an explicit model of the system, this perspective overlaps with the field of Multi-Agent Reinforcement Learning (MARL).

Nevertheless, Agents (both clients and resource providers) interact with an Environment receiving and submitting information. While the bulk of observations are about reading the state machine, other observations about both on-chain and off-chain states can be used, generalizing the definition of Environment. In the same way, while the main output of Agents is about updating the state machine, other writing tasks exists, enabling agents to interact and modify other aspects of the Environment.

Local States

The local state for each agent includes its hardware specifications, such as CPU, GPU, and RAM. These states influence the agent’s capacity to accept compute tasks and formulate offers. Hardware profiling tools enable agents to know their local states and inform their policies accordingly.

Every agent hardware specifications may limit the state space size. For example, some IoT actors would only be able to store and act based on on-chain data. For the same reason, some agents may be unable to perform certain tasks. In other words, each agent has different constraints on both their state space and action space.

Global States

A set of environmental variables may include:

  • L1 and L2 tokens price. The dynamics of the protocol, being based on smart contracts and EVM technology, may be driven by the state of the L1 (Ethereum) and L2 protocol. One shallow proxy for this is the point-in-time price of the two protocols. A deeper understanding of the protocol dynamics could inform the modeling of payment token prices forecasting, informing the optimal behaviour of agents in this blockchain-based marketplace, informing in turn the agent policy.

  • Gas Fees: because of the need to record the outcome of a negotiation on the blockchain, the point-in-time gas fees of the protocol blockchain is necessary to build policies: the profitability of a job is a function of the (current) transaction costs. Here we refer to the gas fees of the L2 associated with CoopHive; as per prices, the gas fee time series of Ethereum may be relevant in modeling the dynamics of costs for agents recording states on-chain in the interaction with the protocol.

  • Electricity costs. This is a space-time dependent variable defining the cost of electricity in the world. Agents, aware of their own location, are interested in measuring the point-in-time field of the cost of electricity to understand the hedge they may have against other potential agents in different locations. This means that agents could have a module solely focused on the modeling, forecasting and uncertainty quantification of electricity prices to enhance the state space.

  • On-chain states: the history of credible commitments recorded on chain is a valuable information to inform policies.

Schemes

A specific instantiation of a scheme, for this marketplace, can look like:

  • request: A client agent signals a need for compute resources, specifying some terms.
  • offer: A provider agent responds with available resources and counter-terms.
  • accept: Agreement to terms, triggering task commencement.
  • attestation: Verification that a task has been initiated or completed.
  • terminate: Signaling the end of a negotiation, either due to task completion, timeout, or error.

Schemes-dependent and Agent-dependent Actions

A straightforward example of actions is the verification of a successful on-chain attestation linked in a message and the sequential writing of the following attestation, linked to the first one. In fact, together with a public key necessarily recorded on-chain, agents are associated with a private key used to sign messages as well.

We here stress the possible modularity of such Action, within the Agent Policy; in some marketplaces/schemes definition, these kinds of actions could be responsibility of the schemes' client, for example synchronously checking the successful recording of every on-chain state linked to any message within the scheme. While this would sacrifice flexibility in terms of agent policy modularity, it would provide a more solid and simplified infrastructure for policy development.

To give an example, upon receiving a message communicating the possibility for a task to start, an agent might want to verify on-chain that the other party has locked the required collateral, before proceeding with task execution. In this case, only once verified, the agent updates the scheme state by writing an attestation message, recording the actual commencement of the compute task. In case of missing collateral, a scheme-compatible collateral_missing message may be sent instead, warning the client about the pending state of the negotiation.

Objective

Regardless of the specific marketplace and of the nature of the scheme, agents are continuously spending computational resources in being able to perform Negotiation and Scheduling operations. Particularly in the context of exchanging arbitrary bundles of assets, in doing this Agents have to deal with risk. A trivial example of a source of risk is the volatility of the exchanged tokens with respect to a risk-free asset. Because of this, agents are more similar to portfolio managers than it may seem, given they can lose capital. This can happen because of the inherent risk in the tokens held buy an agent or because of the systematic losses associated with a poorly performing Negotiation strategy. It is therefore natural to assess agents performance (and even guide their training in a data-driven framework) using risk adjusted metrics.

As discussed in Recent Advances in Reinforcement Learning in Finance, possible metrics include:

  • Cumulative Return;
  • Sharpe Ratio;
  • Sortino Ratio;
  • Differential Sharpe Ratio.

Whitepaper

coming soon