# zkGraph: a simple example, step by step In this tutorial we are going to create a new zkGraph. The zkGraph that we will build together will simply add two numbers together. As we explain in the next section, it's actually more than that: our zkGraph will really retrieve two numbers from the Ethereum (Sepolia) blockchain (this is our **on-chain data**), and *verifiably* add them together (this is our **off-chain computation**), generating a *proof* that the addition was done correctly. While verifiably adding two numbers may not seem like the most important or exciting thing to do with this technology, the idea of this guide is just to walk through the simplest non-trivial example to give you an idea of what a zkGraph is, what it does, and the development flow for building them. After this it should be easier to create more interesting examples of zkGraphs for your own use cases. ### Introduction: what is a zkGraph? A zkGraph is a program that can be run on the zkOracle network. An analogy is that a zkGraph is to the zkOracle network what a Smart Contract is to the Ethereum network. It's more than that, however. When a developer writes a zkGraph, the logic that the developer writes, in plain AssemblyScript code, is compiled to an arithmetic circuit via zkWasm. This makes the correct execution and results of the code completely verifiable using succinct non-interactive arguments of knowledge (SNARKs). The developer can define an arbitrary computation in AssemblyScript, and the zkOracle network can then execute the computation within an arithmetic circuit, and generate a proof that the computation was carried out correctly. The results of the computation can then be published on-chain and verified by the universal verifier. The point is that zkGraphs allow decentralized off-chain computations that are entirely trustless. You don't have to trust that the zkOracle network computed your results correctly, because the zkOracle network provides a **proof** that the computation was done correctly. ### A note on prerequisites If arithmetic circuits, SNARKs and so on are unfamiliar to you, that's OK. One of the advantage of using zkGraph is that developers can write code that is entirely verifiable without having to know the particulars of arithmetic circuits etc. If you're not familiar with AssemblyScript, that's fine, too: AssemblyScript is essentially a TypeScript library that allows you to write code compatible with WebAssembly (also known as wasm). TypeScript, in turn, is just JavaScript with explicit types. So if you know how to code in JavaScript, you are ready to begin writing zkGraphs, with very few adjustments. In this simple example we will not be writing any really advanced JavaScript code, so even if you are a beginner with JavaScript, by following this tutorial you should be able to create a working zkGraph. This guide is meant to be as self-contained as possible, so that it can be accessible to as many people as possible, wherever they are in their web3 development journey. If you are having issues or believe that more explanation of certain points is necessary, feel free to email me at levi@hyperoracle.io and I will try to update the guide to incorporate your suggestions. ### Initial instalation This instalation was performed on a fresh MacOS. The steps should be essentially the same on a Linux operating system. If you're using Windows, you may want to install GitBash first. GitBash allows Windows users to run terminals that will run the same commands as a Unix (Linux or MacOS) operating system. First, you will want to install the latest stable versions of node and npm. On MacOS, you can do this simply by visiting https://nodejs.org/en/download and selecting the MacOS installer. This should install npm as well. In this guide, we are using node version 20.10.0 and npm version 10.2.3. You will also want to install typescript. To install it using npm, run the command `sudo npm install typescript -g` . **Note:** There are two ways of installing zkGraph. One is by cloning zkgraph-template, and the other is by installing zkgraph-cli. As of December 6, 2023, zkgraph-cli has the most up-to-date code, so that is the method that we will use in this guide. To install zkgraph-cli, you can use yarn, npm, pnpm, or the CLI. In this guide we will be using the npm instalation method. First, you will need to run the following command: `npm install @hyperoracle/zkgraph-cli@latest` As of December 7, 2023, @latest refers to zkGraph version 1.0.0. Next, run `npm create zkgraph@latest` ### Creating your first zkGraph project At this point you will see something like this: ![Screen Shot 2023-12-06 at 4.58.44 PM](https://hackmd.io/_uploads/SJGYbKJIp.png) We will name our zkGraph project `zkgraph-add` and select "Event" as our template. In this example, we will read information (two numbers) from an event emitted on the Ethereum (Sepolia) blockchain and use that data to compute something else (their sum). Now it will ask you to input your private key. This will be the private key of an Ethereum wallet that you own. For this tutorial, we highly recommend that you create a new wallet on MetaMask and load it *only* with Sepolia testnet ETH from a testnet faucet. Once you have this wallet, you can copy and paste the private key into the terminal. It will not display on the terminal, instead appearing as a string of asterisks. When the zkGraph project folder is generated, the private key that you enter at this step is stored in the `zkgraph.config.ts` file. And `zkgraph.config.ts` is included as a line in the `.gitignore` file, which means that (as long as you don't remove this line from the `.gitignore` file), even when you push edits to your zkGraph to GitHub, your private key will not be pushed to GitHub. Next it will ask you for a Pinata JWT. It is possible to skip this step and fill in the Pinata JWT later in the `zkgraph.config.ts` file: if you want to do so, you can hit "Enter", but you will need to input the Pinata JWT later when it comes time to publish your zkGraph. We will go ahead and obtain a Pinata JWT to use now. To obtain a Pinata JWT, you will need to go to https://www.pinata.cloud and either sign up, or log in if you already have an account. Once you are logged in, go to "API Keys" and click "New Key". You can name your key (in this case let's name it `zkgraph-add`, the same as our project). **Important:** Before you go on to generate the API key, be aware that Pinata will show your API key and JWT token to you only once, right after you generate it. Do not close the window that pops up after you generate your API key until you have copied its contents to a safe location. Now check the "Admin" box under "Scope," then click "Generate API Key." Click "Copy All", paste the contents to a safe location, and save. Then copy the Pinata JWT, return to the terminal where you're initializing your new zkGraph project, paste, and hit Enter. Now you should see that the zkGraph project has been initialized, like so. Run the following commands to complete the initial setup: `cd zkgraph-add` `npm install` ### The zkGraph development flow The zkGraph development flow consists of the following steps: 1. Compile 2. Execute 3. Setup 4. Prove 5. Upload 6. Verify 7. Publish Before we start changing the code, let's look at what's going on in the template example and make sure that our setup works. In the `zkgraph.yaml` file, we see where our zkGraph will retrieve data from. ``` dataSources: - kind: ethereum network: sepolia event: - address: '0xa60ecf32309539dd84f27a9563754dca818b815e' events: - "Sync(uint112,uint112)" ``` This is telling us that our zkGraph will be reading data from an event emitted on the Ethereum Sepolia blockchain. Some Smart Contracts emit an *event* when a certain function is called. The event can record certain data about the corresponding transaction. Each event has a *signature*, which is formed by stripping the whitespace and variable names from the function. For a much more comprehensive primer on Ethereum events and how to parse information from them, see [this guide](https://mirror.xyz/spacesailor.eth/LEe2yoLoqy97BWHyO6J65XhnG8t33Nmvz_Vsa3ve7rY). If we pull up [Sepolia Etherscan](https://sepolia.etherscan.io) and search `0xa60ecf32309539dd84f27a9563754dca818b815e`, we see a smart contract. Switching to the "Events" tab, we can see the last 17 contract events. Scrolling down a bit, we find an event for the transaction `Sync(uint112 reserve0, uint112 reserve1)`. If we strip away the variable names reserve0 and reserve1 and the whitespace, we get `Sync(uin112,uint112)`, which matches the event signature in our `zkgraph.yaml` file. Note that the block in which this event occurred is block number 2279547. ![Screen Shot 2023-12-06 at 1.16.31 PM](https://hackmd.io/_uploads/S1IAVtkLT.png) Let's also look at the mapping.ts file (copied here for convenience). ``` //@ts-ignore import { require } from "@hyperoracle/zkgraph-lib"; import { Bytes, Block, Event } from "@hyperoracle/zkgraph-lib"; let addr = Bytes.fromHexString('0xa60ecf32309539dd84f27a9563754dca818b815e'); let esig_sync = Bytes.fromHexString("0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1"); export function handleBlocks(blocks: Block[]): Bytes { // init output state let state: Bytes; // #1 can access all (matched) events of the latest block let events: Event[] = blocks[0].events; // #2 also can access (matched) events of a given account address (should present in yaml first). // a subset of 'events' let eventsByAcct: Event[] = blocks[0].account(addr).events; // #3 also can access (matched) events of a given account address & a given esig (should present in yaml first). // a subset of 'eventsByAcct' let eventsByAcctEsig: Event[] = blocks[0].account(addr).eventsByEsig(esig_sync) // require match event count > 0 require(eventsByAcctEsig.length > 0) // this 2 way to access event are equal effects, alway true when there's only 1 event matched in the block (e.g. block# 2279547 on sepolia). require( events[0].data == eventsByAcct[0].data && events[0].data == eventsByAcctEsig[0].data ); // set state to the address of the 1st (matched) event, demo purpose only. state = events[0].address; return state } ``` What the zkGraph does is determined in this case by the `handleBlocks` function, since that is declared as the `handler` in the zkgraph.yaml file. The output, state, is a Bytes object, which is given by `events[0].address`. `events`, in turn, is the list of all events matching the specified signature that can be found in `blocks[0]` the first entry of the input list of blocks. So this computation outputs the address of the first matching event of the first block in the list. We will be able to see this output when we execute the zkGraph. #### Compile The first step in the zkGraph development flow is to compile the mapping.ts file to zkWasm binary. This is done by running the following command in the zkgraph project directory: `npm run compile --local` Since we are only testing for now, we can save some time by compiling only a local image. This is why we add the `--local` flag to the command. We will do this with all the remaining commands of the development flow, for now. The output should look something like this: ``` > zkgraph-add@0.0.0 compile > zkgraph compile 5:15:43 PM [zkGraph] [*]5094 lines in /Users/levi/code/zkgraph-add/build/zkgraph_full.wat 5:15:43 PM [zkGraph] [+] Output written to `/Users/levi/code/zkgraph-add/build` folder. 5:15:43 PM [zkGraph] [+] COMPILATION SUCCESS! ``` You should also see a new `build` folder below your main zkGraph project directory, containing files `zkgraph_full.wasm` and `zkgraph_full.wat`. This is the compiled zkWasm image. #### Execute The next step is to execute the zkGraph. For this we will need the block number of the event we are trying to find. We saw earlier that our matching event can be found in block `2279547`. So now we run the following command: `npm run exec 2279547 --local` The output should look something like this: ``` > zkgraph-add@0.0.0 exec > zkgraph exec 2279547 8:48:41 PM [zkGraph] [*] Run zkgraph on block 2279547 [*] No storage DS provided, skip... [*] Defined Data Sources - Event: (0) Address: 0xa60ecf32309539dd84f27a9563754dca818b815e Event Sigs: [ '0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1' ] [*] 6 receipts fetched [*] 1 event matched Tx[0]Event[0] |--addr : a60ecf32309539dd84f27a9563754dca818b815e Tx[0]Event[0] |--arg#0: 1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1 Tx[0]Event[0] |--data : 00000000000000000000000000000000000000000000068f1888e6eb7036fffe00000000000000000000000000000000000000000003336530047e5ec3da40c0 8:48:43 PM [zkGraph] [+] ZKGRAPH STATE OUTPUT: a60ecf32309539dd84f27a9563754dca818b815e ``` Let's explain the data shown here. As we saw earlier on Sepolia Etherscan, `0xa60e...815e` is the address of the smart contract that emitted the event corresponding to the function `Sync(uint112 reserve0, uint112 reserve1)`. Also, `0x1c41...bad1` is exactly the Keccak-256 hash of the event signature, `Sync(uint112,uint112)` (if you like, you can check this for yourself using [this online Keccak-256 hash calculator](https://emn178.github.io/online-tools/keccak_256.html)). Another thing to notice is that the values of the arguments `reserve0` and `reserve1` are encoded in `data`. Specifically, `reserve0` is `0...068f1...fffe`, the first 64 hex characters/32 bytes of `data`, and `reserve1` is `0...03336...40c0`, the last 32 bytes of `data`. The zkGraph State Output is just the output of the handler `handleBlocks`, which in this case just returns the address of the first matching event, namely `0xa60e...815e`. In general, you will need to save the zkGraph State Output for the Prove step. We will go through the rest of the zkGraph development flow later; for now, let's change the mapping.ts file to return the sum of two numbers given in the event data, rather than returning the address of the smart contract. ### Modifying the mapping.ts file Now that we have seen how zkGraph retrieves and processes data from events to generate the zkGraph State Output, we can change the mapping.ts file to show how we can compute using on-chain data. At the top of the mapping.ts file you will see some imports from zkgraph-lib: ``` //@ts-ignore import { require } from "@hyperoracle/zkgraph-lib"; import { Bytes, Block, Event } from "@hyperoracle/zkgraph-lib"; ``` These types, representing different kinds of on-chain data, are compatible with those of AssemblyScript: for a list of types you can use in zkgraph-lib and their methods, see https://docs.hyperoracle.io/zkgraph/zkgraph-assemblyscript-lib. In this project, we will add reserve0 to reserve1 and return the sum as the zkGraph output. For this, we will import the BigInt type from zkGraph lib, so that we can add reserve0 and reserve1 as BigInt objects. Add BigInt to the import statement: ``` //@ts-ignore import { require } from "@hyperoracle/zkgraph-lib"; import { BigInt, Bytes, Block, Event } from "@hyperoracle/zkgraph-lib"; ``` Now, our handler's function signature is this: `export function handleBlocks(blocks: Block[]): Bytes` We will run into some errors if we try to return a BigInt object instead, so we will keep the return type as a Bytes object, and convert our sum from BigInt to Bytes at the end. Recall that `events` is the list of matching events in the first block, and `events[0]` is the particular event (from block 2279547) that we are interested in. Now `reserve0` happens to be encoded in `events[0].data` as the first 32 bytes, and `reserve1` is encoded in `events[0].data` as the last 32 bytes. Therefore (as Bytes objects), `reserve0` is `events[0].data.slice(0, 32)` and `reserve1` is `events[0].data.slice(32, 64)`. **Note:** this particular encoding of the input variables is not always the information or encoding you will find in the `data` field of an event. When dealing with different events, you will have to find out what the information in `data` represents and how it is encoded (lists and objects are encoded differently from integers, for example). We again recommend [this guide](https://mirror.xyz/spacesailor.eth/LEe2yoLoqy97BWHyO6J65XhnG8t33Nmvz_Vsa3ve7rY) for more information on how to parse data from the `data` field of an event. In order to add `reserve0` to `reserve1`, we first cast them as big-endian BigInts, like so: ``` let reserve0: BigInt = BigInt.fromBytesBigEndian(events[0].data.slice(0, 32)); let reserve1: BigInt = BigInt.fromBytesBigEndian(events[0].data.slice(32, 64)); ``` Now, with this library, we can't express the sum of these two BigInts as `reserve0 + reserve1`. Instead we will have to use the `.plus` method of BigInt to add them. `let sum_bigint: BigInt = reserve0.plus(reserve1);` Now we will cast `sum_bigint` as a Bytes object so that we have the required return type at the end. There is no direct conversion from BigInt to Bytes, but we can make the conversion by going through a HexString. ``` let sum_bytes: Bytes = Bytes.fromHexString(sum_bigint.toHexString()); return sum_bytes ``` Excluding all the template code that we're not using for this particular example, our block handler now looks like this: ``` export function handleBlocks(blocks: Block[]): Bytes { let events: Event[] = blocks[0].events; let reserve0: BigInt = BigInt.fromBytesBigEndian(events[0].data.slice(0, 32)); let reserve1: BigInt = BigInt.fromBytesBigEndian(events[0].data.slice(32, 64)); let sum_bigint: BigInt = reserve0.plus(reserve1); let sum_bytes: Bytes = Bytes.fromHexString(sum_bigint.toHexString()); return sum_bytes; } ``` Let's see if it works! ### Returning to the zkGraph development flow Since we are probably done editing the mapping.ts file, we can leave off the `--local` flag when compiling and executing. The outputs for the Compile step should be pretty similar (though the number of lines in the compiled image may change). However, in the Execute step we should see this: ``` > zkgraph-add@0.0.0 exec > zkgraph exec 2279547 3:26:23 PM [zkGraph] [*] Run zkgraph on block 2279547 [*] No storage DS provided, skip... [*] Defined Data Sources - Event: (0) Address: 0xa60ecf32309539dd84f27a9563754dca818b815e Event Sigs: [ '0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1' ] [*] 6 receipts fetched [*] 1 event matched Tx[0]Event[0] |--addr : a60ecf32309539dd84f27a9563754dca818b815e Tx[0]Event[0] |--arg#0: 1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1 Tx[0]Event[0] |--data : 00000000000000000000000000000000000000000000068f1888e6eb7036fffe00000000000000000000000000000000000000000003336530047e5ec3da40c0 3:26:25 PM [zkGraph] [+] ZKGRAPH STATE OUTPUT: 0339f4488d654a341140be ``` The zkGraph state output has changed. Trimming the leading zeroes, we have `reserve0 = 68f1888e6eb7036fffe` and `reserve1 = 3336530047e5ec3da40c0`. Using [this big number calculator](https://www.boxentriq.com/code-breaking/big-number-calculator), we find that `3336530047e5ec3da40c0` is indeed the sum. Success! Now copy this zkGraph state ouput somewhere, because we will need it for the Prove step. #### Setup Next is the setup phase. Run the command `npm run setup` This sets up the arithmetic circuit for proving the result of the computation specified in the mapping.ts file. There are options for manually setting a circuit size, but we will not get into that here. The output should look something like this: ``` 3:33:03 PM [zkGraph] >> SET UP 3:33:04 PM [zkGraph] [*] IMAGE MD5: 1BAAD66C22D531B26FC2A54DF922A6A2 ? You are going to publish a Setup request to the Sepolia testnet, which would require 0.005 SepoliaETH. Proceed? › (Y/n) ``` If you hit y, the rest of the output should look like this: ``` ✔ You are going to publish a Setup request to the Sepolia testnet, which would require 0.005 SepoliaETH. Proceed? … yes 3:34:00 PM [zkGraph] [+] Setup Request Transaction Sent: 0x71bb16c5dcd8a34161413af439012b4234d25f97c84a00f9aa4352ab2e3333df, Waiting for Confirmation 3:34:17 PM [zkGraph] [+] Transaction Confirmed. Creating Setup Task 3:34:22 PM [zkGraph] [+] SETUP TASK STARTED. TASK ID: 6572486dd9626b25e9835286 [*] Please wait for image set up... (estimated: 1-5 min) [*] Task submit time: 2023-12-07T22:34:21.543Z [*] Process started: 2023-12-07T22:34:37.106Z [*] Process finished: 2023-12-07T22:34:37.422Z [*] Pending time: 0 min 15.563 sec [*] Running time: 0 min 0.316 sec [+] SET UP SUCCESS ``` It's a good idea to copy this information somewhere, as you will need the Task ID in a later step. Now if we go to Sepolia Etherscan and view transaction `0x71bb16c5dcd8a34161413af439012b4234d25f97c84a00f9aa4352ab2e3333df`, if we look at Input Data and view it as UTF-8, we will see the Image MD5 in the input data. **Note:** If you have run this command before without changing the mapping.ts file, you may get the following line in your output: `9:22:15 PM [zkGraph] [*] IMAGE ALREADY EXISTS` In general, the setup phase only needs to be done once. If you see the`IMAGE ALREADY EXISTS` message, just skip to the next step. #### Prove Next, run `npm run prove 2279547 0339f4488d654a341140be` since 2279547 is the block containing our event and `0339f4488d654a341140be` is the zkGraph state output that we found in the Execute step. Before generating a full proof, the zkGraph prover does a mock execution to ensure the constraints are satisfied. The output should look something like this: ``` > zkgraph-add@0.0.0 prove > zkgraph prove 2279547 0339f4488d654a341140be 3:50:47 PM [zkGraph] >> PROVE: PRETEST MODE 3:50:47 PM [zkGraph] >> PROVE: TEST MODE DOESN'T OUTPUT PROOF FILE [*] No storage DS provided, skip... [*] Defined Data Sources - Event: (0) Address: 0xa60ecf32309539dd84f27a9563754dca818b815e Event Sigs: [ '0x1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1' ] [*] 6 receipts fetched [*] 1 event matched Tx[0]Event[0] |--addr : a60ecf32309539dd84f27a9563754dca818b815e Tx[0]Event[0] |--arg#0: 1c411e9a96e071241c2f21f7726b17ae89e3cab4c78be50e062b03a9fffbbad1 Tx[0]Event[0] |--data : 00000000000000000000000000000000000000000000068f1888e6eb7036fffe00000000000000000000000000000000000000000003336530047e5ec3da40c0 3:50:49 PM [zkGraph] [+] ZKWASM MOCK EXECUTION SUCCESS! 3:50:49 PM [zkGraph] [+] READY FOR PROVE MODE: npx zkgraph prove <block id> <expected state> --prove ``` Now run `npx zkgraph prove 2279547 0339f4488d654a341140be --prove` to enter prove mode. The ouput will be the same except that zkGraph will ask you whether you want to spend .005 Sepolia ETH to send the prove request. Hit y and you should see something like this: ``` ✔ You are going to publish a Prove request to the Sepolia testnet, which would require 0.005 SepoliaETH. Proceed? … yes 4:01:18 PM [zkGraph] [+] Prove Request Transaction Sent: 0xd16877094e524c77cb81bfaf21fa89e83df26d90181f82ef6c3402007dfad147, Waiting for Confirmation 4:01:51 PM [zkGraph] [+] Transaction Confirmed. Creating Prove Task 4:01:52 PM [zkGraph] [+] PROVE TASK STARTED. TASK ID: 65724edfd9626b25e983589e 4:01:52 PM [zkGraph] [+] WAITING FOR PROVE RESULT. ABOUT 3 TO 5 MINUTED [+] PROVE SUCCESS! [*] Task submit time: 2023-12-07T23:01:51.156Z [*] Process started: 2023-12-07T23:01:55.814Z [*] Process finished: 2023-12-07T23:02:38.1Z [*] Pending time: 0 min 4.658 sec [*] Running time: 0 min 42.286 sec 4:02:38 PM [zkGraph] [+] Proof written to /Users/levi/code/zkgraph-add/build/proof_65724edfd9626b25e983589e.txt. ``` **Note:** the deploy step is not used anymore, since there is a universal verifier. On Sepolia, the Universal Verifier address is `0x714C66711F6552D4F388Ec79D4A33FE20173cC34` #### Upload This step is pretty straightforward. Just run `npm run upload` and it should return an IPFS hash of your uploaded zkGraph. ``` > zkgraph-add@0.0.0 upload > zkgraph upload 5:30:59 PM [zkGraph] >> UPLOAD 5:31:01 PM [zkGraph] [+] IPFS UPLOAD SUCCESS! 5:31:01 PM [zkGraph] [+] IPFS HASH: QmbBWSTbPXuKMqfp6dXFooRZpF4fyD16hoXZmAsamfMTYr ``` You should save this IPFS hash, because you will need it for the Publish step. #### Verify You will need to have the prove task ID from the Prove step in order to complete on-chain verification of the proof. In this case, we will run `npm run verify verify 65724edfd9626b25e983589e` If the verification is successful, it will look something like this: ``` > zkgraph-add@0.0.0 verify > zkgraph verify 65724edfd9626b25e983589e 5:57:25 PM [zkGraph] >> VERIFY PROOF ONCHAIN ================================================================= 5:57:27 PM [zkGraph] >> VERIFY PROOF ONCHAIN SUCCESS ``` #### Publish Here you can publish your zkGraph and set the bounty reward per trigger: this is especially useful if you want to use automation in your zkGraph. Each proof of triggering will have to be verified, so it's not possible for someone to spam your zkGraph's trigger and drain your ETH. Still, you can set the bounty reward per trigger to be some very low number, for example 0.000000001. You will also need to provide the IPFS hash that was ouput during the Publish step. **Note:** since the Deploy step is no longer used, in the current version of zkGraph the `publish` command does not include a deployed contract address as an argument. Therefore the command should be `npm run publish <ipfs hash> <bounty reward per trigger>` In this case, we will run ``` npm run publish QmbBWSTbPXuKMqfp6dXFooRZpF4fyD16hoXZmAsamfMTYr 0.000000001 ``` If the zkGraph is published successfully, the output will look something like this: ``` 7:06:03 PM [zkGraph] >> PUBLISH ZKGRAPH [*] Please wait for publish tx... (estimated: 30 sec) [+] ZKGRAPH PUBLISHED SUCCESSFULLY! [*] Transaction confirmed in block 4843655 on sepolia [*] Transaction hash: 0x692431abff90614bc4b48e9bdfe0fdd4e23939271be60e34e18e785a429e28fe 7:06:18 PM [zkGraph] [*] PUBLISH TX HASH: 0x692431abff90614bc4b48e9bdfe0fdd4e23939271be60e34e18e785a429e28fe ```