Skip to main content
Executors (also called execution strategies) define how passed proposals are executed onchain. They determine what transactions can be executed, who can trigger execution, and what conditions must be met.

What are Executors?

An executor is a contract that:
  1. Receives execution requests from the space contract
  2. Validates execution conditions (quorum, timelock, etc.)
  3. Executes transactions on behalf of the governance system
Each proposal specifies an execution strategy when created, determining how it will be executed if it passes.

Executor Types

Snapshot X supports multiple execution patterns:
type ExecutorType =
  | 'SimpleQuorumVanilla'
  | 'SimpleQuorumAvatar'
  | 'SimpleQuorumTimelock'
  | 'EthRelayer'
  | 'Axiom'
  | 'Isokratia';

Core Executors

Vanilla Executor

The simplest executor - no actual execution, just marks the proposal as passed.
function createVanillaExecutor() {
  return {
    type: 'vanilla',
    getExecutionData(executorAddress: string) {
      return {
        executor: executorAddress,
        executionParams: []
      };
    }
  };
}
Use case: Signaling proposals that don’t require onchain execution. Deployment: No deployment needed, uses a singleton contract.

Avatar Executor (Safe/Zodiac)

Executes transactions through a Gnosis Safe or any Avatar-compatible contract.
function createAvatarExecutor() {
  return {
    type: 'avatar',
    getExecutionData(executorAddress: string, transactions: MetaTransaction[]) {
      const abiCoder = new AbiCoder();
      const executionParams = abiCoder.encode(
        [
          'tuple(address to, uint256 value, bytes data, uint8 operation, uint256 salt)[]'
        ],
        [transactions]
      );
      return {
        executor: executorAddress,
        executionParams: [executionParams]
      };
    }
  };
}
Transaction format:
type MetaTransaction = {
  to: string;        // Target contract address
  value: string;     // ETH value to send
  data: string;      // Encoded function call
  operation: 0 | 1;  // 0 = Call, 1 = DelegateCall
  salt: string;      // Unique identifier
};
Deployment:
const { address, txId } = await client.deployAvatarExecution({
  signer,
  params: {
    controller: '0x...',  // Address that can update settings
    target: '0x...',      // Safe/Avatar address
    spaces: ['0x...'],    // Authorized space addresses
    quorum: 1000000n      // Minimum voting power required (in wei)
  },
  saltNonce: '0x...'
});
Use case: Execute arbitrary transactions through a Safe multi-sig after governance approval.

Timelock Executor

Adds a time delay between proposal passing and execution, with veto capability.
// Uses same getExecutionData as Avatar executor
Deployment:
const { address, txId } = await client.deployTimelockExecution({
  signer,
  params: {
    controller: '0x...',      // Address that can update settings
    vetoGuardian: '0x...',    // Address that can veto executions
    spaces: ['0x...'],        // Authorized space addresses
    timelockDelay: 172800n,   // Delay in seconds (e.g., 2 days)
    quorum: 1000000n          // Minimum voting power required
  },
  saltNonce: '0x...'
});
Execution flow:
1

Proposal Passes

Once voting ends and quorum is met, proposal is queued
2

Timelock Delay

Wait for the configured delay period
3

Execute or Veto

Either the veto guardian vetoes, or anyone executes the queued proposal
Execute queued proposal:
await client.executeQueuedProposal({
  signer,
  executionStrategy: '0x...',
  executionParams: '0x...'  // Encoded transactions
});
Veto execution:
await client.vetoExecution({
  signer,
  executionStrategy: '0x...',
  executionHash: '0x...'  // Hash of the execution to veto
});
Use case: Add security delay for high-stakes governance decisions, allowing time for review or emergency veto.

Cross-Chain Executors

EthRelayer Executor

Relays execution from Starknet to Ethereum L1.
function createEthRelayerExecutor({ destination }: { destination: string }) {
  return {
    type: 'ethRelayer',
    getExecutionData(executorAddress: string, transactions: MetaTransaction[]) {
      const abiCoder = new AbiCoder();
      const executionParams = abiCoder.encode(
        ['tuple(address to, uint256 value, bytes data, uint8 operation)[]'],
        [transactions]
      );
      
      // Hash the execution params for cross-chain verification
      const executionHash = uint256.bnToUint256(
        BigInt(keccak256(executionParams))
      );
      
      return {
        executor: executorAddress,
        executionParams: [
          destination,
          `0x${executionHash.low.toString(16)}`,
          `0x${executionHash.high.toString(16)}`
        ]
      };
    }
  };
}
Configuration:
  • destination: L1 executor contract that will receive the execution
  • transactions: Array of transactions to execute on L1
Use case: Starknet governance controlling Ethereum contracts.

ZK-Proof Executors

Axiom Executor

Uses Axiom’s ZK proofs to verify historical blockchain data for execution.
const { address, txId } = await client.deployAxiomExecution({
  signer,
  params: {
    controller: '0x...',
    quorum: 1000000n,
    contractAddress: '0x...',  // Contract to read data from
    slotIndex: 0n,             // Storage slot to read
    space: '0x...',            // Space contract address
    querySchema: '0x...'       // Axiom query schema hash
  },
  saltNonce: '0x...'
});
Use case: Execute based on proven historical token balances or other onchain state.

Isokratia Executor

Executes based on ZK proofs with a proving time allowance.
const { address, txId } = await client.deployIsokratiaExecution({
  signer,
  params: {
    provingTimeAllowance: 86400,  // 24 hours to generate proof
    quorum: 1000000n,
    queryAddress: '0x...',        // Query contract address
    contractAddress: '0x...',     // Contract to read data from
    slotIndex: 0n                 // Storage slot to read
  },
  saltNonce: '0x...'
});
Use case: Execute transactions with cryptographic guarantees about voter eligibility.

Execution Data

The getExecutionData function prepares execution parameters for a proposal:
function getExecutionData(
  type: ExecutorType,
  executorAddress: string,
  input?: ExecutionInput
) {
  if (type === 'SimpleQuorumVanilla') {
    return createVanillaExecutor().getExecutionData(executorAddress);
  }
  
  if (
    ['SimpleQuorumAvatar', 'SimpleQuorumTimelock'].includes(type) &&
    input?.transactions
  ) {
    return createAvatarExecutor().getExecutionData(
      executorAddress,
      input.transactions
    );
  }
  
  if (type === 'EthRelayer' && input?.transactions && input.destination) {
    return createEthRelayerExecutor({
      destination: input.destination
    }).getExecutionData(executorAddress, input.transactions);
  }
  
  if (type === 'Axiom' && input?.transactions) {
    return createAxiomExecutor().getExecutionData(
      executorAddress,
      input.transactions
    );
  }
  
  if (type === 'Isokratia' && input?.transactions) {
    return createIsokratiaExecutor().getExecutionData(
      executorAddress,
      input.transactions
    );
  }
  
  throw new Error(
    `Not enough data to create execution for executor ${executorAddress}`
  );
}

Using Executors

1. Deploy Executor

First, deploy your chosen executor contract:
const { address: executorAddress, txId } = await client.deployAvatarExecution({
  signer,
  params: {
    controller: await signer.getAddress(),
    target: safeAddress,
    spaces: [], // Will add space after deployment
    quorum: ethers.parseEther('1000') // 1000 tokens
  }
});

2. Create Space with Executor

Reference the executor when creating proposals:
await client.propose({
  signer,
  envelope: {
    data: {
      space: '0x...',
      authenticator: '0x...',
      strategies: [...],
      executionStrategy: {
        addr: executorAddress,
        params: '0x...' // Executor-specific params
      },
      metadataUri: 'ipfs://...'
    }
  }
});

3. Execute Passed Proposal

After voting ends and the proposal passes:
// For immediate execution (Avatar/Vanilla)
await client.execute({
  signer,
  space: '0x...',
  proposal: 1,
  executionParams: '0x...' // Encoded transactions
});

// For timelock execution
// Wait for timelock delay, then:
await client.executeQueuedProposal({
  signer,
  executionStrategy: executorAddress,
  executionParams: '0x...'
});

Transaction Building

When creating proposals with transactions, build the transaction array:
import { AbiCoder, Interface } from '@ethersproject/abi';

const targetInterface = new Interface([
  'function transfer(address to, uint256 amount)'
]);

const transactions: MetaTransaction[] = [
  {
    to: tokenAddress,
    value: '0',
    data: targetInterface.encodeFunctionData('transfer', [
      recipientAddress,
      ethers.parseEther('100')
    ]),
    operation: 0, // Call
    salt: '0x0000000000000000000000000000000000000000000000000000000000000001'
  },
  // Add more transactions as needed
];

const { executor, executionParams } = createAvatarExecutor().getExecutionData(
  executorAddress,
  transactions
);

Executor Configuration

Each network configuration specifies available executor implementations:
type EvmNetworkConfig = {
  // ...
  executionStrategiesImplementations: {
    SimpleQuorumVanilla?: string;
    SimpleQuorumAvatar?: string;
    SimpleQuorumTimelock?: string;
    EthRelayer?: string;
    Axiom?: string;
    Isokratia?: string;
  };
};
These are used by the proxy factory to deploy new executor instances.

Execution Flow

Spaces

Learn about governance spaces

Strategies

Understand voting power

Creating Proposals

Create executable proposals

Safe Integration

Integrate with Gnosis Safe

Build docs developers (and LLMs) love