Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions serve-hedera-contract-as-graphql/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Hedera network: 'testnet' or 'mainnet'
HEDERA_NETWORK=testnet

# EVM address of the contract you want to expose as GraphQL.
# Default in index.mjs is a live ZkWard testnet vault so the snippet
# runs out-of-the-box; set your own to serve your contract.
VAULT_ADDRESS=0xe7E6fEDce9d72D112137B631E8D51831D30729A9

# Optional HTTP port
PORT=4000
3 changes: 3 additions & 0 deletions serve-hedera-contract-as-graphql/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
.env
package-lock.json
69 changes: 69 additions & 0 deletions serve-hedera-contract-as-graphql/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Serve any Hedera contract as a standardized GraphQL / subgraph endpoint

**The Graph doesn't index Hedera.** This snippet bridges the gap: point at any Hedera contract, get a GraphQL endpoint whose query shape matches a Messari-style standardized subgraph on The Graph. All Graph-native tooling — subgraph MCP servers, playgrounds, GraphiQL, subgraph explorers — works over Hedera contracts unchanged.

## The gap this fills

- Hedera Mirror Node already indexes every contract event on Hedera for free, with sub-second consensus. But it speaks REST/JSON.
- The Graph ecosystem speaks GraphQL and has years of tooling — but doesn't index Hedera. Verified via the [official networks registry](https://networks-registry.thegraph.com/TheGraphNetworksRegistry.json): 129 EVM chains supported, Hedera not among them.

This snippet is the missing glue — a ~200 LOC self-contained adapter that reads Mirror Node and speaks standardized-subgraph GraphQL.

## Run it

```bash
npm install
node index.mjs

# In a second terminal:
curl -sX POST http://localhost:4000/graphql \
-H 'content-type: application/json' \
-d '{"query":"{ pools { id network totalNav memberCount } transactions(first: 3) { type actor amount } _meta { block { number } deployment } }"}'
```

Default `VAULT_ADDRESS` points at a live ZkWard testnet vault so this runs out-of-the-box with real data. Point at your own contract:

```bash
VAULT_ADDRESS=0xYourContract node index.mjs
```

## What ships in the ERC-4626 preset

Any contract with these two event signatures gets the whole standardized query surface:

```solidity
event Deposited(address indexed member, uint256 amount, uint256 shares);
event Withdrawn(address indexed member, uint256 shares, uint256 amount);
```

Supported queries (identical shape to Messari standardized-vaults):

- `pool(id)` / `pools(first, where)`
- `transactions(first, orderBy, orderDirection, where)`
- `_meta { block, deployment, hasIndexingErrors }`

Storage reads (`totalShares`, `totalAssets`, `memberCount`) come from Mirror Node's `eth_call` bridge — no server-side state.

## Same query, both indexing backends

```bash
# Studio-hosted subgraph (Sepolia example):
curl -sX POST https://api.studio.thegraph.com/query/1758819/zkward/v0.1.1 \
-H 'content-type: application/json' \
-d '{"query":"{ pools { id network totalNav } _meta { block { number } deployment } }"}'

# This snippet's endpoint (Hedera):
curl -sX POST http://localhost:4000/graphql \
-H 'content-type: application/json' \
-d '{"query":"{ pools { id network totalNav } _meta { block { number } deployment } }"}'
```

Same query. Same shape. Different indexing backends. That's the moat: one schema, N backends, N chains.

## Extended library

This snippet bundles the adapter inline as `adapter.mjs` to keep it self-contained. The upstream package with more presets, TypeScript types, and HCS-attestation add-ons lives at:

- Package: [`@zkward/hedera-graphql-adapter`](https://github.com/ZkVanguard/zkward-ethglobal/tree/main/packages/hedera-graphql-adapter)
- Reference deployment: https://www.zkward.com/api/subgraph/hedera
- Related MCP server: [`mcp/zkward-vaults`](https://github.com/ZkVanguard/zkward-ethglobal/tree/main/mcp/zkward-vaults) — Claude Desktop / Cursor tool that queries this shape across multiple backends
219 changes: 219 additions & 0 deletions serve-hedera-contract-as-graphql/adapter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Standalone adapter (single-file version of @zkward/hedera-graphql-adapter).
// Source: https://github.com/ZkVanguard/zkward-ethglobal/tree/main/packages/hedera-graphql-adapter
// Bundled here so this snippet is `npm install && node index.mjs` ready.

import { buildSchema, execute, parse, validate } from 'graphql';

// ─── Mirror Node client ────────────────────────────────────────────────────
const MIRROR_HOSTS = {
testnet: 'https://testnet.mirrornode.hedera.com/api/v1',
mainnet: 'https://mainnet.mirrornode.hedera.com/api/v1',
};

async function mirrorFetch(base, path) {
const r = await fetch(base + path);
if (!r.ok) return null;
return r.json();
}

// ─── Precomputed event topic0 hashes (ERC-4626 preset) ─────────────────────
// keccak256(utf8Bytes("EventName(argTypes)"))
const ERC4626_TOPICS = {
Deposited: '0x73a19dd210f1a7f902193214c0ee91dd35ee5b4d920cba8d519eca65a7b488ca',
Withdrawn: '0x92ccf450a286a957af52509bc1c9939d1a6a481783e142e41e2499f0bb66ebc6',
};

// SimpleUsdcVault-style auto-getter selectors (fallback to ERC-20 totalSupply)
const ERC4626_SELECTORS = {
totalShares: '0x3a98ef39',
totalSupply: '0x18160ddd',
totalAssets: '0x01e1d114',
memberCount: '0x11aee380',
};

function topicToAddress(topic) {
if (!topic) return '0x' + '0'.repeat(40);
return '0x' + topic.slice(-40).toLowerCase();
}

function decodeUint(data, wordOffset = 0) {
if (!data || data === '0x') return 0n;
const chunk = data.slice(2 + wordOffset * 64, 2 + (wordOffset + 1) * 64);
return chunk ? BigInt('0x' + chunk) : 0n;
}

function decodeUint256Response(hex) {
if (!hex || hex === '0x') return 0n;
return BigInt(hex.length > 66 ? '0x' + hex.slice(2, 66) : hex);
}

async function readViewUint(base, address, selector, fallback) {
const call = async (sel) => {
const r = await fetch(`${base}/contracts/call`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ to: address, data: sel, estimate: false }),
});
if (!r.ok) return null;
const j = await r.json();
return j.result;
};
const primary = await call(selector);
if (primary && primary !== '0x') return decodeUint256Response(primary);
if (fallback) {
const alt = await call(fallback);
if (alt && alt !== '0x') return decodeUint256Response(alt);
}
return 0n;
}

// ─── Standardized schema (mirrors Messari) ─────────────────────────────────
const TYPEDEFS = /* GraphQL */ `
scalar BigInt
scalar Bytes
enum OrderDirection { asc, desc }
enum TxType { DEPOSIT, WITHDRAW, OTHER }
type Pool {
id: Bytes!
network: String!
totalShares: BigInt!
totalNav: BigInt!
sharePrice: BigInt!
memberCount: Int!
totalFeesCollected: BigInt!
createdAtBlock: BigInt!
createdAtTimestamp: BigInt!
updatedAtBlock: BigInt
updatedAtTimestamp: BigInt
}
type Transaction {
id: Bytes!
pool: Pool!
type: TxType!
actor: Bytes!
amount: BigInt!
shares: BigInt!
sharePrice: BigInt!
blockNumber: BigInt!
timestamp: BigInt!
transactionHash: Bytes!
}
type _Block_ { number: Int! timestamp: Int }
type _Meta_ { block: _Block_! deployment: String! hasIndexingErrors: Boolean! }
input Pool_filter { id: Bytes network: String }
input Transaction_filter { type: TxType actor: Bytes }
type Query {
pool(id: Bytes!): Pool
pools(first: Int = 10, where: Pool_filter): [Pool!]!
transactions(first: Int = 25, orderBy: String, orderDirection: OrderDirection, where: Transaction_filter): [Transaction!]!
_meta: _Meta_
}
`;

// ─── Adapter factory ───────────────────────────────────────────────────────
export function createHederaGraphQLAdapter({ network, contract, mirrorNodeBase }) {
const base = mirrorNodeBase || MIRROR_HOSTS[network];
const vault = contract.toLowerCase();
const networkLabel = `hedera-${network}`;
const DECIMALS = 6n;
const ONE = 10n ** DECIMALS;

async function fetchPool() {
const meta = await mirrorFetch(base, `/contracts/${vault}`);
if (!meta) return null;
const [totalShares, totalAssets, memberCount] = await Promise.all([
readViewUint(base, vault, ERC4626_SELECTORS.totalShares, ERC4626_SELECTORS.totalSupply),
readViewUint(base, vault, ERC4626_SELECTORS.totalAssets),
readViewUint(base, vault, ERC4626_SELECTORS.memberCount),
]);
const sharePrice = totalShares === 0n ? ONE : (totalAssets * ONE) / totalShares;
return {
id: vault,
network: networkLabel,
totalShares: totalShares.toString(),
totalNav: totalAssets.toString(),
sharePrice: sharePrice.toString(),
memberCount: Number(memberCount),
totalFeesCollected: '0',
createdAtBlock: '0',
createdAtTimestamp: '0',
updatedAtBlock: null,
updatedAtTimestamp: String(Math.floor(Date.now() / 1000)),
};
}

async function fetchTransactions(limit, filter = {}) {
const params = new URLSearchParams({ order: 'desc', limit: String(Math.min(limit * 3, 100)) });
const r = await mirrorFetch(base, `/contracts/${vault}/results/logs?${params}`);
const rows = [];
for (const log of r?.logs ?? []) {
const topic0 = (log.topics[0] || '').toLowerCase();
let type = null;
if (topic0 === ERC4626_TOPICS.Deposited) type = 'DEPOSIT';
else if (topic0 === ERC4626_TOPICS.Withdrawn) type = 'WITHDRAW';
if (!type) continue;
if (filter.type && filter.type !== type) continue;
const actor = topicToAddress(log.topics[1]);
if (filter.actor && filter.actor.toLowerCase() !== actor) continue;
const [w0, w1] = [decodeUint(log.data, 0), decodeUint(log.data, 1)];
const amount = type === 'DEPOSIT' ? w0 : w1;
const shares = type === 'DEPOSIT' ? w1 : w0;
const tsSec = parseInt((log.timestamp || '0').split('.')[0], 10);
rows.push({
id: `${log.transaction_hash}-${log.index}`,
pool: vault,
type,
actor,
amount: amount.toString(),
shares: shares.toString(),
sharePrice: '0',
blockNumber: String(log.block_number),
timestamp: String(tsSec),
transactionHash: log.transaction_hash,
});
if (rows.length >= limit) break;
}
return rows;
}

const resolvers = {
Query: {
pool: async (_r, args) => (args.id.toLowerCase() === vault ? fetchPool() : null),
pools: async (_r, args) => {
if (args.where?.id && args.where.id.toLowerCase() !== vault) return [];
if (args.where?.network && args.where.network !== networkLabel) return [];
const p = await fetchPool();
return p ? [p].slice(0, args.first ?? 10) : [];
},
transactions: async (_r, args) => fetchTransactions(args.first ?? 25, args.where),
_meta: async () => {
const txs = await fetchTransactions(1).catch(() => []);
return {
block: { number: txs[0] ? Number(txs[0].blockNumber) : 0, timestamp: Math.floor(Date.now() / 1000) },
deployment: `hedera-mirror-adapter:${vault}`,
hasIndexingErrors: false,
};
},
},
Transaction: { pool: async () => fetchPool() },
};

const schema = buildSchema(TYPEDEFS);
for (const [typeName, fields] of Object.entries(resolvers)) {
const t = schema.getType(typeName);
if (!t || !t.getFields) continue;
const tf = t.getFields();
for (const [k, fn] of Object.entries(fields)) if (tf[k]) tf[k].resolve = fn;
}

return {
async execute({ query, variables, operationName }) {
const doc = parse(query);
const errs = validate(schema, doc);
if (errs.length) return { errors: errs.map((e) => ({ message: e.message })) };
const r = await execute({ schema, document: doc, variableValues: variables, operationName });
return { data: r.data, errors: r.errors?.map((e) => ({ message: e.message })) };
},
getSchemaSDL: () => TYPEDEFS,
};
}
68 changes: 68 additions & 0 deletions serve-hedera-contract-as-graphql/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// serve-hedera-contract-as-graphql
//
// Minimal Express server that exposes ANY Hedera contract as a
// standardized GraphQL / subgraph endpoint. Query shape matches
// Messari's standardized subgraphs on The Graph — so the same query
// works against a Studio-hosted subgraph AND this endpoint unchanged.
//
// Why: The Graph doesn't index Hedera (as of 2026-09; 129 EVM chains
// supported, Hedera not among them). Hedera Mirror Node already indexes
// every contract event. This snippet bridges the two so all Graph-native
// tooling (subgraph MCP servers, playgrounds, GraphiQL) works over
// Hedera contracts without waiting for native support.
//
// Run:
// npm install
// node index.mjs
// curl -sX POST http://localhost:4000/graphql \
// -H 'content-type: application/json' \
// -d '{"query":"{ pools { id network totalNav memberCount } transactions(first: 3) { type actor amount } _meta { block { number } deployment } }"}'
//
// Point it at your own vault by setting VAULT_ADDRESS. Default is
// ZkWard's live testnet vault so this snippet is runnable out of the box.

import express from 'express';
import { createHederaGraphQLAdapter } from './adapter.mjs';

const NETWORK = process.env.HEDERA_NETWORK || 'testnet'; // 'testnet' | 'mainnet'
const VAULT = process.env.VAULT_ADDRESS || '0xe7E6fEDce9d72D112137B631E8D51831D30729A9';

const adapter = createHederaGraphQLAdapter({
network: NETWORK,
contract: VAULT,
});

const app = express();
app.use(express.json());

app.post('/graphql', async (req, res) => {
const result = await adapter.execute({
query: req.body.query,
variables: req.body.variables,
operationName: req.body.operationName,
});
res.json(result);
});

app.get('/graphql/sdl', (_req, res) => {
res.type('text/plain').send(adapter.getSchemaSDL());
});

app.get('/', (_req, res) => {
res.json({
endpoint: 'hedera-graphql-adapter',
network: NETWORK,
vault: VAULT,
graphql: 'POST /graphql',
schema: 'GET /graphql/sdl',
example: 'curl -sX POST /graphql -d {"query":"{ pools { id totalNav memberCount } }"}',
});
});

const port = Number(process.env.PORT || 4000);
app.listen(port, () => {
console.log(`hedera-graphql-adapter live on http://localhost:${port}`);
console.log(` vault: ${VAULT}`);
console.log(` network: ${NETWORK}`);
console.log(` try: curl -sX POST http://localhost:${port}/graphql -H 'content-type: application/json' -d '{"query":"{ pools { id totalNav } _meta { block { number } } }"}'`);
});
17 changes: 17 additions & 0 deletions serve-hedera-contract-as-graphql/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "serve-hedera-contract-as-graphql",
"version": "0.1.0",
"description": "Serve any Hedera contract as a standardized GraphQL / subgraph endpoint via Mirror Node. The Graph doesn't index Hedera — this snippet bridges the gap.",
"type": "module",
"main": "index.mjs",
"scripts": {
"start": "node index.mjs"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"express": "^4.19.2",
"graphql": "^16.9.0"
}
}