GRC-004: Adding Rust as a first-class subgraph mapping language

Stage: RFC (Request for Comment)
GRC: 004
Authors: @cargopete (Petko Pavlovski)
Related: GRC-003 · Graphite SDK · graph-node PR #6462

Summary

This GRC proposes recognising Rust as a first-class subgraph mapping language — not by changing graph-node, but by implementing the AssemblyScript ABI in Rust.

The Graphite SDK compiles Rust handlers to WASM that is structurally indistinguishable from AssemblyScript output. The manifest declares language: wasm/assemblyscript. Graph-node requires zero changes. The subgraph deploys like any other.

This is not a design proposal. Two subgraphs are live on The Graph Studio today, indexing on Arbitrum One. The SDK has full feature parity with graph-ts. Everything a production subgraph needs can be written in Rust, tested with cargo test, and deployed with a single command.

Background

GRC-003 proposed adding a native Rust ABI to graph-node — a parallel rust_abi/ module (~1,450 LOC), manifest language dispatch, and a TLV serialization protocol. The PR received constructive feedback: a second ABI means a second surface to maintain, and the graph-node maintainers were understandably cautious about accepting a large outside contribution to a critical code path.

The response to that feedback was a question: what if graph-node didn’t need to change at all?


The Key Insight

Graph-node does not inspect WASM binaries to determine their origin. It calls exported functions by name, passes data via the AssemblyScript memory model, and reads entity data back out through the same model. The “language” is not the WASM — it is the memory layout, allocator protocol, and string encoding the WASM uses.

The AssemblyScript memory model is fully specified:

  • A 20-byte object header (RT class ID, ref count, size)
  • Strings as UTF-16LE with a 4-byte length prefix
  • TypedMap<K, V> as a heap-allocated array of key-value pairs
  • A bump allocator exporting __new(size, rtId) -> ptr and __pin / __unpin

Implementing this in Rust means the WASM output is structurally identical to AssemblyScript output. Graph-node accepts it as a standard subgraph. No heuristics. No dispatch. No new code paths.

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

The concerns raised in GRC-003 — maintenance burden, ABI evolution, chain coverage gaps, namespace decisions — are all moot. There is no new ABI. There is no graph-node change. There is nothing to maintain upstream.


Design

graph-as-runtime

The graph-as-runtime crate is a no_std implementation of the AssemblyScript runtime:

  • Allocator. A bump allocator that exports __new, __pin, and __unpin with the correct signatures. Graph-node calls __new to allocate strings and typed maps; the module owns its own heap.
  • String encoding. All strings passed to and from host functions are UTF-16LE with a 4-byte length prefix. AscString::from_str / to_string handle the conversion transparently.
  • TypedMap. Entities are serialised as AS TypedMap<string, Value> — an array of {key: AscString, value: AscValue} pairs with the correct RT class IDs. store.set and store.get operate on this layout.
  • Host imports. WASM host functions are imported by their exact AS names (store.set, log.log, ethereum.call, etc.) using #[link(wasm_import_module)]. Graph-node matches by name only.

graphite-macros

  • #[handler] generates a #[no_mangle] pub extern "C" entry point that reads an AS-encoded EthereumEvent from the pointer graph-node passes, calls the user’s function, and returns.
  • #[handler(call)] / #[handler(block)] / #[handler(file)] handle the other trigger types.
  • #[derive(Entity)] is generated by graphite codegen from schema.graphql — typed structs with new(), load(), save(), remove(), and builder-pattern setters.

graphite-sdk

The user-facing SDK re-exports everything a handler needs: BigInt, BigDecimal, Address, Bytes, EventContext, crypto, ens, json, ipfs, data_source, mock, and nonfatal_error!. On WASM it calls host FFI; on native (for cargo test) it dispatches to thread-local mocks.

graphite-cli

graphite init my-subgraph                          # Scaffold from scratch
graphite init my-subgraph --from-contract 0x...   # Fetch ABI from Etherscan
graphite codegen                                   # Generate Rust types from ABI + schema.graphql
graphite manifest                                  # Generate subgraph.yaml from graphite.toml
graphite build                                     # cargo build → wasm-opt
graphite test                                      # cargo test
graphite deploy my-name/my-subgraph                # Deploy to local graph-node or The Graph Studio

Developer Experience

A handler in Graphite looks like this:

#![cfg_attr(target_arch = "wasm32", no_std)]
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(&event.tx_hash), event.log_index[0]);
    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();
}

Tests run natively with cargo test — no WASM compiler, no PostgreSQL, no external binaries:

#[test]
fn transfer_creates_entity() {
    graphite::mock::reset();
    let raw = RawEthereumEvent {
        tx_hash: [0xab; 32],
        params: 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(vec![100]) },
        ],
        ..Default::default()
    };
    handle_transfer_impl(
        &ERC20TransferEvent::from_raw_event(&raw).unwrap(),
        &graphite::EventContext::default(),
    );
    assert!(graphite::mock::has_entity("Transfer", "abab...ab-00"));
}
AssemblyScript Graphite (Rust)
Nullable safety Runtime WASM trap Compile-time Option<T>
Closures / iterators Compiler crash Full support
Testing setup Node.js + PostgreSQL + binary cargo test, nothing else
Type errors Silent runtime coercions Compile-time
Debugging Comment-driven bisect Actionable compiler errors
Ecosystem None All of crates.io
Crate deployment n/a cargo install graphite-cli

Implementation Status

This is a working implementation, not a proposal.

Feature parity with graph-ts

Every host function exposed by graph-node is implemented:

Feature Status
Event, call, block, file handlers :white_check_mark:
store.set / store.get / store.remove / store.getInBlock :white_check_mark:
ethereum.call (contract view calls) :white_check_mark:
ethereum.encode / ethereum.decode :white_check_mark:
ipfs.cat :white_check_mark:
json.fromBytes :white_check_mark:
ens.nameByAddress :white_check_mark:
dataSource.create / createWithContext :white_check_mark:
crypto.keccak256 (native, no mock required) :white_check_mark:
BigInt — full arithmetic, bitwise, shifts :white_check_mark:
BigDecimal — full arithmetic :white_check_mark:
All GraphQL scalars (String, Int, Float, Boolean, BigInt, BigDecimal, Bytes, Address, Timestamp, Int8, DateTime) :white_check_mark:
@derivedFrom, immutable entities, non-fatal errors :white_check_mark:
Block handler filters (polling, every: N) :white_check_mark:
Dynamic data sources and factory pattern :white_check_mark:
Receipt context :white_check_mark:
Native cargo test (no Docker) :white_check_mark:

Examples (all tested, all CI’d to WASM)

  • erc20 — ERC20 Transfer indexer. Live on The Graph Studio, Arbitrum One.
  • erc721 — NFT transfer and approval indexer. Live on The Graph Studio, Arbitrum One.
  • erc1155 — Multi-token: TransferSingle, TransferBatch, URI.
  • multi-source — Multiple contracts in one subgraph, one WASM file.
  • file-ds — File data source: ERC721 transfer spawns IPFS metadata handler.
  • uniswap-v2 — Factory + template pattern: tracks pairs and swaps.

Crates

Crate crates.io
graph-as-runtime Published
graphite-macros Published
graphite-sdk Published (imported as graphite)
graphite-cli Published (cargo install graphite-cli)

What This Means for The Graph

Nothing breaks. Existing AS subgraphs are unaffected. The manifest still declares language: wasm/assemblyscript. The network, The Graph Studio, hosted service, and graph-node are all unchanged.

Rust subgraphs work today. Any developer can cargo install graphite-cli, scaffold a project, and deploy to The Graph Studio without waiting for a protocol upgrade, a graph-node release, or a governance decision.

The maintenance concern from GRC-003 is resolved. There is no second ABI in graph-node. There is no upstream code to accept. There is nothing for the protocol to maintain. Graphite is a library, not a fork.

The developer experience concern is also resolved. The GRC-003 PR feedback noted that a two-ABI graph-node would require both teams to stay in sync. This approach requires no coordination — graph-node can release independently, and Graphite tracks the AS ABI by matching the TypedMap layout and host function signatures, which are stable by definition (changing them would break all existing subgraphs).


Open Questions

  1. graph-cli integration. The Graph CLI currently scaffolds AssemblyScript-only projects. Is there appetite for graph init --language rust to delegate to Graphite, or is a separate graphite init the right long-term story?

  2. Long-term ABI stability. The AS ABI is stable by necessity — breaking it would break all existing subgraphs. Is there any planned change to the graph-node runtime (e.g., a new allocation protocol, a new string encoding) that Graphite should be aware of?

  3. Chain coverage. The Graphite SDK currently covers Ethereum (all trigger types). Are there non-EVM chains where community members would want Rust support? The same AS-ABI approach applies — the trigger serialization for each chain would need to be implemented in graph-as-runtime.


References

full docs - What is Graphite? - Graphite ; next order of business for me is to heavily improve the devex, we gotta get 10x better than the AS developer experience :flexed_biceps: