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) -> ptrand__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__unpinwith the correct signatures. Graph-node calls__newto 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_stringhandle 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.setandstore.getoperate 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-encodedEthereumEventfrom 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 bygraphite codegenfromschema.graphql— typed structs withnew(),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 | |
store.set / store.get / store.remove / store.getInBlock |
|
ethereum.call (contract view calls) |
|
ethereum.encode / ethereum.decode |
|
ipfs.cat |
|
json.fromBytes |
|
ens.nameByAddress |
|
dataSource.create / createWithContext |
|
crypto.keccak256 (native, no mock required) |
|
BigInt — full arithmetic, bitwise, shifts |
|
BigDecimal — full arithmetic |
|
| All GraphQL scalars (String, Int, Float, Boolean, BigInt, BigDecimal, Bytes, Address, Timestamp, Int8, DateTime) | |
@derivedFrom, immutable entities, non-fatal errors |
|
Block handler filters (polling, every: N) |
|
| Dynamic data sources and factory pattern | |
| Receipt context | |
Native cargo test (no Docker) |
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
-
graph-cli integration. The Graph CLI currently scaffolds AssemblyScript-only projects. Is there appetite for
graph init --language rustto delegate to Graphite, or is a separategraphite initthe right long-term story? -
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?
-
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
- GRC-003: Rust Subgraph Mappings — A Native Rust ABI for graph-node
- graph-node PR #6462 — the original native ABI implementation
- Graphite SDK
- graphite-cli on crates.io
- Substreams documentation — prior art for Rust→WASM in The Graph ecosystem