Allogram

An allogram is the smallest meaningful unit of a writing system

Built on Scaffold-ETH2, inspired by the design-principles of OpenZeppelin.

Goals

  • Fun and easy to set up and start to play around with. Scaffold Eth is great at this because they have mastered it and heard feedback for years

  • Shows what's possible and inspires experimentation (must be a quick feedback loop of making changes in contract and editing the app)

  • Documentation that inspires: "wow this looks cool and I want to try some idea out" it should hook the action of forking the repo.

Getting Started

npx create-eth@latest -e allocapital/allo-app

# Starter Contract in /packages/hardhat/contracts/Strategy.sol
# Allo App in /packages/app
# Contract Debugger in /packages/nextjs
# Indexer in /packages/allo-indexer

Architecture

Exploring four different builds to find the common patterns of the function calls which will help us discover the correct abstractions.

  • SimpleGrants - add projects to a cart and donate tokens to them
  • AlloIRL - quadratic vote for projects, calculate matching funds, projects claim matching funds
  • AlloStaker - stake ALLO on projects
  • AlloFunder - crowdfunding

The more we can discover and re-use common patterns, interfaces, and events, the more flexible and powerful the architecture becomes.

contract SimpleGrants {
    // Register project (with application details in metadata)
    function register(address project, string calldata metadataURI) public {}

    // Approve project (with review details in metadata)
    function approve(address project, string calldata metadataURI, address token) public onlyOwner {}

    // Fund project (we want to allow to fund many project in one tx)
    function allocate(address[] calldata projects, uint256[] calldata amounts) public {}
}

contract AlloIRL {
    // Register project
    function register(address project, string calldata metadataURI) public {}

    // Approve project
    function approve(address project, string calldata metadataURI) public onlyOwner {}

    // Fund matching funds + Vote for projects
    function allocate(address[] calldata projects, uint256[] calldata amounts, address token) public {}

    // Transfer matching funds to projects 
    function distribute(address[] calldata projects, uint256[] calldata amounts, address token) public {}

}

contract AlloStaker {
    // Register project
    function register(address project, string calldata metadataURI) public {}

    // Approve project (can also be auto-approved in register())
    function approve(address project, string calldata metadataURI) public onlyOwner {}

    // Stake to project
    function allocate(address[] calldata projects, uint256[] calldata amounts) public {}

    // Withdraw stake
    function withdraw(address project, uint256 amount) public {}

    // Stakers can claim accumulated rewards
    function claimRewards() public {}
}

event Register(address indexed project, string metadataURI);
event Approve(address indexed project, string metadataURI);

event Allocate(address indexed from, address indexed recipient, uint256 amount, address token);
event Distribute(address indexed from, address indexed recipient, uint256 amount, address token);

  • Register - register project (can we handle both project registrations and applications / campaigns etc with this?)
    • projects[projectAddress][applicationId]
    • When applicationId is 0 we interpret it as the project registration. This way we have a simple and flexible way to handle project registration, applications, creation of campaigns etc.
  • Allocate - transfer tokens from sender to recipient (could also be contract address)
  • Distribute - withdraw tokens from contract to recipient

The main functions in these three are:

  • register + approve (of projects)
  • allocate (vote, fund, stake)

If these emit events with the same signatures we can index it from one indexer.

This means we can create hooks that work for all strategy contracts following these function and event signatures.

  • useRegister + useApprove - useRegistrations + useRegistrationByAddress
  • useAllocate + useAllocations

This allows us to have a toolkit that:

  • easily create and approve projects
  • query registered projects and reviews
  • vote, fund, stake to projects
  • query votes, funding, stakings

Indexer

The indexer picks up the events emitted by these contract extensions.

It can be queried like:

Applications

{
  registrations(
    orderBy: "createdAt"
    orderDirection: "desc"
    limit: 100
    where: { isApproved: true }
  ) {
    items {
      address
      metadata # Project or Application metadata
      review # Review metadata
      createdAt
      updatedAt
      isApproved
      strategyAddress
      allocations { # allocations for project
        items {
          amount
        }
      }
    }
    totalCount
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
  }
}

Allocations

{
  allocations(
    after: $after
    before: $before
    orderBy: "amount"
    orderDirection: "desc"
    limit: 100
    where: { amount_gte: 100, token: "0xUSDCtoken" }
  ) {
    items {
      amount
      recipient
      from
      token
      createdAt
      strategyAddress
    }
  }
}

Composing Contracts

A Strategy contract can easily be created by composing extensions and gates.

Extensions

These are inspired by the design choices in OpenZeppelin ERC20 with a public and an internal function.

Registry

A simple registry to handle eligibility for funding (or keeping track of project addresses for other purposes).

Metadata can be sent for both registration (Application) and approving (Review).

event Register(address indexed project, string metadataURI);
event Approve(address indexed project, string metadataURI);

enum Status { Registered, Approved }
mapping(address => Status) public projects; 

function _register(address recipient, string calldata metadataURI, bytes calldata data) internal virtual
function _approve(address recipient, string calldata metadataURI, bytes calldata data) internal virtual

Allocator

The Allocator comes with functions to allocate tokens to a collection of recipients. We use arrays for the public function to simplify token transfers to multiple recipients.

event Allocate(address indexed from, address indexed recipient, uint256 amount, address token);
event Withdraw(address indexed from, address indexed recipient, uint256 amount, address token);

function allocate(address[] calldata recipients, uint256[] calldata amounts, address token, bytes[] calldata data) public virtual
function _allocate(address recipient) internal virtual

function withdraw(address[] calldata recipients, uint256[] calldata amounts, address token, bytes[] calldata data) public virtual
function _withdraw(address recipient) internal virtual

The reason the allocate function contains an array of recipients and amounts is because we want to be able to send to many in one transaction without using Multicall.

Never approve Multicall3 to spend your tokens.
https://github.com/mds1/multicall?tab=readme-ov-file#security

MerkleClaim

MerkleVerify provides two internal functions that can be called from parent contract and a public claim function to transfer tokens.

event MerkleSet(bytes32 root);
event Claim(address recipient, uint256 amount, address token);

function _setMerkle(bytes32 root) internal virtual 
function _verify(bytes32 memory proof, address token, address recipient, uint256 amount) internal virtual
function claim(bytes32 memory proof, address token, address recipient, uint256 amount) public virtual

Gates

Gates are small contracts with simple checks that can be used on contracts. Similar to OpenZeppelin's Ownable with the modifier onlyOwner.

TokenGate

TokenGate lets us check the balance of ERC20 or ERC721 tokens.

modifier onlyTokens(account, token, amount)
Example
contract GatedRegistryWithMerkleClaim is Allocator, Registry, TokenGate, MerkleClaim {
    address private alloToken;
    address private guildNFT;

    constructor(address _alloToken, address _guildNFT) {
        alloToken = _alloToken;
        guildNFT = _guildNFT;
    }

    // Only holders of 1 Guild NFT can register projects
    function register(
        address project,
        string memory metadataURI, // Metadata contain Application Details
        bytes memory data
    ) public override onlyToken(_msgSender(), guildNFT, 1) {
        _register(project, metadataURI, data);
    }

    // Only holders of 10 ALLO tokens can approve projects
    function approve(
        address project,
        string memory metadataURI, // Metadata contains Application Review
        bytes memory data
    ) public override onlyToken(_msgSender(), alloToken, 10 ** 18) {
        _approve(project, metadataURI, data);
    }
  
    // Only Owner can update Merkle
    function setMerkle(bytes32 _root) public override onlyOwner {
      _setMerkle(_root);
    }
  
    // This is the same function as in MerkleClaim added here for clarity
    function claim(bytes32[] memory proof, address token, address recipient, uint256 amount) public override {
      if (_verify(proof, token, recipient, amount)) {
        IERC20(token).transfer(recipient, amount);
        emit Claim(recipient, amount, token);
      }
    }
}

EASGate

Check if an account has an attestation for a specific schemaUID and attester.
Can be used for example for OP Badgeholders or only approved projects can register projects.

modifier onlyEAS(account, schemaUID, attester)
contract OptimismRetroFunding is Allocator, EASGate, Registry {
  // Only OP Badgeholders can call
  function allocate() public overide onlyEAS(msg.sender, badgeholderSchemaUID, approvedAttester) {
    _allocate(recipient, token, amount);
  }
  
  function register(address project) onlyEAS(project, approvedSchemaUID, approvedAttester) {
    _register(recipient, metadataURI);
  }
}

SignatureGate (TBD)

SignatureGate lets us restrict function calls to offchain generated signatures.

modifier onlySignature(account, data)
// data contains signature + hash and is decoded in function 
contract OffchainSignatureCheck is Allocator, SignatureGate {
  // Signature has been generated offchain by a trusted signer
  function register(address project, string calldata metadataURI bytes calldata) onlySignature(msg.sender, data) {
    _register(project, metadataURI);
  }
}

React Components

Ready-made components for most common use-cases.

Categories:

  • registrations
    • register
    • registrations
    • approvals
  • allocations
    • allocate
    • allocations
    • distribute
    • distributions
    • deposit
    • deposits
  • cart (maps registrations to allocation amounts)
  • erc20
    • token (name, symbol and decimals)
    • approve
    • allowance
    • balance
  • merkle
    • generate merkle tree
    • set merkle root
    • generate proof
    • claim
  • quadratic
    • calculate matching amounts from votes

RegistrationForm

Use-cases:

  • Register Project
  • Register Application / Campaign

Here's what it handles:

  • Form + validation
  • Uploading of metadata (+ avatars, cover images etc)
  • Call strategy register via useRegister hook.
  • Waiting for event to be published and redirect to RegistrationDetails

Should be easy to add and remove different form fields.

ProjectsList and ApplicationsList

RegistrationApproval

AllocationForm

Use-cases:

  • Transfer tokens to addresses (projects, etc)

React Hooks

function useRegister(strategyAddress) {}
function useApprove(strategyAddress) {}

function useRegistrations(query: IndexerQuery) {} // Query projects (and applications?)

function useAllocate(strategy: Address) {}
function useAllocations(query: IndexerQuery) {}

// Allocations into Strategy (wraps useAllocations)
function useDeposits(strategy: Address, query: IndexerQuery) {}

// Allocations from Strategy (wraps useAllocations)
function useWithdraws(strategy: Address, query: IndexerQuery) {}

const register = useRegister("0xStrategyAddress")
register.mutate({ ... })