diff --git a/components/SubgraphEditor/Editor.tsx b/components/SubgraphEditor/Editor.tsx index 77a576f..e62ebe9 100644 --- a/components/SubgraphEditor/Editor.tsx +++ b/components/SubgraphEditor/Editor.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react' -import { ViewPort, Top, Fill, Bottom, BottomResizable, Right } from 'react-spaces' +import { ViewPort, Top, Fill, Bottom, BottomResizable, Right, RightResizable } from 'react-spaces' import { useRouter } from 'next/router' import styled from 'styled-components' import CodeEditor from 'components/CodeEditor' @@ -19,6 +19,7 @@ import ImageLibrary from './ImageLibrary/ImageLibrary' import { useEditorState } from 'hooks/editor-state' import { WalletButton, Header, HeaderRight } from 'components/layouts' import { useGeneratedFiles } from 'hooks/useGeneratedFiles' +import SubgraphTester from './SubgraphTester' const CloseButton = styled.button` background: none; @@ -97,6 +98,7 @@ const Editor: React.FC = () => { const plausible = usePlausible() const [subgraphId, setSubgraphId] = useEditorState('subgraph-file') const [tab, setTab] = useState(SCHEMA_FILE_NAME) + const [showTest, setShowTest] = useState(false) const { saveSchema, saveMapping, subgraph } = useLocalSubgraph(subgraphId) @@ -181,6 +183,7 @@ const Editor: React.FC = () => { /> + @@ -214,6 +217,12 @@ const Editor: React.FC = () => { )} + {subgraph && showTest && ( + + + + )} + {bottomView !== BottomView.NONE && ( diff --git a/components/SubgraphEditor/SubAdapterPreview.tsx b/components/SubgraphEditor/SubAdapterPreview.tsx deleted file mode 100644 index 8f44cd5..0000000 --- a/components/SubgraphEditor/SubAdapterPreview.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useState } from 'react' -import styled from 'styled-components' -import { Adapter } from '@cryptostats/sdk' -import Attribute from '../Attribute' -import { IPFS_GATEWAY } from 'resources/constants' - -const Container = styled.div`` - -const Header = styled.div` - border-top: solid 1px #4a4a4d; - border-bottom: solid 1px #4a4a4d; - padding: 16px; - background: #2f2f2f; - margin: 0 -16px; - cursor: pointer; - font-size: 14px; - display: flex; - align-items: center; - - &:hover { - background: #262626; - } -` - -const Body = styled.div` - padding: var(--spaces-4) var(--spaces-2); -` - -const Value = styled.pre` - white-space: pre-wrap; - margin: 4px 0 10px; - font-size: 14px; -` - -const Icon = styled.img<{ size?: string }>` - width: auto; - margin-right: 16px; - - ${({ size }) => - size === 'small' - ? ` - max-height: 16px; - margin-right: 16px; - ` - : ``} - ${({ size }) => - size === 'large' - ? ` - max-height: 32px; - margin-right: 32px; - ` - : ``} -` - -interface SubAdapterPreviewProps { - subadapter: Adapter - openByDefault: boolean -} - -const SubAdapterPreview: React.FC = ({ subadapter, openByDefault }) => { - const [open, setOpen] = useState(openByDefault) - - // @ts-ignore - const { name, ...metadata } = subadapter.metadata.metadata - - if (!open) { - return ( -
setOpen(true)}> - {metadata.icon?.cid && ( - - )} - {name || subadapter.id} - {metadata.subtitle ? ` - ${metadata.subtitle}` : null} -
- ) - } - - return ( - -
setOpen(false)}> - {metadata.icon?.cid && ( - - )} - {name || subadapter.id} - {metadata.subtitle ? ` - ${metadata.subtitle}` : null} -
- - - {Object.entries(metadata).map(([key, val]: [string, any]) => ( - - {val?.cid ? ( -
- -
- ) : ( - {JSON.stringify(val, null, 2)} - )} -
- ))} - -
- ) -} - -export default SubAdapterPreview diff --git a/components/SubgraphEditor/SubAdapterTest.tsx b/components/SubgraphEditor/SubAdapterTest.tsx deleted file mode 100644 index bb4b14e..0000000 --- a/components/SubgraphEditor/SubAdapterTest.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from 'react' -import styled from 'styled-components' -import { Adapter } from '@cryptostats/sdk' -import QueryForm from './QueryForm' -import { useEditorState } from 'hooks/editor-state' - -const Container = styled.div`` - -const Header = styled.div` - border-top: solid 1px #4a4a4d; - border-bottom: solid 1px #4a4a4d; - padding: 16px; - background: #2f2f2f; - cursor: pointer; - - &:hover { - background: #262626; - } -` - -const Icon = styled.img<{ size?: string }>` - width: auto; - margin-right: 16px; - - ${({ size }) => - size === 'small' - ? ` - max-height: 16px; - margin-right: 16px; - ` - : ``} - ${({ size }) => - size === 'large' - ? ` - max-height: 32px; - margin-right: 32px; - ` - : ``} -` - -interface SubAdapterPreviewProps { - subadapter: Adapter - openByDefault: boolean -} - -const SubAdapterTest: React.FC = ({ subadapter, openByDefault }) => { - const [fileName] = useEditorState('open-file') - const [open, setOpen] = useEditorState(`subtest-${fileName}-${subadapter.id}-open`, openByDefault) - - // @ts-ignore - const { name, subtitle, ...metadata } = subadapter.metadata.metadata - - if (!open) { - return ( -
setOpen(true)}> - {metadata.icon?.cid && ( - - )} - {name || subadapter.id} - {subtitle ? ` - ${subtitle}` : null} -
- ) - } - - const queries = Object.entries(subadapter.queries) - - return ( - -
setOpen(false)}> - {metadata.icon?.cid && ( - - )} - {name || subadapter.id} - {subtitle ? ` - ${subtitle}` : null} -
- -
- {queries.map(([queryName, fn]: [string, (...params: any[]) => Promise]) => ( - - ))} -
-
- ) -} - -export default SubAdapterTest diff --git a/components/SubgraphEditor/SubgraphTester.tsx b/components/SubgraphEditor/SubgraphTester.tsx new file mode 100644 index 0000000..b90657b --- /dev/null +++ b/components/SubgraphEditor/SubgraphTester.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import styled from 'styled-components' +import { SubgraphData } from 'hooks/local-subgraphs' +import { useSubgraphRunner } from 'hooks/useSubgraphRunner' + +const Container = styled.div`` + +const Header = styled.div` + border-top: solid 1px #4a4a4d; + border-bottom: solid 1px #4a4a4d; + padding: 16px; + background: #2f2f2f; + margin: 0 -16px; + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + + &:hover { + background: #262626; + } +` + +const Body = styled.div` + padding: var(--spaces-4) var(--spaces-2); +` + +interface SubgraphTesterProps { + subgraph: SubgraphData +} + +const SubgraphTester: React.FC = ({ subgraph }) => { + const { compile } = useSubgraphRunner(subgraph) + + return ( + +
Test
+ + + + +
+ ) +} + +export default SubgraphTester diff --git a/hooks/useSubgraphRunner.ts b/hooks/useSubgraphRunner.ts new file mode 100644 index 0000000..daefc57 --- /dev/null +++ b/hooks/useSubgraphRunner.ts @@ -0,0 +1,157 @@ +import { useEffect, useState } from 'react' +import { generateContractFile, generateContractHelpers, generateSchemaASHelpers, generateSchemaFile } from 'utils/graph-file-generator' +import { DEFAULT_MAPPING, SubgraphData } from './local-subgraphs' + +// @ts-ignore +import helperCode from '!raw-loader!resources/graph-as-helper-code.txt' + +export enum CompileState { + UNCOMPILED, + COMPILING, + READY, + OUTDATED, +} + +export const useSubgraphRunner = (subgraph: SubgraphData | null) => { + const [compileState, setCompileState] = useState(CompileState.UNCOMPILED) + + useEffect(() => { + setCompileState(CompileState.OUTDATED) + }, [subgraph]) + + const compile = async () => { + if (!subgraph) { + return + } + try { + const { compileAs } = await import('utils/as-compiler') + + const libraries: { [name: string]: string } = {} + + let mappingCode = subgraph.mappings[DEFAULT_MAPPING] + '\n' + helperCode + + for (const contract of subgraph.contracts) { + const code = await generateContractFile(contract.abi) + libraries[`contracts/${contract.name}.ts`] = code + mappingCode += generateContractHelpers(contract.name, contract.abi) + } + + libraries['schema/index.ts'] = await generateSchemaFile(subgraph.schema) + + mappingCode += await generateSchemaASHelpers(subgraph.schema) + console.log(mappingCode) + + const { binding, binary } = await compileAs(mappingCode, { libraries, bindings: true }) + console.log(binding) + const binding2 = binding! + .replace('{ exports }', '{ instance: { exports } }') + .replace(/Bytes,/g, 'imports.Bytes,') + .replace(/BigInt,/g, 'imports.Bytes,') + .replace(/Address,/g, 'imports.Address,') + + const mod = await import(/* webpackIgnore: true */`data:text/javascript;charset=utf-8,${encodeURIComponent(binding2)}`) + console.log(mod) + + class Address extends Uint8Array {} + class BigInt extends Uint8Array {} + class Bytes extends Uint8Array {} + + const i = await mod.instantiate(binary, { + conversion: { + typeConversion: { + stringToH160(s: string) { + return i.makeAddress(s) + }, + bytesToHex(bytes: Uint8Array) { + return '0x' + Buffer.from(bytes).toString('hex'); + }, + }, + }, + numbers: {}, + index: { + store: { + set(type: string, id: string, ref: any) { + console.log(`Creating ${type} with id ${id}`, ref) + //@ts-ignore + console.log(i.getPair(ref)) + // window.mem = Buffer.from(i.memory.buffer).toString('hex') + }, + }, + }, + json: { + json: { + fromBytes(bytes: Uint8Array) { + console.log(bytes, Buffer.from(bytes).toString()) + return JSON.parse(Buffer.from(bytes).toString()) + }, + }, + }, + Address, + BigInt, + Bytes, + }) + console.log(i) + i.test() + + //@ts-ignore + window.mod = i + //@ts-ignore + window.toHex = b => '0x' + Buffer.from(b).toString('hex'); + // i.handlePairCreated({ params: {}, transaction: { from: { toHex: () => '' } } }) + + const block = i.___cs_generate_eth_block({ + hash: i.___cs_generate_Bytes('0x0000000000000000000000000000000000000000000000000000000000000000'), + parentHash: i.___cs_generate_Bytes('0x0000000000000000000000000000000000000000000000000000000000000000'), + unclesHash: i.___cs_generate_Bytes('0x'), + author: i.___cs_generate_Address('0x0000000000000000000000000000000000000000'), + stateRoot: i.___cs_generate_Bytes('0x0000000000000000000000000000000000000000000000000000000000000000'), + transactionsRoot: i.___cs_generate_Bytes('0x0000000000000000000000000000000000000000000000000000000000000000'), + receiptsRoot: i.___cs_generate_Bytes('0x0000000000000000000000000000000000000000000000000000000000000000'), + number: i.___cs_generate_BigInt(0), + gasUsed: i.___cs_generate_BigInt(0), + gasLimit: i.___cs_generate_BigInt(0), + timestamp: i.___cs_generate_BigInt(0), + difficulty: i.___cs_generate_BigInt(0), + totalDifficulty: i.___cs_generate_BigInt(0), + size: i.___cs_generate_BigInt(0), + baseFeePerGas: i.___cs_generate_BigInt(0), + }) + + const tx = i.___cs_generate_eth_tx({ + hash: i.___cs_generate_Bytes('0x0000000000000000000000000000000000000000000000000000000000000000'), + index: i.___cs_generate_BigInt(0), + from: i.___cs_generate_Address('0x0000000000000000000000000000000000000000'), + to: i.___cs_generate_Address('0x0000000000000000000000000000000000000000'), + value: i.___cs_generate_BigInt(0), + gasLimit: i.___cs_generate_BigInt(0), + gasPrice: i.___cs_generate_BigInt(0), + input: i.___cs_generate_Bytes('0x0000000000000000000000000000000000000000000000000000000000000000'), + nonce: i.___cs_generate_BigInt(0), + }) + + const params = i.___cs_generate_EventParamArray_from_JSON(JSON.stringify([ + { name: 'pair', type: 'address', value: '0x0000000000000000000000000000000000000000' }, + { name: 'token0', type: 'address', value: '0x0000000000000000000000000000000000000000' }, + { name: 'token1', type: 'address', value: '0x0000000000000000000000000000000000000000' }, + ])) + + const event = i.___cs_generate_UniV2Factory_PairCreated( + i.___cs_generate_Address('0x0000000000000000000000000000000000000000'), + i.___cs_generate_BigInt(0), // block log index + i.___cs_generate_BigInt(0), // tx log index + null, // Logtype + block, + tx, + params, + ) + console.log(event) + } catch (e: any) { + console.error(e) + } + } + + return { + compileState, + compile, + } +} diff --git a/resources/graph-as-helper-code.txt b/resources/graph-as-helper-code.txt new file mode 100644 index 0000000..21f550b --- /dev/null +++ b/resources/graph-as-helper-code.txt @@ -0,0 +1,123 @@ +import { + BigInt as ___cs_graph_BigInt, + Address as ___cs_graph_Address, + Bytes as ___cs_graph_Bytes, + ethereum as ___cs_graph_eth, + json as ___cs_graph_json, + JSONValueKind as ___cs_graph_JSONValueKind, +} from '@graphprotocol/graph-ts' + +export function ___cs_generate_Bytes(s: string): ___cs_graph_Bytes { + return ___cs_graph_Bytes.fromHexString(s); +} + +export function ___cs_generate_BigInt(n: i32): ___cs_graph_BigInt { + return ___cs_graph_BigInt.fromI32(n); +} + +export function ___cs_generate_Address(s: string): ___cs_graph_Address { + return ___cs_graph_Address.fromString(s); +} + +class ___cs_plain_eth_block { + hash: ___cs_graph_Bytes; + parentHash: ___cs_graph_Bytes; + unclesHash: ___cs_graph_Bytes; + author: ___cs_graph_Address; + stateRoot: ___cs_graph_Bytes; + transactionsRoot: ___cs_graph_Bytes; + receiptsRoot: ___cs_graph_Bytes; + number: ___cs_graph_BigInt; + gasUsed: ___cs_graph_BigInt; + gasLimit: ___cs_graph_BigInt; + timestamp: ___cs_graph_BigInt; + difficulty: ___cs_graph_BigInt; + totalDifficulty: ___cs_graph_BigInt; + + size: ___cs_graph_BigInt | null; + baseFeePerGas: ___cs_graph_BigInt | null; +} + +export function ___cs_generate_eth_block(block: ___cs_plain_eth_block): ___cs_graph_eth.Block { + return new ___cs_graph_eth.Block( + block.hash, + block.parentHash, + block.unclesHash, + block.author, + block.stateRoot, + block.transactionsRoot, + block.receiptsRoot, + block.number, + block.gasUsed, + block.gasLimit, + block.timestamp, + block.difficulty, + block.totalDifficulty, + + // These two can normally be null, so no default + block.size, + block.baseFeePerGas, + ); +} + +class ___cs_plain_eth_tx { + hash: ___cs_graph_Bytes; + index: ___cs_graph_BigInt; + from: ___cs_graph_Address; + to: ___cs_graph_Address | null; + value: ___cs_graph_BigInt; + gasLimit: ___cs_graph_BigInt; + gasPrice: ___cs_graph_BigInt; + input: ___cs_graph_Bytes; + nonce: ___cs_graph_BigInt; +} + +export function ___cs_generate_eth_tx(tx: ___cs_plain_eth_tx): ___cs_graph_eth.Transaction { + return new ___cs_graph_eth.Transaction( + tx.hash, + tx.index, + tx.from, + tx.to, + tx.value, + tx.gasLimit, + tx.gasPrice, + tx.input, + tx.nonce, + ); +} + +export function ___cs_generate_EventParamArray_from_JSON(jsonParams: string): Array<___cs_graph_eth.EventParam> { + let parsed = ___cs_graph_json.fromString(jsonParams); + if (parsed.kind != ___cs_graph_JSONValueKind.ARRAY) { + throw new Error('Expected JSON array'); + } + + let input = parsed.toArray(); + let output = new Array<___cs_graph_eth.EventParam>(input.length); + + for (let i = 0; i < input.length; i += 1) { + let obj = input[i].toObject(); + let name = obj.get('name')!.toString(); + let type = obj.get('type')!.toString(); + let valueNode = obj.get('value')!; + let value: ___cs_graph_eth.Value; + + if (type == 'address') { + value = ___cs_graph_eth.Value.fromAddress(___cs_graph_Address.fromString(valueNode.toString())); + } else if (type == 'string') { + value = ___cs_graph_eth.Value.fromString(valueNode.toString()); + } else if (type == 'bigint') { + let str = valueNode.toString() + // TODO: check if typed + value = ___cs_graph_eth.Value.fromUnsignedBigInt(BigInt.fromString(str)); + } + + output[i] = new ___cs_graph_eth.EventParam(name, value); + } + + return output; +} + +class ___cs_PlainJSON + +export function ___cs_plainJSONToJSON(plainJson: ___cs_PlainJSON): ___cs_graph_json diff --git a/utils/as-compiler.ts b/utils/as-compiler.ts index 36ceeb3..3c341c8 100644 --- a/utils/as-compiler.ts +++ b/utils/as-compiler.ts @@ -14,13 +14,23 @@ for (let filename of context.keys()) { interface CompilerOptions { libraries?: { [name: string]: string } + bindings?: boolean } -export async function compileAs(tsCode: string, { libraries }: CompilerOptions = {}) { +export async function compileAs( + tsCode: string, + { libraries, bindings }: CompilerOptions = {} +): Promise<{ binary: Uint8Array, binding: string | null }> { const sources: any = { 'input.ts': tsCode, } - var argv = ['--outFile', 'binary', '--textFile', 'text'] + const argv = ['--outFile', 'binary', '--textFile', 'text'] + + if (bindings) { + argv.push('--bindings') + argv.push('raw') + argv.push('--exportRuntime') + } const output: any = {} const result = await asc.main([...argv, ...Object.keys(sources)], { @@ -57,7 +67,10 @@ export async function compileAs(tsCode: string, { libraries }: CompilerOptions = throw new Error(result.stderr.toString()) } - return output.binary + return { + binary: output.binary, + binding: output['binary.js'] || null, + } } export async function loadAsBytecode(bytecode: Uint8Array) { diff --git a/utils/deploy-subgraph.ts b/utils/deploy-subgraph.ts index 7de488b..bd2f152 100644 --- a/utils/deploy-subgraph.ts +++ b/utils/deploy-subgraph.ts @@ -90,14 +90,14 @@ export async function* deploySubgraph( libraries['schema/index.ts'] = await generateSchemaFile(subgraph.schema) - const compiled = await compileAs(subgraph.mappings[DEFAULT_MAPPING], { libraries }) + const { binary } = await compileAs(subgraph.mappings[DEFAULT_MAPPING], { libraries }) yield { status: STATUS.IPFS_UPLOAD, file: 'Mapping', } - const mappingCID = await uploadToIPFS(compiled, 'mapping.wasm') + const mappingCID = await uploadToIPFS(binary, 'mapping.wasm') const dataSources: any[] = [] diff --git a/utils/graph-file-generator.ts b/utils/graph-file-generator.ts index f515483..0afd315 100644 --- a/utils/graph-file-generator.ts +++ b/utils/graph-file-generator.ts @@ -1,3 +1,4 @@ +import { Kind, NamedTypeNode, NonNullTypeNode, ObjectTypeDefinitionNode } from 'graphql' import immutable from 'immutable' export async function generateContractFile(abi: any) { @@ -12,6 +13,46 @@ export async function generateContractFile(abi: any) { return [...codegen.generateModuleImports(), ...codegen.generateTypes()].join('\n') } +export function generateContractHelpers(contractName: string, abi: any) { + let code = '\n' + const imports: string[] = [] + + for (const item of abi) { + if (item.type === 'event') { + console.log(item) + const adjustedName = `___cs_event_${item.name}` + imports.push(`${item.name} as ${adjustedName}`) + + // for (const input of item.inputs) { + // } + + code += `export function ___cs_generate_${contractName}_${item.name}( + address: Address, + logIndex: BigInt, + transactionLogIndex: BigInt, + logType: string | null, + block: ___cs_graph_eth.Block, + tx: ___cs_graph_eth.Transaction, + params: Array<___cs_graph_eth.EventParam>, + ): ${adjustedName} { + return new ${adjustedName}( + address, + logIndex, + transactionLogIndex, + logType, + block, + tx, + params, + ); + }` + } + console.log(item) + } + + const importLine = `import {${imports.join(',')}} from './contracts/${contractName}'` + return `\n${importLine}\n${code}\n` +} + export async function generateSchemaFile(schemaCode: string) { const [gql, { default: SchemaCodeGenerator }] = await Promise.all([ import('graphql/language'), @@ -26,3 +67,50 @@ export async function generateSchemaFile(schemaCode: string) { return [...generator.generateModuleImports(), ...generator.generateTypes()].join('\n') } + +function getRawType(type: string): string { + switch (type) { + case 'ID': + case 'String': + return 'string' + + default: + console.warn(`Unknown type ${type}`) + return type + } +} + +export async function generateSchemaASHelpers(schemaCode: string) { + const gql = await import('graphql/language') + const parsed = gql.parse(schemaCode) + + const imports: string[] = [] + + let code = '\n' + + for (const definition of parsed.definitions) { + if (definition.kind === Kind.OBJECT_TYPE_DEFINITION) { + const entityDefinition = definition as ObjectTypeDefinitionNode + const name = entityDefinition.name.value + const adjustedName = `___cs_entity_${name}` + const plainClassName = `___cs_entity_plain_${name}` + + imports.push(`${name} as ${adjustedName}`) + + let plainClassDef = `class ${plainClassName} {\n` + let getterClassDef = `export function ___cs_get${name}(entity: ${adjustedName}): ${plainClassName} { + return {\n` + + for (const field of entityDefinition.fields || []) { + const type = getRawType(((field.type as NonNullTypeNode).type as NamedTypeNode).name.value) + plainClassDef += `${field.name.value}: ${type};\n` + getterClassDef += `${field.name.value}: entity.${field.name.value},\n` + } + + code += `${plainClassDef}}\n\n${getterClassDef}};\n}\n` + } + } + + const importLine = `import {${imports.join(',')}} from './schema';` + return `${importLine}\n${code}` +}