Graphite

Write The Graph subgraph handlers in Rust. The compiled WASM is AssemblyScript-ABI-compatible — unmodified graph-node accepts it as a standard subgraph.

Live on The Graph Studio. ERC20 and ERC721 subgraphs are deployed and indexing on Arbitrum One. Zero graph-node changes required.


Why Rust?

AssemblyScript is a subset of TypeScript that compiles to WASM. It works, but it gives up most of what makes typed languages useful: no closures, no iterators, no algebraic types, no real ecosystem. Graphite lets you write the same subgraph mappings in Rust and get all of that back — plus cargo test without Docker.

How It Works

Graph-node identifies WASM subgraphs by the structure of their memory and the names of their exported functions — not by who wrote them. The graph-as-runtime crate implements the AssemblyScript memory model in Rust: 20-byte object headers, UTF-16LE strings, TypedMap entity layout, the full set of host function imports. The resulting WASM is structurally indistinguishable from AssemblyScript output. The manifest declares language: wasm/assemblyscript and graph-node accepts it without any special handling.

Your Rust handler
      │
      ▼
graphite-macros  (#[handler], #[derive(Entity)])
      │
      ▼
graph-as-runtime  (AS ABI: allocator, UTF-16LE strings, TypedMap, host imports)
      │
      ▼
WASM binary  ──────────────────►  unmodified graph-node / The Graph Studio

Quick Example

#![allow(unused)]
#![cfg_attr(target_arch = "wasm32", no_std)]
fn main() {
extern crate alloc;

use alloc::format;
use graphite_macros::handler;

mod generated;
use generated::{ERC20TransferEvent, Transfer};

#[handler]
pub fn handle_transfer(event: &ERC20TransferEvent, ctx: &graphite::EventContext) {
    let id = format!("{}-{}", hex(&ctx.tx_hash), hex(&ctx.log_index));
    Transfer::new(&id)
        .set_from(event.from.to_vec())
        .set_to(event.to.to_vec())
        .set_value(event.value.clone())
        .set_block_number(ctx.block_number.clone())
        .set_timestamp(ctx.block_timestamp.clone())
        .save();
}
}

Test it natively — no Docker, no PostgreSQL:

#![allow(unused)]
fn main() {
#[test]
fn transfer_creates_entity() {
    mock::reset();
    handle_transfer_impl(&event, &graphite::EventContext::default());
    assert_eq!(mock::entity_count("Transfer"), 1);
}
}

Feature Parity

FeatureStatus
Event / Call / Block / File handlers
store.set / store.get / store.remove / store.getInBlock
ethereum.call, ethereum.encode, ethereum.decode
log.info / log.warning / log.error / log.critical
ipfs.cat, json.fromBytes, ens.nameByAddress
dataSource.create / createWithContext / context accessors
crypto.keccak256 / sha256 / sha3 / secp256k1.recover
BigInt — full arithmetic, bitwise, shifts
BigDecimal — full arithmetic
All GraphQL scalar types
Block handler filters (polling, every: N)
Native cargo test (no Docker)
Non-fatal errors

Crates

CratePurpose
graph-as-runtimeno_std AS ABI layer: allocator, type layout, host FFI
graphite-macros#[handler], #[derive(Entity)] proc macros
graphite-cliCLI: init, codegen, manifest, build, test, deploy
graphite-sdkUser-facing SDK and MockHost for native testing

The SDK crate is published as graphite-sdk but imported as graphite:

[dependencies]
graphite = { package = "graphite-sdk", version = "1", default-features = false }

Getting Started

These pages walk you from a blank terminal to a deployed subgraph.

  1. Installation — install Rust, the WASM target, and the CLI.
  2. Your First Subgraph — build and deploy an ERC20 Transfer indexer step by step.
  3. Project Structure — what every generated file is for.

Installation

Prerequisites

Rust

Install Rust via rustup, then add the WASM compilation target:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown

wasm-opt

graphite build runs wasm-opt -Oz to shrink the binary. It's optional but recommended — a typical handler lands around 50–80 KB after optimisation.

# macOS
brew install binaryen

# cargo (any platform)
cargo install wasm-opt

graphite-cli

cargo install graphite-cli

Verify:

graphite --version

Optional: Etherscan API Key

graphite init --from-contract can fetch ABIs automatically from Etherscan (and compatible explorers). Set the key in your environment:

export ETHERSCAN_API_KEY=your_key_here

Supported chains include Ethereum mainnet/testnets, Arbitrum, Optimism, Base, Polygon, and any chain with an Etherscan-compatible explorer.

Optional: Local graph-node

For local development without The Graph Studio, you need a running graph-node. The quickest path is the official Docker Compose setup from the graph-node repository.

The Graph Studio works without any local infrastructure — just a deploy key.

Your First Subgraph

This walkthrough builds an ERC20 Transfer indexer from scratch — the same one that's live on The Graph Studio (Arbitrum One).


1. Create the Project

graphite init my-subgraph --network mainnet
cd my-subgraph

If you have an Etherscan API key, pass the contract address and the CLI fetches the ABI for you:

ETHERSCAN_API_KEY=yourkey graphite init my-subgraph \
  --from-contract 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
  --network mainnet

2. Define the Schema

Edit schema.graphql:

type Transfer @entity {
  id: ID!
  from: Bytes!
  to: Bytes!
  value: BigInt!
  blockNumber: BigInt!
  timestamp: BigInt!
  transactionHash: Bytes!
}

Each @entity type becomes a generated Rust struct with a builder.


3. Add the ABI

Drop the ERC20 ABI JSON into abis/:

cp path/to/ERC20.json abis/ERC20.json

The ABI must be standard Ethereum JSON format. At minimum you need the Transfer event:

[
  {
    "anonymous": false,
    "inputs": [
      { "indexed": true,  "name": "from",  "type": "address" },
      { "indexed": true,  "name": "to",    "type": "address" },
      { "indexed": false, "name": "value", "type": "uint256" }
    ],
    "name": "Transfer",
    "type": "event"
  }
]

4. Configure graphite.toml

output_dir = "src/generated"
schema     = "schema.graphql"
network    = "mainnet"

[[contracts]]
name        = "ERC20"
abi         = "abis/ERC20.json"
address     = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
start_block = 6082465

5. Generate the Manifest

graphite manifest

This reads graphite.toml and schema.graphql and writes subgraph.yaml. Re-run it whenever you change the config.


6. Run Codegen

graphite codegen

This generates Rust source into src/generated/:

src/generated/
├── mod.rs      # re-exports
├── erc20.rs    # ERC20TransferEvent and other event/call structs
└── schema.rs   # Transfer entity builder

The generated ERC20TransferEvent has typed fields (from: [u8; 20], to: [u8; 20], value: BigInt) decoded from raw ABI bytes. The Transfer entity has setter methods for every schema field.


7. Write the Handler

Edit src/lib.rs:

#![allow(unused)]
#![cfg_attr(target_arch = "wasm32", no_std)]
fn main() {
extern crate alloc;

use alloc::format;
use graphite_macros::handler;

mod generated;
use generated::{ERC20TransferEvent, Transfer};

fn hex(b: &[u8]) -> alloc::string::String {
    b.iter().map(|x| format!("{:02x}", x)).collect()
}

#[handler]
pub fn handle_transfer(event: &ERC20TransferEvent, ctx: &graphite::EventContext) {
    let id = format!("{}-{}", hex(&ctx.tx_hash), hex(&ctx.log_index));

    Transfer::new(&id)
        .set_from(event.from.to_vec())
        .set_to(event.to.to_vec())
        .set_value(event.value.clone())
        .set_block_number(ctx.block_number.clone())
        .set_timestamp(ctx.block_timestamp.clone())
        .set_transaction_hash(ctx.tx_hash.to_vec())
        .save();
}
}

The #[handler] macro generates two things:

  • handle_transfer_impl(event, ctx) — the logic function you call from tests.
  • handle_transfer(event_ptr: i32) — the extern "C" WASM entry point graph-node calls.

In subgraph.yaml, the handler name is handle_transfer.

Note: #![cfg_attr(target_arch = "wasm32", no_std)] + extern crate alloc is required. When targeting WASM, the standard library is unavailable. Use alloc::format!, alloc::vec!, alloc::string::String, and so on.


8. Test Natively

cargo test

No Docker, no PostgreSQL, no graph-node. Tests use an in-process mock store:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use graph_as_runtime::ethereum::{EthereumValue, EventParam, FromRawEvent, RawEthereumEvent};
    use graphite::mock;

    fn mock_event() -> RawEthereumEvent {
        RawEthereumEvent {
            tx_hash: [0xab; 32],
            log_index: alloc::vec![0],
            block_number: alloc::vec![1, 0, 0, 0],
            block_timestamp: alloc::vec![100, 0, 0, 0],
            params: alloc::vec![
                EventParam { name: "from".into(),  value: EthereumValue::Address([0xaa; 20]) },
                EventParam { name: "to".into(),    value: EthereumValue::Address([0xbb; 20]) },
                EventParam { name: "value".into(), value: EthereumValue::Uint(alloc::vec![100]) },
            ],
            ..Default::default()
        }
    }

    #[test]
    fn transfer_creates_entity() {
        mock::reset();

        let raw = mock_event();
        let event = ERC20TransferEvent::from_raw_event(&raw).unwrap();
        handle_transfer_impl(&event, &graphite::EventContext::default());

        let tx_hex = "ab".repeat(32);
        assert!(mock::has_entity("Transfer", &format!("{}-00", tx_hex)));
    }
}
}

See Testing for the full mock API.


9. Build

graphite build

Compiles to WASM and runs wasm-opt. Output: build/my-subgraph.wasm.


10. Deploy

The Graph Studio:

graphite deploy \
  --node https://api.studio.thegraph.com/deploy/ \
  --ipfs https://api.thegraph.com/ipfs/ \
  --deploy-key YOUR_DEPLOY_KEY \
  --version-label v1.0.0 \
  your-subgraph-slug

Local graph-node:

graphite deploy \
  --node http://localhost:8020 \
  --ipfs http://localhost:5001 \
  myname/my-subgraph

The CLI uploads the WASM, schema, and ABIs to IPFS, rewrites subgraph.yaml with IPFS hashes, then calls subgraph_deploy on the graph-node JSON-RPC endpoint.

Project Structure

graphite init creates the following layout:

my-subgraph/
├── Cargo.toml           # Rust crate (cdylib), declares graphite dependency
├── graphite.toml        # Graphite config: contracts, ABIs, output dir
├── subgraph.yaml        # The Graph manifest (generated by `graphite manifest`)
├── schema.graphql       # GraphQL entity schema (you write this)
├── abis/
│   └── MyContract.json  # ABI JSON (fetched or copied manually)
└── src/
    ├── lib.rs           # Your handlers (you write this)
    └── generated/       # Auto-generated by `graphite codegen` — do not edit
        ├── mod.rs
        ├── mycontract.rs   # Event and call structs from the ABI
        └── schema.rs        # Entity builders from schema.graphql

Key Files

Cargo.toml

A standard Rust crate configured as a cdylib (dynamic library), which is what the WASM target needs:

[lib]
crate-type = ["cdylib"]

[dependencies]
graphite = { package = "graphite-sdk", version = "1", default-features = false }
graphite-macros = "1"
graph-as-runtime = "1"

graphite.toml

The single source of truth for your subgraph configuration. graphite codegen and graphite manifest both read from it. See the graphite.toml reference for all options.

subgraph.yaml

Generated by graphite manifest. You should not edit it by hand — regenerate it whenever graphite.toml or schema.graphql changes. The manifest declares language: wasm/assemblyscript, which is how graph-node accepts Graphite WASM without modification.

src/generated/

Everything in here is overwritten every time you run graphite codegen. Never edit these files directly.

  • {contract_name}.rs — one struct per event and call, with typed fields matching the ABI. Each struct implements FromRawEvent for decoding.
  • schema.rs — one struct per @entity type in schema.graphql. Each struct has new, load, save, remove, and a setter for every field.

src/lib.rs

Where you write your handler functions. The crate must be no_std when targeting WASM:

#![allow(unused)]
#![cfg_attr(target_arch = "wasm32", no_std)]
fn main() {
extern crate alloc;
}

Tests live here too and run natively with cargo test.

Core Concepts

  • Handlers — event, call, block, and file handlers; the #[handler] macro.
  • Entities#[derive(Entity)], the builder API, loading and saving.
  • PrimitivesBigInt, BigDecimal, Bytes, and Address.
  • Testing — native cargo test with the mock store.
  • Event Context Reference — all fields on EventContext, CallContext, and FileContext.

Handlers

Handlers are Rust functions that graph-node calls when an indexed event, call, or block occurs. The #[handler] macro generates the extern "C" WASM entry point and a testable _impl function from your Rust function.

Handler Types

AttributeTriggered bySignature
#[handler]Ethereum eventfn(event: &FooEvent, ctx: &EventContext)
#[handler(call)]Contract function callfn(call: &FooCall, ctx: &CallContext)
#[handler(block)]Every blockfn(block: &EthereumBlock, ctx: &EventContext)
#[handler(file)]IPFS file contentfn(content: &[u8], ctx: &FileContext)

Event Handlers

The most common handler type. Fires for every matching event log.

#![allow(unused)]
fn main() {
#[handler]
pub fn handle_transfer(event: &ERC20TransferEvent, ctx: &graphite::EventContext) {
    let id = format!("{}-{}", hex(&ctx.tx_hash), hex(&ctx.log_index));
    Transfer::new(&id)
        .set_from(event.from.to_vec())
        .set_to(event.to.to_vec())
        .set_value(event.value.clone())
        .save();
}
}

The generated event struct (ERC20TransferEvent) has typed fields matching the ABI. Indexed parameters and non-indexed parameters are decoded automatically. See Entities for the builder API.

Call Handlers

Fires when a specific contract function is called (requires call: true in the graph-node config for the chain).

#![allow(unused)]
fn main() {
#[handler(call)]
pub fn handle_transfer_call(call: &ERC20TransferCall, ctx: &graphite::CallContext) {
    // call.to, call.value — decoded from the calldata
    // ctx.from, ctx.to, ctx.block_number, etc.
}
}

Declare in graphite.toml:

[[contracts.call_handlers]]
function = "transfer(address,uint256)"
handler  = "handle_transfer_call"

Block Handlers

Fires for every block, or every N blocks if a filter is configured.

#![allow(unused)]
fn main() {
#[handler(block)]
pub fn handle_block(block: &graphite::EthereumBlock, ctx: &graphite::EventContext) {
    // ctx.block_number, ctx.block_timestamp, etc.
}
}

Declare in graphite.toml:

# Every block
[[contracts.block_handlers]]
handler = "handle_block"

# Every 10 blocks
[[contracts.block_handlers]]
handler = "handle_block_polled"
filter  = { kind = "polling", every = 10 }

File Handlers

Fires when IPFS content is fetched for a file data source. See File Handlers.

#![allow(unused)]
fn main() {
#[handler(file)]
pub fn handle_metadata(content: &[u8], ctx: &graphite::FileContext) {
    // parse JSON or raw bytes from IPFS content
}
}

The _impl Convention

The #[handler] macro generates two functions from pub fn handle_foo(...):

  • pub fn handle_foo_impl(event, ctx) — the actual logic, callable from tests.
  • extern "C" fn handle_foo(event_ptr: i32) — the WASM entry point graph-node calls.

In subgraph.yaml the handler name is handle_foo. In tests you call handle_foo_impl.

#![allow(unused)]
fn main() {
#[test]
fn my_test() {
    mock::reset();
    handle_transfer_impl(&event, &graphite::EventContext::default());
    // assertions...
}
}

no_std Requirement

Subgraph crates must be no_std when targeting WASM. The standard library is not available in the WASM environment.

#![allow(unused)]
#![cfg_attr(target_arch = "wasm32", no_std)]
fn main() {
extern crate alloc;

use alloc::{format, string::String, vec, vec::Vec};
}

This only applies to the wasm32 target — native cargo test runs with the full standard library.

Entities

Entities are the data you store in The Graph's indexed store. Each @entity type in your schema.graphql becomes a generated Rust struct with a builder.

Schema Definition

type Token @entity {
  id: ID!
  owner: Bytes!
  tokenId: BigInt!
  mintedAt: BigInt!
  uri: String
}

Generated API

graphite codegen produces a Token struct with:

#![allow(unused)]
fn main() {
// Create a new entity (not yet saved)
let token = Token::new("0x1234");

// Builder pattern — chain setters
token
    .set_owner(owner_bytes)
    .set_token_id(token_id_bigint)
    .set_minted_at(block_number)
    .set_uri("ipfs://...".into())
    .save();                        // writes to the store

// Load an existing entity
if let Some(token) = Token::load("0x1234") {
    let owner = token.owner();      // returns &[u8]
    token.set_owner(new_owner).save();
}

// Remove an entity
Token::remove("0x1234");
}

Methods

MethodDescription
Token::new(id)Constructs a new entity with the given ID. Not saved until .save() is called.
Token::load(id)Loads from the store. Returns Option<Token>.
Token::remove(id)Deletes the entity from the store.
.set_field(value)Sets a field. Returns &mut Self for chaining.
.field()Gets a field value. Returns a reference to the stored value.
.save()Writes all set fields to the store. Upserts — creates if not present, updates if it exists.

Field Types

Setter types depend on the GraphQL schema type:

GraphQL TypeRust setter type
ID! / String&str or String
Bytes / AddressVec<u8>
BigIntVec<u8> (little-endian bytes)
BigDecimalVec<u8> (serialised)
Booleanbool
Inti32
Int8i64
Floatf64

Nullable Fields

Fields without ! are optional. The setter accepts Option<T> or you can pass a value directly — the generated code wraps it.

Immutable Entities

Fields marked @entity(immutable: true) in the schema can only be set once. Attempting to update them at a later block will cause graph-node to error.

@derivedFrom

Fields with @derivedFrom are not stored in the entity — graph-node computes them as reverse lookups. They have no generated setter and do not appear in save() calls.

type Token @entity {
  id: ID!
  transfers: [Transfer!]! @derivedFrom(field: "token")
}

type Transfer @entity {
  id: ID!
  token: Token!
}

Entity IDs

IDs must be unique within an entity type. Common patterns:

#![allow(unused)]
fn main() {
// Event-scoped: tx hash + log index
let id = format!("{}-{}", hex(&ctx.tx_hash), hex(&ctx.log_index));

// Address-scoped
let id = format!("0x{}", hex(&address));

// Compound
let id = format!("{}-{}", token_id, owner);
}

Primitives

Graphite provides Rust types for all The Graph's primitive scalars.

BigInt

Arbitrary-precision integer, stored as little-endian bytes (Vec<u8>). Matches The Graph's BigInt scalar.

#![allow(unused)]
fn main() {
use graphite::BigInt;

let a = BigInt::from(1000u64);
let b = BigInt::from_signed_bytes_le(&[100, 0, 0, 0]);

// Arithmetic
let sum   = a.plus(&b);
let diff  = a.minus(&b);
let prod  = a.times(&b);
let quot  = a.divided_by(&b);
let rem   = a.mod_(&b);
let pow   = a.pow(10);

// Comparison
let is_gt = a.gt(&b);
let is_lt = a.lt(&b);
let is_eq = a.equals(&b);

// Bitwise
let shifted = a.left_shift(4);
let anded   = a.bit_and(&b);
let ored    = a.bit_or(&b);

// Conversion
let as_i32: i32 = a.to_i32();
let as_str: String = a.to_string();
let as_hex: String = a.to_hex();
let as_f64: f64 = a.to_f64();

// From various sources
let from_str = BigInt::from_string("12345678901234567890");
let from_hex = BigInt::from_hex("0xdeadbeef");
}

In handlers, BigInt values are typically passed as event.value.clone() — the generated event structs already carry the right type.

BigDecimal

Arbitrary-precision decimal. Matches The Graph's BigDecimal scalar.

#![allow(unused)]
fn main() {
use graphite::BigDecimal;

let a = BigDecimal::from_string("1234.5678");
let b = BigDecimal::from_f64(3.14);

let sum   = a.plus(&b);
let diff  = a.minus(&b);
let prod  = a.times(&b);
let quot  = a.divided_by(&b);
let neg   = a.neg();
let abs   = a.truncated();

let as_str = a.to_string();
}

Bytes

A raw byte array. Matches The Graph's Bytes scalar.

#![allow(unused)]
fn main() {
use graphite::Bytes;

let b = Bytes::from_hex("0xdeadbeef");
let s = b.to_hex();     // "0xdeadbeef"
let v: Vec<u8> = b.to_vec();
}

In practice, Bytes values from events are usually passed directly as Vec<u8>:

#![allow(unused)]
fn main() {
.set_transaction_hash(ctx.tx_hash.to_vec())
.set_from(event.from.to_vec())
}

Address

A 20-byte Ethereum address. Matches The Graph's Address scalar.

#![allow(unused)]
fn main() {
use graphite::Address;

let addr = Address::from_bytes(&[0xaa; 20]);
let s = addr.to_hex();  // "0xaaaa...aaaa" (checksummed)
let b: [u8; 20] = addr.to_bytes();
}

Working with Raw Event Values

Generated event structs expose typed fields that correspond directly to ABI types:

Solidity ABI typeRust field type
address[u8; 20]
uint256 / uint128 / etc.Vec<u8> (LE BigInt bytes)
bytes32[u8; 32]
bytesVec<u8>
stringString
boolbool
int256Vec<u8> (LE signed bytes)

Pass them to entity setters without conversion:

#![allow(unused)]
fn main() {
.set_from(event.from.to_vec())  // [u8; 20] → Vec<u8>
.set_value(event.value.clone()) // Vec<u8> BigInt
.set_token_id(event.token_id.clone())
}

Testing

Graphite subgraphs can be tested with cargo test — no Docker, no PostgreSQL, no graph-node. Tests run natively using an in-process mock store.

Basic Setup

cargo test

Tests live in the same src/lib.rs file alongside your handlers (or in a tests/ directory). The graphite::mock module provides the in-memory store.

The Mock API

mock::reset()

Clears the entire in-memory store. Always call this at the start of each test to prevent state leaking between tests.

#![allow(unused)]
fn main() {
#[test]
fn my_test() {
    mock::reset();
    // ...
}
}

mock::has_entity(type, id)

Returns true if an entity with the given type name and ID exists in the store.

#![allow(unused)]
fn main() {
assert!(mock::has_entity("Transfer", "0xabc-00"));
}

mock::entity_count(type)

Returns the number of entities of a given type.

#![allow(unused)]
fn main() {
assert_eq!(mock::entity_count("Transfer"), 1);
}

mock::assert_entity(type, id)

Returns an assertion builder for inspecting a specific entity's field values.

#![allow(unused)]
fn main() {
mock::assert_entity("Transfer", &id)
    .field_bytes("from", &[0xaa; 20])
    .field_bytes("to", &[0xbb; 20])
    .field_exists("value")
    .field_exists("blockNumber");
}
MethodDescription
.field_exists(name)Asserts the field is set
.field_bytes(name, expected)Asserts a Bytes/Address field equals the given bytes
.field_string(name, expected)Asserts a String field equals the given value
.field_bool(name, expected)Asserts a Boolean field

mock::set_call_result(result)

Mocks the return value of an ethereum.call. See Contract Calls.

mock::set_current_address(address)

Sets the value returned by data_source::address_current(). Useful when testing template handlers:

#![allow(unused)]
fn main() {
mock::set_current_address([0xAA; 20]);
handle_swap_impl(&event, &graphite::EventContext::default());
}

mock::assert_contract_data_source_created(template, address)

Asserts that data_source::create_contract was called with the given template name and address:

#![allow(unused)]
fn main() {
mock::assert_contract_data_source_created("Pair", pair_address);
}

Writing a Test

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use graph_as_runtime::ethereum::{EthereumValue, EventParam, FromRawEvent, RawEthereumEvent};
    use graphite::mock;

    fn mock_transfer() -> RawEthereumEvent {
        RawEthereumEvent {
            tx_hash: [0xab; 32],
            log_index: alloc::vec![0],
            block_number: alloc::vec![1, 0, 0, 0],
            block_timestamp: alloc::vec![100, 0, 0, 0],
            params: alloc::vec![
                EventParam { name: "from".into(),  value: EthereumValue::Address([0xaa; 20]) },
                EventParam { name: "to".into(),    value: EthereumValue::Address([0xbb; 20]) },
                EventParam { name: "value".into(), value: EthereumValue::Uint(alloc::vec![100]) },
            ],
            ..Default::default()
        }
    }

    #[test]
    fn transfer_creates_entity() {
        mock::reset();

        let raw = mock_transfer();
        let event = ERC20TransferEvent::from_raw_event(&raw).unwrap();
        handle_transfer_impl(&event, &graphite::EventContext::default());

        let tx_hex = "ab".repeat(32);
        let id = format!("{}-00", tx_hex);

        assert!(mock::has_entity("Transfer", &id));
        mock::assert_entity("Transfer", &id)
            .field_bytes("from", &[0xaa; 20])
            .field_bytes("to", &[0xbb; 20])
            .field_exists("value");
    }

    #[test]
    fn upsert_does_not_duplicate() {
        mock::reset();

        let raw = mock_transfer();
        let event = ERC20TransferEvent::from_raw_event(&raw).unwrap();

        handle_transfer_impl(&event, &graphite::EventContext::default());
        handle_transfer_impl(&event, &graphite::EventContext::default()); // same id

        assert_eq!(mock::entity_count("Transfer"), 1);
    }
}
}

Constructing Mock Events

RawEthereumEvent has a Default implementation — you only need to set the fields your handler uses.

#![allow(unused)]
fn main() {
RawEthereumEvent {
    tx_hash: [0xab; 32],
    log_index: alloc::vec![0],
    block_number: alloc::vec![1, 0, 0, 0],  // little-endian: block 1
    block_timestamp: alloc::vec![100, 0, 0, 0],
    params: alloc::vec![
        EventParam { name: "from".into(), value: EthereumValue::Address([0xaa; 20]) },
        EventParam { name: "value".into(), value: EthereumValue::Uint(alloc::vec![100]) },
    ],
    ..Default::default()
}
}

EthereumValue Variants

VariantSolidity type
EthereumValue::Address([u8; 20])address
EthereumValue::Uint(Vec<u8>)uint256, uint128, etc. (LE bytes)
EthereumValue::Int(Vec<u8>)int256, int128, etc. (LE signed bytes)
EthereumValue::Bool(bool)bool
EthereumValue::String(String)string
EthereumValue::Bytes(Vec<u8>)bytes
EthereumValue::FixedBytes([u8; N])bytesN
EthereumValue::Array(Vec<EthereumValue>)array types

Event Context Reference

Every handler receives a context parameter alongside the event. It carries the block and transaction data available at the time of indexing.

EventContext

Passed to event handlers (#[handler]) and block handlers (#[handler(block)]).

#![allow(unused)]
fn main() {
pub struct EventContext {
    pub address:                [u8; 20],
    pub log_index:              Vec<u8>,         // LE BigInt bytes
    pub transaction_log_index:  Vec<u8>,
    pub log_type:               Option<String>,

    pub block_hash:             [u8; 32],
    pub block_number:           Vec<u8>,         // LE BigInt bytes
    pub block_timestamp:        Vec<u8>,
    pub block_gas_used:         Vec<u8>,
    pub block_gas_limit:        Vec<u8>,
    pub block_difficulty:       Vec<u8>,
    pub block_total_difficulty: Vec<u8>,
    pub block_base_fee_per_gas: Option<Vec<u8>>, // EIP-1559, None pre-London

    pub tx_hash:                [u8; 32],
    pub tx_index:               Vec<u8>,
    pub tx_from:                [u8; 20],
    pub tx_to:                  Option<[u8; 20]>, // None for contract creation
    pub tx_value:               Vec<u8>,
    pub tx_gas_limit:           Vec<u8>,
    pub tx_gas_price:           Vec<u8>,
    pub tx_nonce:               Vec<u8>,
    pub tx_input:               Vec<u8>,

    pub receipt:                Option<TransactionReceipt>,
}
}

receipt is populated only when receipt = true is set in graphite.toml for the contract.

TransactionReceipt

#![allow(unused)]
fn main() {
pub struct TransactionReceipt {
    pub transaction_hash:   [u8; 32],
    pub transaction_index:  Vec<u8>,
    pub block_hash:         [u8; 32],
    pub block_number:       Vec<u8>,
    pub cumulative_gas_used: Vec<u8>,
    pub gas_used:           Vec<u8>,
    pub contract_address:   Option<[u8; 20]>,
    pub status:             Option<Vec<u8>>,    // 1 = success, 0 = reverted
    pub root:               Option<[u8; 32]>,
    pub logs_bloom:         Vec<u8>,
    pub logs:               Vec<EthereumLog>,
}
}

Enabling Receipt Data

[[contracts]]
name    = "ERC20"
abi     = "abis/ERC20.json"
address = "0x..."
receipt = true   # populate ctx.receipt in handlers

CallContext

Passed to call handlers (#[handler(call)]).

#![allow(unused)]
fn main() {
pub struct CallContext {
    pub from:         [u8; 20],
    pub to:           [u8; 20],
    pub block_hash:   [u8; 32],
    pub block_number: Vec<u8>,
    pub tx_hash:      [u8; 32],
    pub tx_index:     Vec<u8>,
    pub gas:          Vec<u8>,
    pub gas_used:     Vec<u8>,
    pub input:        Vec<u8>,
    pub output:       Vec<u8>,
    pub value:        Vec<u8>,
}
}

FileContext

Passed to file data source handlers (#[handler(file)]).

#![allow(unused)]
fn main() {
pub struct FileContext {
    pub id:      String,    // data source ID
    pub address: [u8; 20],  // address that created this data source
}
}

Using Context Fields in Tests

EventContext::default() constructs a zeroed context for use in tests:

#![allow(unused)]
fn main() {
handle_transfer_impl(&event, &graphite::EventContext::default());
}

To set specific fields:

#![allow(unused)]
fn main() {
let ctx = graphite::EventContext {
    tx_hash: [0xab; 32],
    block_number: alloc::vec![42, 0, 0, 0],
    ..Default::default()
};
handle_transfer_impl(&event, &ctx);
}

CLI Reference

Commands

graphite init

Scaffold a new subgraph project.

graphite init <name> [OPTIONS]
FlagDescription
--network <network>Network name (e.g. mainnet, arbitrum-one). Required.
--from-contract <address>Fetch ABI from Etherscan for this address. Requires ETHERSCAN_API_KEY.

Examples:

# Minimal scaffold
graphite init my-subgraph --network mainnet

# Fetch ABI from Etherscan
ETHERSCAN_API_KEY=yourkey graphite init my-subgraph \
  --from-contract 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
  --network mainnet

Generates: Cargo.toml, graphite.toml, schema.graphql, abis/, src/lib.rs.


graphite codegen

Generate Rust types from ABIs and schema.graphql.

graphite codegen [OPTIONS]
FlagDescription
-c, --config <path>Path to graphite.toml. Defaults to ./graphite.toml.
-w, --watchRe-run codegen on file changes (watches ABIs and schema).

Output: src/generated/ (or the output_dir in graphite.toml).

graphite codegen          # one-shot
graphite codegen --watch  # live reload

graphite manifest

Generate subgraph.yaml from graphite.toml.

graphite manifest [OPTIONS]
FlagDescription
-c, --config <path>Path to graphite.toml. Defaults to ./graphite.toml.
-o, --output <path>Output path. Defaults to ./subgraph.yaml.
graphite manifest
graphite manifest -o deploy/subgraph.yaml

graphite build

Compile the subgraph to WASM.

graphite build [OPTIONS]
FlagDescription
--releaseRelease build (default).
-c, --config <path>Path to graphite.toml.

Runs cargo build --target wasm32-unknown-unknown --release, then copies the WASM to build/ and runs wasm-opt -Oz if available. Output: build/<name>.wasm.

graphite build

graphite test

Run the subgraph's native tests.

graphite test [OPTIONS] [ARGS...]
FlagDescription
--coverageRun with coverage (requires cargo-llvm-cov).
extra argsPassed through to cargo test.
graphite test
graphite test -- transfer_creates_entity  # run a specific test
graphite test -- --nocapture              # show println! output

graphite deploy

Deploy the subgraph to a graph-node or The Graph Studio.

graphite deploy <name> [OPTIONS]
FlagDescription
--node <url>Graph-node deploy endpoint. Required.
--ipfs <url>IPFS endpoint for uploading WASM and schema. Required.
--deploy-key <key>Deploy key (required for The Graph Studio).
--version-label <label>Version label, e.g. v1.0.0 (required for Studio).
-c, --config <path>Path to graphite.toml.

The Graph Studio:

graphite deploy \
  --node https://api.studio.thegraph.com/deploy/ \
  --ipfs https://api.thegraph.com/ipfs/ \
  --deploy-key YOUR_DEPLOY_KEY \
  --version-label v1.0.0 \
  your-subgraph-slug

Local graph-node:

graphite deploy \
  --node http://localhost:8020 \
  --ipfs http://localhost:5001 \
  myname/my-subgraph

The CLI:

  1. Builds the WASM if not already built.
  2. Uploads the WASM, schema, and ABIs to IPFS.
  3. Rewrites subgraph.yaml with IPFS content hashes.
  4. Calls the graph-node subgraph_deploy JSON-RPC endpoint.
  5. Prints the playground and query URLs on success.

graphite.toml Reference

graphite.toml is the single config file for a Graphite project. graphite codegen, graphite manifest, graphite build, and graphite deploy all read from it.

Top-Level Fields

output_dir = "src/generated"   # where codegen writes Rust source
schema     = "schema.graphql"  # path to your GraphQL schema
network    = "mainnet"         # network name
FieldTypeRequiredDescription
output_dirstringyesDirectory for generated Rust source.
schemastringyesPath to schema.graphql.
networkstringyesNetwork name (e.g. mainnet, arbitrum-one, matic).

[[contracts]]

One [[contracts]] section per indexed contract.

[[contracts]]
name        = "ERC20"
abi         = "abis/ERC20.json"
address     = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
start_block = 6082465
receipt     = false               # optional, default false
FieldTypeRequiredDescription
namestringyesContract name. Used to prefix generated types (e.g. ERC20TransferEvent).
abistringyesPath to the ABI JSON file.
addressstringyesContract address to index.
start_blockintegeryesBlock number to start indexing from.
receiptboolnoExpose TransactionReceipt in ctx.receipt. Default false.

[[contracts.event_handlers]]

Declare which ABI events map to which handler functions. These are auto-generated by codegen but can be customised.

[[contracts.event_handlers]]
event   = "Transfer(address,address,uint256)"
handler = "handle_transfer"

[[contracts.call_handlers]]

[[contracts.call_handlers]]
function = "transfer(address,uint256)"
handler  = "handle_transfer_call"
FieldDescription
functionFull function signature, e.g. transfer(address,uint256).
handlerRust function name (without the WASM extern "C" wrapper).

[[contracts.block_handlers]]

# Every block
[[contracts.block_handlers]]
handler = "handle_block"

# Every N blocks
[[contracts.block_handlers]]
handler = "handle_block_polled"
filter  = { kind = "polling", every = 10 }
FieldDescription
handlerRust function name.
filterOptional. { kind = "polling", every = N } to fire every N blocks.

[[templates]]

Dynamic data source templates. Used with the factory pattern. See Dynamic Data Sources.

# Contract template
[[templates]]
name = "Pair"
abi  = "abis/Pair.json"

# IPFS file template
[[templates]]
name    = "NFTMetadata"
kind    = "file/ipfs"
handler = "handle_nft_metadata"
FieldTypeRequiredDescription
namestringyesTemplate name. Passed to data_source::create_contract.
abistringfor contract templatesABI path.
kindstringfor file templates"file/ipfs".
handlerstringfor file templatesHandler function name.

Complete Example

output_dir = "src/generated"
schema     = "schema.graphql"
network    = "arbitrum-one"

[[contracts]]
name        = "Factory"
abi         = "abis/Factory.json"
address     = "0x1234...5678"
start_block = 175000000

[[contracts.event_handlers]]
event   = "PairCreated(address,address,address,uint256)"
handler = "handle_pair_created"

[[templates]]
name = "Pair"
abi  = "abis/Pair.json"

[[templates.event_handlers]]
event   = "Swap(address,uint256,uint256,uint256,uint256,address)"
handler = "handle_swap"

Advanced

Dynamic Data Sources

Dynamic data sources (also called the factory pattern) let you start indexing a new contract address at runtime — typically when a factory contract deploys a new instance.

The classic example is Uniswap V2: when the factory emits PairCreated, you create a new data source for the pair contract so its Swap events get indexed.

Setup

Declare a template in graphite.toml:

[[contracts]]
name        = "Factory"
abi         = "abis/Factory.json"
address     = "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f"
start_block = 10000835

[[contracts.event_handlers]]
event   = "PairCreated(address,address,address,uint256)"
handler = "handle_pair_created"

[[templates]]
name = "Pair"
abi  = "abis/Pair.json"

[[templates.event_handlers]]
event   = "Swap(address,uint256,uint256,uint256,uint256,address)"
handler = "handle_swap"

Factory Handler

In the factory handler, call data_source::create_contract to start indexing the new address:

#![allow(unused)]
fn main() {
use graphite::data_source;

#[handler]
pub fn handle_pair_created(event: &FactoryPairCreatedEvent, _ctx: &graphite::EventContext) {
    let pool_id = addr_hex(&event.pair);

    Pool::new(&pool_id)
        .set_token0(event.token0.to_vec())
        .set_token1(event.token1.to_vec())
        .save();

    // Start indexing events from this pair address using the "Pair" template
    data_source::create_contract("Pair", event.pair);
}
}

Template Handler

Template handlers use data_source::address_current() to find out which instance they're running for:

#![allow(unused)]
fn main() {
#[handler]
pub fn handle_swap(event: &PairSwapEvent, ctx: &graphite::EventContext) {
    let pair_addr = data_source::address_current();
    let pool_id = addr_hex(&pair_addr);

    let swap_id = format!("{}-{}", hex(&event.tx_hash), hex(&event.log_index));
    Swap::new(&swap_id)
        .set_pool(pool_id.clone())
        .set_amount0_in(event.amount0_in.clone())
        .set_amount1_out(event.amount1_out.clone())
        .set_block_number(ctx.block_number.clone())
        .save();
}
}

Data Source API

#![allow(unused)]
fn main() {
use graphite::data_source;

// Create a new contract data source instance
data_source::create_contract("TemplateName", address_bytes);

// Create with context data attached
data_source::create_contract_with_context("TemplateName", address_bytes, context_map);

// Introspect the current data source (inside a template handler)
let addr: [u8; 20]  = data_source::address_current();
let net:  String    = data_source::network_current();
let id:   String    = data_source::id_current();
let ctx:  TypedMap  = data_source::context_current();
}

Testing Dynamic Data Sources

#![allow(unused)]
fn main() {
#[test]
fn pair_created_makes_data_source() {
    mock::reset();

    handle_pair_created_impl(&event, &graphite::EventContext::default());

    mock::assert_contract_data_source_created("Pair", pair_address);
    assert!(mock::has_entity("Pool", &addr_hex(&pair_address)));
}

#[test]
fn swap_increments_count() {
    mock::reset();

    // Create the pool first
    handle_pair_created_impl(&factory_event, &graphite::EventContext::default());

    // Set the data source address for the template handler
    mock::set_current_address(pair_address);

    handle_swap_impl(&swap_event, &graphite::EventContext::default());
    assert_eq!(mock::entity_count("Swap"), 1);
}
}

See the Uniswap V2 example for a complete factory + template subgraph.

File Handlers (IPFS)

File data sources allow you to fetch and index content from IPFS. A typical use case is an NFT contract that stores metadata CIDs on-chain — you index the URI event to get the CID, then the file handler parses the JSON metadata.

Setup

Declare a file template in graphite.toml:

[[contracts]]
name        = "ERC721"
abi         = "abis/ERC721.json"
address     = "0x..."
start_block = 1000000

[[contracts.event_handlers]]
event   = "URI(string,uint256)"
handler = "handle_uri"

[[templates]]
name    = "NFTMetadata"
kind    = "file/ipfs"
handler = "handle_nft_metadata"

Creating a File Data Source

In your event handler, trigger the file fetch by calling data_source::create_file:

#![allow(unused)]
fn main() {
#[handler]
pub fn handle_uri(event: &ERC721URIEvent, ctx: &graphite::EventContext) {
    // Store a pending NFT record
    let token_id = hex(&event.token_id);
    NFT::new(&token_id)
        .set_token_id(event.token_id.clone())
        .set_content_uri(event.uri.clone())
        .save();

    // Trigger the IPFS fetch
    data_source::create_file("NFTMetadata", &event.uri);
}
}

Writing the File Handler

#![allow(unused)]
fn main() {
use graphite::json;

#[handler(file)]
pub fn handle_nft_metadata(content: &[u8], ctx: &graphite::FileContext) {
    // ctx.id — data source ID
    // ctx.address — address of the contract that created this data source

    let value = match json::from_bytes(content) {
        Some(v) => v,
        None => return,
    };

    let name        = json::get_string(&value, "name").unwrap_or_default();
    let description = json::get_string(&value, "description").unwrap_or_default();
    let image       = json::get_string(&value, "image").unwrap_or_default();

    NFTMetadata::new(&ctx.id)
        .set_name(name)
        .set_description(description)
        .set_image(image)
        .save();
}
}

JSON Parsing

#![allow(unused)]
fn main() {
use graphite::json;

let value = json::from_bytes(content)?;

// Get typed fields
let name:    Option<String> = json::get_string(&value, "name");
let count:   Option<i64>    = json::get_i64(&value, "count");
let flag:    Option<bool>   = json::get_bool(&value, "active");
let nested:  Option<&JsonValue> = json::get_object(&value, "attributes");
}

Direct IPFS Access

You can also call ipfs.cat directly inside a regular event handler:

#![allow(unused)]
fn main() {
use graphite::ipfs;

#[handler]
pub fn handle_uri(event: &ERC721URIEvent, ctx: &graphite::EventContext) {
    if let Some(content) = ipfs::cat(&event.uri) {
        // parse content immediately
    }
}
}

Note: ipfs.cat is synchronous and blocks the handler. For large amounts of IPFS content, the file data source template approach is preferred as it allows parallel fetching.

ENS Resolution

#![allow(unused)]
fn main() {
use graphite::ens;

let name: Option<String> = ens::name_by_address(&address_bytes);
}

See the file-ds example for a complete working subgraph.

Contract Calls

Contract calls let you invoke view (read-only) functions on any contract during indexing. They require a running Ethereum node — they are not available in native cargo test without mocking.

Making a Call

#![allow(unused)]
fn main() {
use graphite::ethereum::{self, EthereumValue};
use graphite::call::ContractCall;

#[handler]
pub fn handle_transfer(event: &ERC20TransferEvent, ctx: &graphite::EventContext) {
    // Call balanceOf(address) on the token contract
    let result = ContractCall::new(ctx.address, "balanceOf(address)")
        .arg(EthereumValue::Address(event.to))
        .call();

    if let Some(values) = result {
        let balance = values[0].clone(); // EthereumValue::Uint(...)
        // use balance...
    }
}
}

ContractCall API

#![allow(unused)]
fn main() {
use graphite::call::ContractCall;
use graphite::ethereum::EthereumValue;

let result: Option<Vec<EthereumValue>> = ContractCall::new(
    contract_address,   // [u8; 20]
    "functionName(type1,type2)"  // function signature
)
.arg(EthereumValue::Address(addr))
.arg(EthereumValue::Uint(amount_bytes))
.call();
}

call() returns None if the call reverts or the function is not found. It returns Some(Vec<EthereumValue>) with the decoded return values on success.

ABI Encoding and Decoding

For manual encoding:

#![allow(unused)]
fn main() {
use graphite::ethereum::{self, EthereumValue};

// Encode a value to ABI bytes
let encoded: Vec<u8> = ethereum::encode(&EthereumValue::Uint(value_bytes))?;

// Decode ABI bytes to a value
let value: EthereumValue = ethereum::decode("uint256", &encoded)?;
}

Mocking Contract Calls in Tests

In tests, use mock::set_call_result to provide a return value for a call:

#![allow(unused)]
fn main() {
use graphite::mock;
use graphite::ethereum::EthereumValue;

mock::set_call_result(
    contract_address,
    "balanceOf(address)",
    Some(alloc::vec![EthereumValue::Uint(alloc::vec![200, 0, 0, 0])]),
);

handle_transfer_impl(&event, &graphite::EventContext::default());
}

Pass None to simulate a reverted call.

Notes

  • Contract calls are only available when graph-node has an Ethereum node configured. Not all networks support them.
  • Calls are expensive — they require an archive node for historical state. Use them sparingly.
  • Each call is synchronous and blocks the handler until the result arrives.

Crypto Utilities

All crypto functions run natively in cargo test — no host calls, no mocking required.

keccak256

#![allow(unused)]
fn main() {
use graphite::crypto;

let hash: [u8; 32] = crypto::keccak256(b"hello world");
}

Useful for computing event topics, storage slot keys, and entity IDs derived from content.

sha256

#![allow(unused)]
fn main() {
let hash: [u8; 32] = crypto::sha256(b"hello world");
}

sha3

#![allow(unused)]
fn main() {
let hash: [u8; 32] = crypto::sha3(b"hello world");
}

secp256k1 Recovery

Recover an Ethereum address from a message hash and ECDSA signature:

#![allow(unused)]
fn main() {
let address: Option<[u8; 20]> = crypto::secp256k1_recover(
    &msg_hash,  // [u8; 32]
    &r,         // [u8; 32]
    &s,         // [u8; 32]
    v,          // u8 (recovery id: 0 or 1)
);
}

Returns None if the signature is invalid.

Function Selector

Compute the 4-byte ABI function selector from a signature string:

#![allow(unused)]
fn main() {
let selector: [u8; 4] = crypto::selector("transfer(address,uint256)");
// → [0xa9, 0x05, 0x9c, 0xbb]
}

This is keccak256(sig)[0..4], computed at compile time if used with a constant string.

Event Topic

Compute an event topic from the event signature:

#![allow(unused)]
fn main() {
let topic: [u8; 32] = crypto::keccak256(b"Transfer(address,address,uint256)");
}

Using in Tests

All these functions work identically in tests and in WASM:

#![allow(unused)]
fn main() {
#[test]
fn topic_matches() {
    let topic = crypto::keccak256(b"Transfer(address,address,uint256)");
    assert_eq!(topic[0], 0xdd);  // first byte of the Transfer topic
}
}

Multi-Source Subgraphs

A single WASM binary can index multiple contracts. This is useful when you want to combine related contracts — for example, an ERC20 token and a liquidity pool — in one deployment.

Setup

Add multiple [[contracts]] sections to graphite.toml:

output_dir = "src/generated"
schema     = "schema.graphql"
network    = "mainnet"

[[contracts]]
name        = "ERC20"
abi         = "abis/ERC20.json"
address     = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
start_block = 6082465

[[contracts.event_handlers]]
event   = "Transfer(address,address,uint256)"
handler = "handle_transfer"

[[contracts]]
name        = "ERC721"
abi         = "abis/ERC721.json"
address     = "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D"
start_block = 12287507

[[contracts.event_handlers]]
event   = "Transfer(address,address,uint256)"
handler = "handle_nft_transfer"

Generated Types

graphite codegen generates a separate module for each contract:

src/generated/
├── mod.rs
├── erc20.rs     # ERC20TransferEvent
├── erc721.rs    # ERC721TransferEvent
└── schema.rs    # all entity builders

Note that two contracts can have events with the same name (like Transfer). The generated types are prefixed with the contract name: ERC20TransferEvent, ERC721TransferEvent. They are fully distinct types.

Handler Implementation

#![allow(unused)]
fn main() {
mod generated;
use generated::{
    ERC20TransferEvent, ERC721TransferEvent,
    TokenTransfer, NFTTransfer,
};

#[handler]
pub fn handle_transfer(event: &ERC20TransferEvent, ctx: &graphite::EventContext) {
    let id = format!("{}-{}", hex(&ctx.tx_hash), hex(&ctx.log_index));
    TokenTransfer::new(&id)
        .set_from(event.from.to_vec())
        .set_to(event.to.to_vec())
        .set_value(event.value.clone())
        .save();
}

#[handler]
pub fn handle_nft_transfer(event: &ERC721TransferEvent, ctx: &graphite::EventContext) {
    let id = format!("{}-{}", hex(&ctx.tx_hash), hex(&ctx.log_index));
    NFTTransfer::new(&id)
        .set_from(event.from.to_vec())
        .set_to(event.to.to_vec())
        .set_token_id(event.token_id.clone())
        .save();
}
}

Entity Namespacing

Entity types are global across all contracts in a subgraph — they are defined in schema.graphql, not per contract. Make sure entity type names are unique.

Testing

Tests work identically — mock::entity_count and mock::has_entity count across the whole store:

#![allow(unused)]
fn main() {
#[test]
fn both_handlers_independent() {
    mock::reset();

    handle_transfer_impl(&token_event, &graphite::EventContext::default());
    handle_nft_transfer_impl(&nft_event, &graphite::EventContext::default());

    assert_eq!(mock::entity_count("TokenTransfer"), 1);
    assert_eq!(mock::entity_count("NFTTransfer"), 1);
}
}

See the multi-source example in the repository for a complete working subgraph.

Examples

All examples are in the examples/ directory of the repository and compile to WASM. Each has a full test suite runnable with cargo test.

ExampleWhat it demonstrates
ERC20Basic event handler, single entity type. Live on The Graph Studio (Arbitrum One).
ERC721Multiple event handlers, multiple entity types. Live on The Graph Studio (Arbitrum One).
ERC1155Three handlers: TransferSingle, TransferBatch, URI. Batch processing.
Uniswap V2Factory + template pattern. Dynamic data sources. Counter updates.
File Data SourceIPFS file handlers. JSON parsing.

ERC20

Source: examples/erc20/ Status: Live on The Graph Studio (Arbitrum One)

Indexes ERC20 Transfer events. The simplest possible subgraph — one event, one entity.

Schema

type Transfer @entity {
  id: ID!
  from: Bytes!
  to: Bytes!
  value: BigInt!
  blockNumber: BigInt!
  timestamp: BigInt!
  transactionHash: Bytes!
}

Handler

#![allow(unused)]
fn main() {
pub fn handle_transfer_impl(raw: &RawEthereumEvent) {
    let event = match ERC20TransferEvent::from_raw_event(raw) {
        Ok(e) => e,
        Err(_) => return,
    };

    let id = format!("{}-{}", hex_bytes(&event.tx_hash), hex_bytes(&event.log_index));

    Transfer::new(&id)
        .set_from(event.from.to_vec())
        .set_to(event.to.to_vec())
        .set_value(event.value)
        .set_block_number(event.block_number)
        .set_timestamp(event.block_timestamp)
        .set_transaction_hash(event.tx_hash.to_vec())
        .save();
}
}

Tests

The example includes three tests:

  • transfer_creates_entity — verifies the entity is created with correct field values.
  • transfer_entity_count — verifies upsert behaviour (same tx hash = same ID = 1 entity) and that different tx hashes produce distinct entities.
cd examples/erc20
cargo test

Key Points

  • Entity ID is {tx_hash_hex}-{log_index_hex} — globally unique per log entry.
  • The handler uses the lower-level handle_transfer_impl(raw: &RawEthereumEvent) pattern (no #[handler] macro) — the WASM entry point is written manually. Both approaches work identically.
  • All numeric fields (value, blockNumber, timestamp) are little-endian Vec<u8> BigInt bytes.

ERC721

Source: examples/erc721/ Status: Live on The Graph Studio (Arbitrum One)

Indexes ERC721 NFT Transfer and Approval events. Demonstrates multiple handlers and multiple entity types updating the same Token entity from different events.

Schema

type Token @entity {
  id: ID!
  owner: Bytes!
  approved: Bytes!
}

type Transfer @entity {
  id: ID!
  from: Bytes!
  to: Bytes!
  tokenId: BigInt!
  blockNumber: BigInt!
  timestamp: BigInt!
  transactionHash: Bytes!
}

type Approval @entity {
  id: ID!
  owner: Bytes!
  approved: Bytes!
  tokenId: BigInt!
}

Handlers

Transfer: Creates a Transfer record and upserts the Token owner.

#![allow(unused)]
fn main() {
pub fn handle_transfer_impl(raw: &RawEthereumEvent) {
    let event = ERC721TransferEvent::from_raw_event(raw)?;
    let id = format!("{}-{}", hex_bytes(&event.tx_hash), hex_bytes(&event.log_index));

    Transfer::new(&id)
        .set_from(event.from.to_vec())
        .set_to(event.to.to_vec())
        .set_token_id(event.token_id.clone())
        .set_block_number(event.block_number.clone())
        .set_timestamp(event.block_timestamp.clone())
        .set_transaction_hash(event.tx_hash.to_vec())
        .save();

    // Update the token's current owner
    let token_id_str = hex_bytes(&event.token_id);
    Token::new(&token_id_str)
        .set_owner(event.to.to_vec())
        .set_approved(alloc::vec![0u8; 20])  // clear approval on transfer
        .save();
}
}

Approval: Updates the token's approved address.

#![allow(unused)]
fn main() {
pub fn handle_approval_impl(raw: &RawEthereumEvent) {
    let event = ERC721ApprovalEvent::from_raw_event(raw)?;
    let token_id_str = hex_bytes(&event.token_id);

    Approval::new(&format!("{}-{}", hex_bytes(&event.tx_hash), hex_bytes(&event.log_index)))
        .set_owner(event.owner.to_vec())
        .set_approved(event.approved.to_vec())
        .set_token_id(event.token_id.clone())
        .save();

    if let Some(token) = Token::load(&token_id_str) {
        token.set_approved(event.approved.to_vec()).save();
    }
}
}

Key Points

  • The Token entity is upserted by both handlers — the Transfer handler sets the owner, the Approval handler sets the approved address.
  • Token::load returns Option<Token> — the Approval handler only updates if the token already exists.
  • Approval is cleared on transfer by writing [0u8; 20] as the approved address.
cd examples/erc721
cargo test

ERC1155

Source: examples/erc1155/

Indexes ERC1155 multi-token events: TransferSingle, TransferBatch, and URI. Demonstrates batch event processing and iterating over arrays in event parameters.

Schema

type Token @entity {
  id: ID!
  uri: String
  totalSupply: BigInt!
}

type Transfer @entity {
  id: ID!
  operator: Bytes!
  from: Bytes!
  to: Bytes!
  tokenId: BigInt!
  value: BigInt!
  blockNumber: BigInt!
  transactionHash: Bytes!
}

Handlers

TransferSingle: Single token transfer — straightforward.

#![allow(unused)]
fn main() {
#[handler]
pub fn handle_transfer_single(event: &ERC1155TransferSingleEvent, ctx: &graphite::EventContext) {
    let id = format!("{}-{}", hex(&ctx.tx_hash), hex(&ctx.log_index));
    Transfer::new(&id)
        .set_operator(event.operator.to_vec())
        .set_from(event.from.to_vec())
        .set_to(event.to.to_vec())
        .set_token_id(event.id.clone())
        .set_value(event.value.clone())
        .set_block_number(ctx.block_number.clone())
        .set_transaction_hash(ctx.tx_hash.to_vec())
        .save();
}
}

TransferBatch: Iterates over the ids and values arrays in the event:

#![allow(unused)]
fn main() {
#[handler]
pub fn handle_transfer_batch(event: &ERC1155TransferBatchEvent, ctx: &graphite::EventContext) {
    for (i, (token_id, value)) in event.ids.iter().zip(event.values.iter()).enumerate() {
        let id = format!("{}-{}-{}", hex(&ctx.tx_hash), hex(&ctx.log_index), i);
        Transfer::new(&id)
            .set_operator(event.operator.to_vec())
            .set_from(event.from.to_vec())
            .set_to(event.to.to_vec())
            .set_token_id(token_id.clone())
            .set_value(value.clone())
            .set_block_number(ctx.block_number.clone())
            .set_transaction_hash(ctx.tx_hash.to_vec())
            .save();
    }
}
}

URI: Updates the token's metadata URI:

#![allow(unused)]
fn main() {
#[handler]
pub fn handle_uri(event: &ERC1155URIEvent, _ctx: &graphite::EventContext) {
    let token_id = hex(&event.id);
    let token = Token::load(&token_id).unwrap_or_else(|| Token::new(&token_id));
    token.set_uri(event.value.clone()).save();
}
}

Key Points

  • Array parameters in ERC1155 TransferBatch (ids[], values[]) are decoded as Vec<Vec<u8>> — each inner Vec<u8> is a little-endian BigInt.
  • Loop index i in the batch handler is used to disambiguate entity IDs within a single transaction log.
  • Token::load(...).unwrap_or_else(|| Token::new(...)) is the idiomatic pattern for upsert when you want to preserve existing fields.
cd examples/erc1155
cargo test

Uniswap V2

Source: examples/uniswap-v2/

The definitive factory + template example. Demonstrates dynamic data sources, counter updates, and the data_source::address_current() API.

What It Does

  1. A Factory contract emits PairCreated whenever a new liquidity pool is deployed.
  2. The factory handler creates a Pool entity and calls data_source::create_contract("Pair", pair_address) to start indexing the new pair.
  3. Each pair emits Swap events. The Pair template handler records each swap and increments Pool.swapCount.

Schema

type Pool @entity {
  id: ID!       # pair address (0x-prefixed hex)
  token0: Bytes!
  token1: Bytes!
  swapCount: BigInt!
}

type Swap @entity {
  id: ID!
  pool: Pool!
  amount0In: BigInt!
  amount1In: BigInt!
  amount0Out: BigInt!
  amount1Out: BigInt!
  blockNumber: BigInt!
  timestamp: BigInt!
}

Factory Handler

#![allow(unused)]
fn main() {
#[handler]
pub fn handle_pair_created(event: &FactoryPairCreatedEvent, _ctx: &graphite::EventContext) {
    let pool_id = addr_hex(&event.pair);

    Pool::new(&pool_id)
        .set_token0(event.token0.to_vec())
        .set_token1(event.token1.to_vec())
        .save();

    data_source::create_contract("Pair", event.pair);
}
}

Template Handler

#![allow(unused)]
fn main() {
#[handler]
pub fn handle_swap(event: &PairSwapEvent, ctx: &graphite::EventContext) {
    let pair_addr = data_source::address_current();
    let pool_id = addr_hex(&pair_addr);

    let swap_id = format!("{}-{}", hex_bytes(&event.tx_hash), hex_bytes(&event.log_index));
    Swap::new(&swap_id)
        .set_pool(pool_id.clone())
        .set_amount0_in(event.amount0_in.clone())
        .set_amount1_in(event.amount1_in.clone())
        .set_amount0_out(event.amount0_out.clone())
        .set_amount1_out(event.amount1_out.clone())
        .set_block_number(ctx.block_number.clone())
        .set_timestamp(ctx.block_timestamp.clone())
        .save();

    // Increment pool.swapCount
    if let Some(pool) = Pool::load(&pool_id) {
        let new_count = le_add_one(pool.swap_count());
        pool.set_swap_count(new_count).save();
    }
}
}

Counter Arithmetic

BigInt values are stored as little-endian byte vectors. The le_add_one helper increments them:

#![allow(unused)]
fn main() {
fn le_add_one(bytes: &[u8]) -> Vec<u8> {
    let mut result = bytes.to_vec();
    let mut carry = 1u16;
    for byte in result.iter_mut() {
        let sum = *byte as u16 + carry;
        *byte = sum as u8;
        carry = sum >> 8;
    }
    if carry > 0 {
        result.push(carry as u8);
    }
    result
}
}

Tests

The example has four tests:

  • pair_created_makes_pool_and_data_source — factory handler creates Pool and triggers data source creation.
  • swap_creates_entity_and_increments_pool_count — swap handler creates Swap and updates Pool.swapCount.
  • swap_count_increments_per_swap — two swaps produce swapCount = [2].
  • multiple_pairs_independent — two factory events produce two independent pools.
cd examples/uniswap-v2
cargo test

Key Points

  • data_source::address_current() returns the address of the contract instance that emitted the event — this is how the template handler knows which pool it belongs to.
  • mock::set_current_address(addr) sets this value in tests.
  • mock::assert_contract_data_source_created("Pair", addr) verifies the factory called create_contract.

File Data Source

Source: examples/file-ds/

Demonstrates IPFS file data sources. An ERC721 contract emits a URI event containing an IPFS CID. The file handler fetches and parses the JSON metadata.

What It Does

  1. The ERC721 URI event contains a CID (e.g. ipfs://QmXxx.../metadata.json).
  2. The event handler creates a stub NFT entity and triggers a file data source.
  3. graph-node fetches the content from IPFS and calls the file handler with the raw bytes.
  4. The file handler parses the JSON and populates NFTMetadata.

Schema

type NFT @entity {
  id: ID!
  tokenId: BigInt!
  contentURI: String!
  metadata: NFTMetadata
}

type NFTMetadata @entity {
  id: ID!
  name: String
  description: String
  image: String
}

Event Handler

#![allow(unused)]
fn main() {
#[handler]
pub fn handle_uri(event: &ERC721URIEvent, _ctx: &graphite::EventContext) {
    let token_id = hex(&event.id);

    NFT::new(&token_id)
        .set_token_id(event.id.clone())
        .set_content_uri(event.value.clone())
        .save();

    // Trigger IPFS fetch — graph-node will call handle_nft_metadata when ready
    data_source::create_file("NFTMetadata", &event.value);
}
}

File Handler

#![allow(unused)]
fn main() {
use graphite::json;

#[handler(file)]
pub fn handle_nft_metadata(content: &[u8], ctx: &graphite::FileContext) {
    let value = match json::from_bytes(content) {
        Some(v) => v,
        None => return,
    };

    NFTMetadata::new(&ctx.id)
        .set_name(json::get_string(&value, "name").unwrap_or_default())
        .set_description(json::get_string(&value, "description").unwrap_or_default())
        .set_image(json::get_string(&value, "image").unwrap_or_default())
        .save();
}
}

graphite.toml

[[contracts]]
name        = "ERC721"
abi         = "abis/ERC721.json"
address     = "0x..."
start_block = 1000000

[[contracts.event_handlers]]
event   = "URI(string,uint256)"
handler = "handle_uri"

[[templates]]
name    = "NFTMetadata"
kind    = "file/ipfs"
handler = "handle_nft_metadata"

Key Points

  • File handlers receive raw bytes (&[u8]). Use graphite::json::from_bytes to parse JSON.
  • ctx.id in the file handler contains a unique identifier for the data source instance — use it as the entity ID to link back to the originating entity.
  • File handlers cannot be tested with cargo test in the same way as event handlers — the IPFS fetch is a graph-node operation. Test the JSON parsing logic separately if needed.