diff --git a/serve-hedera-contract-as-graphql/.env.example b/serve-hedera-contract-as-graphql/.env.example new file mode 100644 index 0000000..cae70aa --- /dev/null +++ b/serve-hedera-contract-as-graphql/.env.example @@ -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 diff --git a/serve-hedera-contract-as-graphql/.gitignore b/serve-hedera-contract-as-graphql/.gitignore new file mode 100644 index 0000000..65d7854 --- /dev/null +++ b/serve-hedera-contract-as-graphql/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +package-lock.json diff --git a/serve-hedera-contract-as-graphql/README.md b/serve-hedera-contract-as-graphql/README.md new file mode 100644 index 0000000..7787bbd --- /dev/null +++ b/serve-hedera-contract-as-graphql/README.md @@ -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 diff --git a/serve-hedera-contract-as-graphql/adapter.mjs b/serve-hedera-contract-as-graphql/adapter.mjs new file mode 100644 index 0000000..986de51 --- /dev/null +++ b/serve-hedera-contract-as-graphql/adapter.mjs @@ -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, + }; +} diff --git a/serve-hedera-contract-as-graphql/index.mjs b/serve-hedera-contract-as-graphql/index.mjs new file mode 100644 index 0000000..f78c128 --- /dev/null +++ b/serve-hedera-contract-as-graphql/index.mjs @@ -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 } } }"}'`); +}); diff --git a/serve-hedera-contract-as-graphql/package.json b/serve-hedera-contract-as-graphql/package.json new file mode 100644 index 0000000..c3c27fc --- /dev/null +++ b/serve-hedera-contract-as-graphql/package.json @@ -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" + } +}