Skip to main content

Writing Arbiters (pt 1 - On-chain Arbiters)

Arbiters are smart contracts that implement the IArbiter interface to validate whether an obligation (attestation) satisfies specific requirements. They act as on-chain judges that determine if conditions have been met for releasing escrowed assets or fulfilling other blockchain operations.

Single-transaction arbiters

Single-transaction arbiters perform validation synchronously within a single transaction. They check if an obligation meets the specified demands immediately when called.

Example 1: String Capitalizer - Synchronous On-chain Computation

This example demonstrates how to write an arbiter that performs synchronous on-chain computations to validate data transformations. It works with a generic data-holding obligation format (StringObligation).

Pattern illustrated: Validating computational results where both the input (demand) and output (obligation) are stored on-chain, and the validation logic can be executed deterministically.

contract StringCapitalizer is IArbiter {
using ArbiterUtils for Attestation;

struct DemandData {
string query; // Input data
}

function checkObligation(
Attestation memory obligation,
bytes memory demand,
bytes32 counteroffer
) external view override returns (bool) {
// Step 1: Validate attestation integrity
if (!obligation._checkIntrinsic()) return false;

// Step 2: Check counteroffer reference if provided
if (counteroffer != bytes32(0) && obligation.refUID != counteroffer) {
return false;
}

// Step 3: Decode both demand and obligation data
DemandData memory demandData = abi.decode(demand, (DemandData));
StringObligation.ObligationData memory obligationData =
abi.decode(obligation.data, (StringObligation.ObligationData));

// Step 4: Apply validation logic
return _isCapitalized(demandData.query, obligationData.item);
}

function _isCapitalized(
string memory query,
string memory result
) internal pure returns (bool) {
// Deterministic computation to validate transformation
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++) {
uint8 queryChar = uint8(queryBytes[i]);
uint8 resultChar = uint8(resultBytes[i]);

if (queryChar >= 0x61 && queryChar <= 0x7A) {
// Lowercase should be capitalized
if (resultChar != queryChar - 32) return false;
} else {
// Non-lowercase should remain unchanged
if (resultChar != queryChar) return false;
}
}

return true;
}
}

When to use this pattern:

  • Validating mathematical computations
  • Checking data transformations (encoding, formatting, etc.)
  • Verifying algorithmic solutions
  • Any scenario where validation logic is purely computational

Example 2: Game Winner - Conditional Escrow for External Attestations

This example demonstrates how to write an arbiter that creates a conditional escrow system for EAS attestations originating from external sources (like a game contract).

Pattern illustrated: Validating attestations from trusted external systems, where the arbiter acts as a bridge between existing attestation infrastructure and escrow mechanisms.

contract GameWinner is IArbiter {
using ArbiterUtils for Attestation;

IEAS public immutable eas;
bytes32 public immutable GAME_WINNER_SCHEMA;
address public immutable trustedGameContract;

struct GameWinnerData {
bytes32 gameId;
address winner;
uint256 timestamp;
uint256 score;
}

struct ClaimDemand {
bytes32 gameId;
uint256 minScore;
uint256 validAfter;
}

function checkObligation(
Attestation memory obligation,
bytes memory demand,
bytes32 counteroffer
) external view override returns (bool) {
// Step 1: Validate attestation integrity
if (!obligation._checkIntrinsic()) return false;

// Step 2: Verify attestation source and type
if (obligation.schema != GAME_WINNER_SCHEMA) return false;
if (obligation.attester != trustedGameContract) return false;

// Step 3: Check counteroffer reference if provided
if (counteroffer != bytes32(0) && obligation.refUID != counteroffer) {
return false;
}

// Step 4: Decode and validate against demand criteria
GameWinnerData memory winnerData =
abi.decode(obligation.data, (GameWinnerData));
ClaimDemand memory claimDemand =
abi.decode(demand, (ClaimDemand));

// Step 5: Apply conditional logic
if (winnerData.gameId != claimDemand.gameId) return false;
if (winnerData.score < claimDemand.minScore) return false;
if (winnerData.timestamp < claimDemand.validAfter) return false;

// Step 6: Verify attestation ownership
if (obligation.recipient != winnerData.winner) return false;

return true;
}
}

When to use this pattern:

  • Bridging external attestation systems with escrow contracts
  • Creating conditional release mechanisms based on third-party validations
  • Implementing trust-based validation where specific attesters are authorized
  • Building on top of existing EAS infrastructure

Common Implementation Pattern

All single-transaction arbiters follow this structure:

  1. Define data structures: Create structs for demand parameters and expected obligation data
  2. Implement checkObligation: The main validation function that returns a boolean
  3. Validate attestation integrity: Use ArbiterUtils to check expiration and revocation
  4. Verify schema and source (if needed): Ensure attestations match expected format and origin
  5. Decode data: Extract information from both obligation and demand
  6. Apply validation logic: Implement the specific rules for your use case
  7. Return verdict: Simple pass/fail boolean

The key difference between examples:

  • Computational validation (StringCapitalizer): Demonstrates algorithmic verification of data transformations
  • Attestation bridging (GameWinner): Demonstrates verification of external attestations with conditional logic

Asynchronous arbiters

Asynchronous arbiters handle validation that cannot be completed in a single transaction. They maintain internal state across multiple transactions and only return true from checkObligation once a multi-step process has been completed.

Example 3: MajorityVoteArbiter - Multi-transaction State Accumulation

This example demonstrates how to write an arbiter that accumulates state over multiple transactions before making a validation decision. It uses voting as an example of a process that requires multiple interactions before completion.

Pattern illustrated: Multi-transaction validation where the arbiter stores intermediate state and only approves obligations after a defined process reaches completion. The validation logic spans multiple transactions with state persistence between them.

contract MajorityVoteArbiter is IArbiter {
using ArbiterUtils for Attestation;

struct DemandData {
address[] voters; // List of authorized voters
uint256 quorum; // Minimum votes required for approval
bytes data; // Additional context for voters
}

struct VoteStatus {
uint256 yesVotes;
uint256 noVotes;
mapping(address => bool) hasVoted;
mapping(address => bool) vote;
}

IEAS public immutable eas;
mapping(bytes32 => VoteStatus) public voteStatuses;

// Events for transparency
event VoteCast(
bytes32 indexed obligation,
address indexed voter,
bool vote,
uint256 yesVotes,
uint256 noVotes
);

event ArbitrationComplete(
bytes32 indexed obligation,
bool decision,
uint256 yesVotes,
uint256 noVotes
);

function castVote(
bytes32 obligation,
bool vote,
bytes calldata demandData
) external {
DemandData memory demand = abi.decode(demandData, (DemandData));

// Verify voter authorization
bool isAuthorized = false;
for (uint256 i = 0; i < demand.voters.length; i++) {
if (demand.voters[i] == msg.sender) {
isAuthorized = true;
break;
}
}
if (!isAuthorized) revert UnauthorizedVoter();

VoteStatus storage status = voteStatuses[obligation];

// Prevent double voting
if (status.hasVoted[msg.sender]) revert AlreadyVoted();

// Record vote
status.hasVoted[msg.sender] = true;
status.vote[msg.sender] = vote;

if (vote) {
status.yesVotes++;
} else {
status.noVotes++;
}

emit VoteCast(obligation, msg.sender, vote, status.yesVotes, status.noVotes);

// Check if decision is reached
if (status.yesVotes >= demand.quorum) {
emit ArbitrationComplete(obligation, true, status.yesVotes, status.noVotes);
} else if (status.noVotes > demand.voters.length - demand.quorum) {
// Quorum becomes impossible
emit ArbitrationComplete(obligation, false, status.yesVotes, status.noVotes);
}
}

function checkObligation(
Attestation memory obligation,
bytes memory demand,
bytes32 /* counteroffer */
) public view override returns (bool) {
// Validate attestation integrity
if (!obligation._checkIntrinsic()) return false;

DemandData memory demandData = abi.decode(demand, (DemandData));
VoteStatus storage status = voteStatuses[obligation.uid];

// Return true if quorum reached with yes votes
return status.yesVotes >= demandData.quorum;
}
}

Key Implementation Aspects:

  1. State Storage: The contract maintains a mapping (voteStatuses) that persists state between transactions
  2. Process Accumulation: Multiple transactions (castVote) modify the stored state incrementally
  3. Completion Detection: The arbiter tracks when the process reaches a deterministic outcome
  4. Deferred Validation: checkObligation returns false until the process completes, then returns the final decision

When to use this pattern:

  • Validation that requires waiting for external events or timeouts
  • Processes that need multiple parties to submit data before validation
  • Scenarios where validation depends on accumulating evidence over time
  • Any validation that cannot be deterministically computed in a single transaction

Pattern Variations:

  • Time-based: Wait for a specific time period before allowing validation
  • Oracle-based: Wait for external data submission from trusted sources
  • Challenge-based: Allow a challenge period before finalizing validation
  • Threshold-based: Accumulate metrics/scores until a threshold is reached

Alternative Implementation Note: Rather than implementing complex multi-transaction logic directly in an arbiter, you could also create a separate contract that manages the multi-step process and then submits a final result to TrustedOracleArbiter. This separation of concerns can make the system more modular and reusable.

Implementing SDK extensions

When writing custom arbiters, you'll want to create corresponding SDK extensions to make them easy to use from client applications. The Alkahest SDKs (TypeScript, Rust, and Python) use a hierarchical module architecture.

TypeScript SDK Extensions

The TypeScript SDK uses a factory pattern with hierarchical namespacing. To add support for a new arbiter:

  1. Create the arbiter module with static encode/decode functions and a client factory:
// alkahest-ts/src/clients/arbiters/custom/majorityVote.ts
import { decodeAbiParameters, encodeAbiParameters, getAbiItem, parseAbiItem } from "viem";
import { abi as majorityVoteArbiterAbi } from "../../../contracts/arbiters/MajorityVoteArbiter";
import type { ChainAddresses } from "../../../types";
import type { ViemClient } from "../../../utils";

// Extract DemandData type from contract ABI
const decodeDemandFunction = getAbiItem({
abi: majorityVoteArbiterAbi.abi,
name: "decodeDemandData",
});
const demandDataType = decodeDemandFunction.outputs[0];

export type MajorityVoteDemandData = {
voters: `0x${string}`[];
quorum: bigint;
data: `0x${string}`;
};

// Static encode/decode functions (usable without client)
export const encodeDemand = (demand: MajorityVoteDemandData): `0x${string}` => {
return encodeAbiParameters([demandDataType], [demand]);
};

export const decodeDemand = (demandData: `0x${string}`): MajorityVoteDemandData => {
return decodeAbiParameters([demandDataType], demandData)[0] as MajorityVoteDemandData;
};

// Client factory for instance methods
export const makeMajorityVoteClient = (viemClient: ViemClient, addresses: ChainAddresses) => {
const voteEvent = parseAbiItem(
"event VoteCast(bytes32 indexed obligation, address indexed voter, bool vote, uint256 yesVotes, uint256 noVotes)"
);

return {
encodeDemand,
decodeDemand,

castVote: async (obligation: `0x${string}`, vote: boolean, demandData: `0x${string}`) => {
return await viemClient.writeContract({
address: addresses.majorityVoteArbiter,
abi: majorityVoteArbiterAbi.abi,
functionName: "castVote",
args: [obligation, vote, demandData],
});
},

getVoteStatus: async (obligation: `0x${string}`) => {
const status = await viemClient.readContract({
address: addresses.majorityVoteArbiter,
abi: majorityVoteArbiterAbi.abi,
functionName: "voteStatuses",
args: [obligation],
});
return { yesVotes: status[0], noVotes: status[1] };
},

waitForQuorum: async (obligation: `0x${string}`, quorum: bigint) => {
// Watch for VoteCast events until quorum reached
return new Promise((resolve) => {
const unwatch = viemClient.watchEvent({
address: addresses.majorityVoteArbiter,
event: voteEvent,
args: { obligation },
onLogs: (logs) => {
const latest = logs[logs.length - 1];
if (latest && BigInt(latest.args.yesVotes!) >= quorum) {
unwatch();
resolve(latest.args);
}
},
});
});
},
};
};
  1. Integrate into the hierarchical client by adding to a parent module:
// alkahest-ts/src/clients/arbiters/custom/index.ts
import type { ChainAddresses } from "../../../types";
import type { ViemClient } from "../../../utils";
import { makeMajorityVoteClient } from "./majorityVote";

export const makeCustomArbitersClient = (viemClient: ViemClient, addresses: ChainAddresses) => ({
majorityVote: makeMajorityVoteClient(viemClient, addresses),
});

// Re-export static functions and types
export { encodeDemand as encodeMajorityVoteDemand, decodeDemand as decodeMajorityVoteDemand } from "./majorityVote";
export type { MajorityVoteDemandData } from "./majorityVote";

Rust SDK Extensions

The Rust SDK uses a module-based architecture with macros for ABI conversions:

  1. Define the module with DemandData types and client methods:
// alkahest-rs/src/clients/arbiters/majority_vote.rs
use crate::{
contracts,
extensions::ContractModule,
impl_abi_conversions,
types::{SharedPublicProvider, SharedWalletProvider},
};
use alloy::{
primitives::{Address, Bytes, U256},
signers::local::PrivateKeySigner,
};

// Implement automatic ABI encoding/decoding via .into() conversions
impl_abi_conversions!(contracts::arbiters::MajorityVoteArbiter::DemandData);

pub struct MajorityVoteAddresses {
pub majority_vote_arbiter: Address,
}

#[derive(Clone)]
pub struct MajorityVoteModule {
signer: PrivateKeySigner,
public_provider: SharedPublicProvider,
wallet_provider: SharedWalletProvider,
pub addresses: MajorityVoteAddresses,
}

impl MajorityVoteModule {
pub fn new(
signer: PrivateKeySigner,
public_provider: SharedPublicProvider,
wallet_provider: SharedWalletProvider,
addresses: MajorityVoteAddresses,
) -> Self {
Self { signer, public_provider, wallet_provider, addresses }
}

/// Cast a vote for an obligation
pub async fn cast_vote(
&self,
obligation: alloy::primitives::FixedBytes<32>,
vote: bool,
demand_data: Bytes,
) -> eyre::Result<alloy::rpc::types::TransactionReceipt> {
let contract = contracts::arbiters::MajorityVoteArbiter::new(
self.addresses.majority_vote_arbiter,
self.wallet_provider.clone(),
);

let tx = contract.castVote(obligation, vote, demand_data);
let pending = tx.send().await?;
let receipt = pending.get_receipt().await?;
Ok(receipt)
}

/// Get vote status for an obligation
pub async fn get_vote_status(
&self,
obligation: alloy::primitives::FixedBytes<32>,
) -> eyre::Result<(U256, U256)> {
let contract = contracts::arbiters::MajorityVoteArbiter::new(
self.addresses.majority_vote_arbiter,
self.public_provider.clone(),
);

let status = contract.voteStatuses(obligation).call().await?;
Ok((status.yesVotes, status.noVotes))
}
}
  1. Add to the parent module and expose via the client:
// In alkahest-rs/src/clients/arbiters/mod.rs
mod majority_vote;
pub use majority_vote::{MajorityVoteModule, MajorityVoteAddresses};

// In ArbitersModule, add accessor method:
impl ArbitersModule {
pub fn majority_vote(&self) -> MajorityVoteModule {
MajorityVoteModule::new(
self.signer.clone(),
self.public_provider.clone(),
self.wallet_provider.clone(),
// addresses from config
)
}
}

Python SDK Extensions

The Python SDK uses PyO3 to wrap Rust modules, providing Python-native interfaces:

  1. Create PyO3 bindings that wrap the Rust module:
// alkahest-py/src/clients/arbiters/majority_vote.rs
use alkahest_rs::clients::arbiters::MajorityVoteModule;
use pyo3::{pyclass, pymethods, PyResult};

#[pyclass]
#[derive(Clone)]
pub struct MajorityVote {
inner: MajorityVoteModule,
}

impl MajorityVote {
pub fn new(inner: MajorityVoteModule) -> Self {
Self { inner }
}
}

#[pymethods]
impl MajorityVote {
/// Cast a vote for an obligation
pub fn cast_vote<'py>(
&self,
py: pyo3::Python<'py>,
obligation: String,
vote: bool,
demand_data: Vec<u8>,
) -> PyResult<pyo3::Bound<'py, pyo3::PyAny>> {
let inner = self.inner.clone();
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let obligation_bytes = obligation.parse()
.map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{}", e)))?;
let receipt = inner.cast_vote(obligation_bytes, vote, demand_data.into())
.await
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{}", e)))?;
Ok(format!("{:?}", receipt.transaction_hash))
})
}

/// Get vote status for an obligation
pub fn get_vote_status<'py>(
&self,
py: pyo3::Python<'py>,
obligation: String,
) -> PyResult<pyo3::Bound<'py, pyo3::PyAny>> {
let inner = self.inner.clone();
pyo3_async_runtimes::tokio::future_into_py(py, async move {
let obligation_bytes = obligation.parse()
.map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{}", e)))?;
let (yes, no) = inner.get_vote_status(obligation_bytes)
.await
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("{}", e)))?;
Ok((yes.to_string(), no.to_string()))
})
}

/// Encode demand data (static method)
#[staticmethod]
pub fn encode_demand(voters: Vec<String>, quorum: u64, data: Vec<u8>) -> PyResult<Vec<u8>> {
use alloy::sol_types::SolValue;
let voters: Vec<alloy::primitives::Address> = voters.iter()
.map(|v| v.parse())
.collect::<Result<_, _>>()
.map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("{}", e)))?;

let demand = alkahest_rs::contracts::arbiters::MajorityVoteArbiter::DemandData {
voters,
quorum: alloy::primitives::U256::from(quorum),
data: data.into(),
};
Ok(demand.abi_encode())
}
}
  1. Expose via the ArbitersClient:
// In alkahest-py/src/clients/arbiters/mod.rs
#[pymethods]
impl ArbitersClient {
#[getter]
pub fn majority_vote(&self) -> MajorityVote {
MajorityVote::new(self.inner.majority_vote())
}
}

Best Practices for SDK Extensions

  1. Extract types from contract ABIs: Use getAbiItem (TS) or impl_abi_conversions! (Rust) to derive types from contract ABIs rather than duplicating struct definitions
  2. Provide static functions: Export encodeDemand/decodeDemand as static functions usable without instantiating a client
  3. Use hierarchical namespacing: Organize under client.arbiters.custom.* or similar to match existing SDK patterns
  4. Handle async properly: Use appropriate async patterns for each SDK (Promises in TS, async/await in Rust, PyO3 futures in Python)
  5. Add event helpers: Include methods to watch for and parse relevant contract events
  6. Test with integration tests: Write tests that deploy contracts and verify SDK interactions

Example Usage Across SDKs

Once implemented, developers can use your arbiter consistently:

// Encode demand (static, no client needed)
import { encodeMajorityVoteDemand } from "alkahest-ts/clients/arbiters/custom";
const demand = encodeMajorityVoteDemand({ voters: [alice, bob, charlie], quorum: 2n, data: "0x" });

// Or via client instance
const demand = client.arbiters.custom.majorityVote.encodeDemand({ ... });
await client.arbiters.custom.majorityVote.castVote(obligationUid, true, demand);
const status = await client.arbiters.custom.majorityVote.getVoteStatus(obligationUid);