├── .envTemplate ├── .gitignore ├── .npmignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin └── the_graph-service.js ├── cdk.json ├── frontend └── bored-apes-ui │ ├── .env-template │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── public │ └── vite.svg │ ├── src │ ├── Account.jsx │ ├── App.css │ ├── App.jsx │ ├── Token.jsx │ ├── assets │ │ └── react.svg │ ├── index.css │ ├── main.jsx │ └── theme.js │ └── vite.config.js ├── graphArchitecture.png ├── jest.config.js ├── lib ├── blockchain-node-stack.js ├── config.js ├── theGraphCluster-construct.js └── the_graph-service-stack.js ├── package-lock.json ├── package.json ├── src └── lambdas │ ├── apiKeyAuthorizer.js │ └── dbCreation.js ├── subgraph ├── README.md └── boredApes │ ├── abis │ └── BoredApeYachtClub.json │ ├── networks.json │ ├── package-lock.json │ ├── package.json │ ├── schema.graphql │ ├── src │ ├── bored-ape-yacht-club.ts │ └── ipfs-handler.ts │ ├── subgraph.yaml │ ├── tests │ ├── bored-ape-yacht-club-utils.ts │ └── bored-ape-yacht-club.test.ts │ └── tsconfig.json └── test └── the_graph-service.test.js /.envTemplate: -------------------------------------------------------------------------------- 1 | # CLIENT_URL=http://localhost:8545 2 | # ALLOWED_IP= 3 | # ALLOWED_SG= 4 | # LOG_LEVEL=info 5 | # CHAIN_ID=1 6 | # API_KEY=secretToken 7 | # BLOCKCHAIN_INSTANCE_TYPE=bc.m5.xlarge 8 | # GRAPH_INSTANCE_TYPE=t3a.xlarge 9 | # AWS_ACCOUNT=CDK_DEFAULT_ACCOUNT 10 | # AWS_REGION=CDK_DEFAULT_REGION -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | generated 4 | build 5 | 6 | # DotEnv 7 | .env 8 | 9 | # CDK asset staging directory 10 | .cdk.staging 11 | cdk.out 12 | cdk.context.json 13 | 14 | subgraph/bayc/node_modules 15 | subgraph/bayc/generated 16 | subgraph/bayc/build 17 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # CDK asset staging directory 2 | .cdk.staging 3 | cdk.out 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | } 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to the TheGraph-Service CDK 2 | 3 | This repo helps to spin up your own self-hosted [Graph node](https://thegraph.com) on AWS. It contains a CDK to deploy a node on ECS/EC2, an IPFS node, a managed PostgreSQL database, and an GraphQl API using ApiGateway. The main purpose of this service is to index and prepare the event data coming from the specified smart contracts, map them into a DB schema, store them in the PostgresqlDB in a more efficient form, and allow basic GraphQL queries through the API. The mappings and smart contract definitions are called *subgraph*. 4 | 5 | The CDK has two stacks in it: 6 | 1. **BlockchainNodeStack:** provides an Ethereum full node based on [Amazon Managed Blockchain Access](https://aws.amazon.com/managed-blockchain/) (AMB). This node can act as a source for the Graph. If you want to use AMB as your blockchain node, deploy the BlockchainNodeStack. It outputs the node's endpoints. Take note of the _AccessorUrl_. This is what you need as endpoint for the graph stack. 7 | 2. **TheGraphServiceStack** provides the [Graph node](https://thegraph.com/). 8 | 9 | This repo has the normal folder structure for a CDK application. In addition, there are two subfolders worth mentioning: 10 | 1. `subgraph`: This folder contains the defintion for a subgraph that can be used for testing. Once theGraph is running, the subgraph needs to be deployed. Then the node will start indexing the subgraph. 11 | 2. `frontend`: A simple react frontend that can be used to test the subgraph and display its results. It can be used to demonstrate the time-travel capabilities of the graph node. It works with the `boredApes` example subgraph. 12 | 13 | # Network Support 14 | TheGraph-Service has support for the following networks (chain IDs) 15 | 16 | * Ethereum mainnet (1) 17 | * Ethereum ropsten (3) 18 | * Ethereum rinkeby (4) 19 | * Ethereum goerli (5) 20 | * Polygon mumbai (80001) 21 | * Polygon matic (137) 22 | * Sepolia (11155111) 23 | 24 | # Architecture 25 | The architecture of the graph node consists of a couple of AWS services. The diagram provides an overview: 26 | 27 | ![Graph Architecture](graphArchitecture.png) 28 | 29 | There are some interesting points: 30 | 31 | 1. We will manage the graph node directly from the development machine. This includes deploying subgraphs as well as queries. 32 | 2. The graph will store that data that it indexed from blockchain in an Aurora DB 33 | 3. The graph's metadata (subgraph definition and similar) are stored on IPFS, backed by an EFS volume. 34 | 4. The graph uses an Ethereum blockchain as data source. 35 | 5. Queries to the graph can be made by calling an API Gateway, which is exposed to the public internet. (the other services are not accessible directly from the internet). 36 | 37 | # Prerequisites 38 | Before you can deploy the CDK, you'll have to install all the needed npm packages of the TheGraph-Service CDK with: 39 | 40 | ```sh 41 | $ npm install 42 | ``` 43 | 44 | Additionally, satisfy the following two pre-requisites. If you have them running already you can skip to section **Setup**. The requirements are 45 | 1. Install CDK 46 | 2. Install Docker 47 | ## Install CDK 48 | To manually install the `cdk` terminal client on MacOS or Linux and ensure the installed version: 49 | 50 | ```sh 51 | $ npm install -g aws-cdk && cdk --version 52 | ``` 53 | 54 | ## Install Docker 55 | The CDK uses a docker based build process. The computer running the cdk commands needs to have [docker](https://www.docker.com/) installed to run the build. If you don't have docker installed, please install it before building the CDK stacks. 56 | 57 | # Setup 58 | Before deploying the CDK application, it's essential to define several key parameters, which are outlined in the following section. 59 | 60 | ## Configuration in .env 61 | There are various configuration parameters that can be set in a `.env` file. The `.envTempate` file shows the config parameters with their default values. To set them copy the template to a `.env`: 62 | 63 | ```sh 64 | cp .envTemplate .env 65 | ``` 66 | 67 | In `.env`, uncomment the values that you want to set and update them. The main ones that need to be set are: 68 | 69 | * `CLIENT_URL` which specifies the RPC URL for the blockchain node. If you are using AMB as blockchain node, make sure that you are using the [token based access](https://docs.aws.amazon.com/managed-blockchain/latest/ethereum-dev/ethereum-tokens.html), because the Graph won't sigv4 its requests by default. The `BlockchainNodeStack` provides this URL in its output. 70 | * `CHAIN_ID` specifies the blockchain that you're indexing. It defaults to *1*, which is the Ethereum mainnet. 71 | * `API_KEY` is the API key that you need to provide to query your graph node. It defaults to *secretToken*. 72 | 73 | We need to allow external access to the graph node just for the deployment of the subgraphs later on. There are two ways of deploying subgraphs to the graph node. 74 | 75 | 1. From your **local development machine**: To access the graph node from the local machine, we will to open the graph node to the *external* IP address of the development machine. You can query the external IP with [whatsmyip.org](https://www.whatsmyip.org/). Take note of the external IP and set it in `.env` as `ALLOWED_IP`. 76 | 2. From an **AWS-based instance** (such as Cloud9): To permit traffic from another EC2 instance, you need to open the graph's security group to allow traffic from the security group that has the cloud9 instance. You can take note of the security group's ID (it should start with `sg-`) and set it in `.env` as `ALLOWED_SG`. 77 | 78 | ## Deployment 79 | At this point you can list the available cdk stacks of our CDK with 80 | 81 | ```sh 82 | $ cdk list 83 | ``` 84 | 85 | If you haven't utilized the CDK before for this account and region, you need to bootstrap the AWS account with the `CDK Toolkit` by running this command. 86 | 87 | ```sh 88 | $ cdk bootstrap aws:/// 89 | ``` 90 | You can now deploy the whole stack with the following command 91 | 92 | ```sh 93 | $ cdk deploy TheGraphServiceStack 94 | ``` 95 | 96 | # Deploy a Subgraph 97 | Once the node is up and running, you will need to deploy a subgraph to it, so that the node has something to index. Refer to the steps in the [README](subgraph/README.md) in the `/subgraph` folder! 98 | 99 | # Access the GraphQL API 100 | There are two ways of accessing the Graph node: 101 | 1. directly from a specified IP (usually the development machine): If an IP has been exposed to the CDK as `allowedIP`, the security groups allow direct access to the EC2 instance that is running the graph containers. This is for development purposes. 102 | 2. through API Gateway: external access for querying the graph is exposed via API GW. There are two routes on the API GW: 103 | 1. `POST /subgraphs/name/{subgraphName}`: This route accepts valid subgraphnames as path element. It is used for queries on the specific subgraph. 104 | 2. `POST /graphql`: This route is for status queries about the syncing status of the graph node. 105 | 106 | The route through the API gateway needs to be authorized. For that the CDK includes a simple authorizer that checks for the existence of an API key in the `authorization` header. The value can be set in `cdk.json` (see above). The authorisation header is only necessary for the queries through the API gateway, because API gateway can be accessed from anywhere. 107 | 108 | You can always lookup the API's **base-url** of the TheGraph-Service on the AWS ParameterStore. It is stored at `/indexer/queryEndpoint` and should look like `https://.execute-api..amazonaws.com`. 109 | 110 | Remark: If you access the URL using the browser, you will simply get a "message: Not found" response. The API accepts only POST requests. 111 | 112 | # GraphQL API Schema 113 | You can lookup and review the GraphQL Schema in [schema.graphql](subgraph/boredApes/schema.graphql). 114 | 115 | # Tear-down of TheGraph-Service 116 | The CDK is configured to completely destroy the resources. This is useful for development environments that requrie rapid re-deployments of the resources to test out various features. However, it also means that after destroying a stack, no data is retained. If you want to retain the database with with the indexed data, you can configure that in the CDK by modifying the `removalPolicy` of the various components. In particular, the database can be set to `RemovalPolicy.SNAPSHOT` to create a DB snapshot before the DB is deleted. 117 | 118 | The command to delete the stack is: 119 | 120 | ```sh 121 | $ cdk destroy TheGraphServiceStack 122 | ``` 123 | 124 | Similarly, if you've used the BlockchainNodeStack for the creating an AMB node, it can be taken down with: 125 | 126 | ```sh 127 | $ cdk destroy BlockchainNodeStack 128 | ``` 129 | -------------------------------------------------------------------------------- /bin/the_graph-service.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const cdk = require('aws-cdk-lib') 4 | const { BlockchainNodeStack } = require('../lib/blockchain-node-stack') 5 | const { TheGraphServiceStack } = require('../lib/the_graph-service-stack') 6 | const { AwsSolutionsChecks } = require('cdk-nag') 7 | const { getConfig } = require('../lib/config.js') 8 | 9 | const app = new cdk.App() 10 | 11 | const config = getConfig() 12 | 13 | const { 14 | clientUrl, 15 | allowedIP, 16 | allowedSG, 17 | logLevel, 18 | chainId, 19 | apiKey, 20 | graphInstanceType, 21 | blockchainInstanceType, 22 | awsAccount, 23 | awsRegion, 24 | } = config 25 | 26 | const blockchainNodeStack = new BlockchainNodeStack( 27 | app, 28 | 'BlockchainNodeStack', 29 | { 30 | env: { 31 | account: awsAccount, 32 | region: awsRegion, 33 | }, 34 | blockchainInstanceType, 35 | chainId, 36 | } 37 | ) 38 | 39 | const graphStack = new TheGraphServiceStack(app, 'TheGraphServiceStack', { 40 | env: { 41 | account: awsAccount, 42 | region: awsRegion, 43 | }, 44 | logLevel, 45 | clientUrl, 46 | chainId, 47 | graphInstanceType, 48 | allowedIP, 49 | allowedSG, 50 | apiKey, 51 | }) 52 | 53 | cdk.Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true })) 54 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "node bin/the_graph-service.js", 3 | "watch": { 4 | "include": ["**"], 5 | "exclude": [ 6 | "README.md", 7 | "cdk*.json", 8 | "jest.config.js", 9 | "package*.json", 10 | "yarn.lock", 11 | "node_modules", 12 | "test" 13 | ] 14 | }, 15 | "context": { 16 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 17 | "@aws-cdk/core:checkSecretUsage": true, 18 | "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], 19 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 20 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 21 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 22 | "@aws-cdk/aws-iam:minimizePolicies": true, 23 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 24 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 25 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 26 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 27 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 28 | "@aws-cdk/core:enablePartitionLiterals": true, 29 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 30 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 31 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 32 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 33 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 34 | "@aws-cdk/aws-route53-patters:useCertificate": true, 35 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/.env-template: -------------------------------------------------------------------------------- 1 | VITE_APP_API_GW_URL= 2 | VITE_APP_STATUS_PATH=/graphql 3 | VITE_APP_SUBGRAPH_PATH=/subgraphs/name/boredApes 4 | VITE_APP_API_KEY=secretToken -------------------------------------------------------------------------------- /frontend/bored-apes-ui/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react/jsx-no-target-blank': 'off', 16 | 'react-refresh/only-export-components': [ 17 | 'warn', 18 | { allowConstantExport: true }, 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # misc 27 | .env 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/README.md: -------------------------------------------------------------------------------- 1 | # Bored Apes Yacht Club Owners – Example Frontend for the subgraph 2 | This is a fairly simple [React](https://react.dev/) frontend for the the [boredApes subgraph](../../subgraph/README.md). It shows a simple website with the owners of [Bored Ape Yacht Club](https://boredapeyachtclub.com/) NFTs at the specified block. With the exception of the images themselves, the data is entirely sourced from the subgraph. 3 | 4 | ## How to use 5 | The frontend is a react app and can be run locally. You have to configure the API GW endpoint before you can run the frontend. 6 | 7 | ### Configuration 8 | The only parameter that you **have to configure** is the endpoint for the subgraph. It is available as output from `TheGraphServiceStack`. There are two ways to get that value: 9 | 10 | 1. Look it up on the AWS console with https://**REGION**.console.aws.amazon.com/cloudformation 11 | 2. Look it up on the console: You need to have the [AWS CLI](https://aws.amazon.com/cli/) installed as well as [JQ](https://jqlang.github.io/jq/) (which will be used to filter the console JSON output). Then you can run `aws cloudformation describe-stacks | jq -r '.Stacks[] | select(.StackName | contains("TheGraphServiceStack")) | .Outputs[] | select(.OutputKey | contains("apiGwEndpoint")) | .OutputValue'` It queries the cloudformation stacks and then filters based on the stack name. 12 | 13 | Take note of the value. 14 | 15 | Copy `.env-template` to `.env` and set the `VITE_APP_API_GW_URL` to the value from the Cloudformation stack. 16 | 17 | The remaining values in the .env-template are set to the values that are correct if you haven't changed them manually during subgraph deployment. Adapt them if necessary, but probably just leave them as is. 18 | 19 | ### Run locally 20 | Install it and run: 21 | 22 | ```sh 23 | cd frontend/bored-apes-ui 24 | npm install 25 | npm start 26 | ``` 27 | 28 | This will run a webserver locally, the frontend will be available at http://localhost:3000. 29 | 30 | ## The idea behind the example 31 | 32 | The frontend showcases the key abilities of subgraphs: 33 | 34 | 1. Index specific blockchain smart contracts: The subgraphs indexes the BAYC NFT collection. 35 | 2. Time-travel queries: Once a smart contract has been indexed, we can query the subgraph for any block in its past. The frontend has a slider to switch to a different block height. 36 | 3. IPFS as data source: On the details view for each NFT you can see it traits. These traits are stored on the IPFS. The subgraph indexes the IPFS files as an additional data source and has access to all its data. It takes the traits and the image link from the IPFS files. 37 | 4. Provenance chain: Because the subgraph is indexing the smart contract from its deployment block, we can see the full list of previous owners of a particular BAYC. -------------------------------------------------------------------------------- /frontend/bored-apes-ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bored-apes-ui-vite", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "@emotion/react": "latest", 16 | "@emotion/styled": "latest", 17 | "@mui/material": "latest", 18 | "graphql": "^16.8.1", 19 | "graphql-request": "^6.1.0", 20 | "react-18-blockies": "^1.0.6", 21 | "react-jazzicon": "^1.0.4", 22 | "react-query": "^3.39.3", 23 | "react-router-dom": "^6.22.1" 24 | }, 25 | "devDependencies": { 26 | "@types/react": "^18.2.55", 27 | "@types/react-dom": "^18.2.19", 28 | "@vitejs/plugin-react": "^4.2.1", 29 | "eslint": "^8.56.0", 30 | "eslint-plugin-react": "^7.33.2", 31 | "eslint-plugin-react-hooks": "^4.6.0", 32 | "eslint-plugin-react-refresh": "^0.4.5", 33 | "vite": "^5.4.14" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/src/Account.jsx: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | import { Box, Grid, Stack, Tooltip, Typography } from '@mui/material' 8 | import React from 'react' 9 | import Token from './Token.jsx' 10 | import Blockies from 'react-18-blockies' 11 | 12 | export default function Account({ data }) { 13 | const { id, tokens } = data 14 | return ( 15 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | {id} 27 | 28 | 29 | 30 | {tokens.map(token => ( 31 | 36 | ))} 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/src/App.jsx: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | import * as React from 'react' 8 | import Container from '@mui/material/Container' 9 | import Typography from '@mui/material/Typography' 10 | import Box from '@mui/material/Box' 11 | 12 | // import { useGetTopHolders } from './useRequest' 13 | import Account from './Account.jsx' 14 | import { LinearProgress, Slider, Stack, TextField } from '@mui/material' 15 | import Grid2 from '@mui/material/Unstable_Grid2/Grid2' 16 | 17 | import { useQuery } from 'react-query' 18 | import { GraphQLClient, gql, request } from 'graphql-request' 19 | 20 | const SUBGRAPH_URL = `${import.meta.env.VITE_APP_API_GW_URL}${import.meta.env.VITE_APP_SUBGRAPH_PATH}` 21 | const STATUS_URL = `${import.meta.env.VITE_APP_API_GW_URL}${import.meta.env.VITE_APP_STATUS_PATH}` 22 | 23 | const graphQLClient = new GraphQLClient(SUBGRAPH_URL, { 24 | headers: { 25 | authorization: import.meta.env.VITE_APP_API_KEY, 26 | }, 27 | }) 28 | 29 | export default function App() { 30 | const [blocknumber, setBlocknumber] = React.useState(12300000) 31 | const [blockDisplayNumber, setBlockDisplayNumber] = React.useState(12300000) 32 | 33 | const topHolderResult = useQuery( 34 | ['get-top-holders', blocknumber], 35 | async () => { 36 | console.log('Querying for blocknumber', blocknumber) 37 | const { accounts } = await graphQLClient.request( 38 | gql` 39 | query getTopHolders($numberOfHolders: Int!, $blocknumber: Int!) { 40 | accounts(first: $numberOfHolders, block: { number: $blocknumber }) { 41 | id 42 | tokens { 43 | contract { 44 | name 45 | symbol 46 | } 47 | token_id 48 | uri 49 | metadata { 50 | image 51 | attributes { 52 | key 53 | value 54 | } 55 | } 56 | previous_owners { 57 | account { 58 | id 59 | } 60 | } 61 | } 62 | } 63 | } 64 | `, 65 | { numberOfHolders: 100, blocknumber } 66 | ) 67 | return accounts 68 | } 69 | ) 70 | // const blockResults = useGetStartAndCurrentBlock("boredApes");) 71 | 72 | if (topHolderResult.isLoading) { 73 | return Loading... 74 | } 75 | 76 | if (topHolderResult.error) { 77 | return Error: {topHolderResult.error.message} 78 | } 79 | 80 | // if (blockResults.error) { console.log('Block error', blockResults.error) } 81 | 82 | const getProgress = () => { 83 | const head = parseInt(blockResults.data.chains[0].chainHeadBlock.number) 84 | const earliest = parseInt(blockResults.data.chains[0].earliestBlock.number) 85 | const latest = parseInt(blockResults.data.chains[0].latestBlock.number) 86 | return ((latest - earliest) / (head - earliest)) * 100 87 | } 88 | 89 | const handleSliderChange = (event, newValue) => { 90 | setBlockDisplayNumber(newValue) 91 | } 92 | 93 | return ( 94 | 95 | 96 | 97 | 98 | {' '} 99 | Bored Ape Owners{' '} 100 | 101 | 102 | 103 | 104 | 112 | 113 | {/* Block Number */} 114 | {/* { */} 115 | { 123 | setBlocknumber(blockDisplayNumber) 124 | console.log('New comitted blocknumber', blockDisplayNumber) 125 | }} 126 | /> 127 | 128 | 129 | {/* 130 | Indexing Status 131 | 132 | 133 | 134 | 135 | 136 | {`${Math.round( 137 | getProgress(), 138 | )}%`} 139 | 140 | 141 | */} 142 | 143 | 144 | 145 | 146 | {topHolderResult.isSuccess && 147 | topHolderResult.data 148 | .filter(a => a.tokens.length > 0) 149 | .map(account => { 150 | return 151 | })} 152 | 153 | 154 | 155 | ) 156 | } 157 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/src/Token.jsx: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | import { 8 | Box, 9 | Card, 10 | CardActionArea, 11 | CardContent, 12 | CardMedia, 13 | Dialog, 14 | DialogContent, 15 | DialogTitle, 16 | Grid, 17 | Stack, 18 | Tooltip, 19 | Typography, 20 | } from '@mui/material' 21 | 22 | import React from 'react' 23 | import Blockies from 'react-18-blockies' 24 | 25 | export default function Token({ data, owner }) { 26 | const [detailsOpen, setDetailsOpen] = React.useState(false) 27 | 28 | const { token_id, contract, uri, metadata, previous_owners } = data 29 | 30 | let imgUrl = 31 | 'https://www.grouphealth.ca/wp-content/uploads/2018/05/placeholder-image.png' 32 | if (metadata != null) { 33 | imgUrl = `https://ipfs.io/ipfs/${metadata.image.slice(7)}` 34 | } 35 | 36 | let attributes = [] 37 | if (metadata != null) { 38 | attributes = metadata.attributes 39 | // setAttributesString(attributes.map(a => `${a.key}: ${a.value}`).join(`','`)) 40 | } 41 | 42 | const handleClose = () => { 43 | setDetailsOpen(false) 44 | } 45 | 46 | const handleOpen = () => { 47 | setDetailsOpen(true) 48 | } 49 | 50 | return ( 51 | 52 | 53 | 54 | 55 | 60 | 61 | 62 | {contract.symbol}#{token_id} 63 | 64 | 65 | {/* Do not display previous owners on card directly. */} 66 | {/* 67 | Previous Owners 68 | 69 | 70 | 71 | {previous_owners.map(o => ( 72 | 73 | 77 | 78 | 84 | 85 | 86 | 87 | ))} 88 | */} 89 | 90 | 91 | 92 | 93 | 94 | 95 | {contract.symbol}#{token_id} 96 | 97 | 98 | {/* */} 99 | 100 | 101 | 106 | 107 | 108 | Owner 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | {owner} 117 | 118 | 119 | {previous_owners.length != 0 && ( 120 | 121 | Previous Owners 122 | 123 | )} 124 | 125 | 126 | {previous_owners.map(o => ( 127 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | ))} 140 | 141 | 142 | 143 | Attributes 144 | 145 | {attributes.map(a => ( 146 | 147 | 151 | {a.key}: 152 | {' '} 153 | {a.value} 154 | 155 | ))} 156 | 157 | {/* */} 158 | 159 | 160 | 161 | ) 162 | } 163 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/src/main.jsx: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | import * as React from 'react' 8 | import * as ReactDOM from 'react-dom/client' 9 | import CssBaseline from '@mui/material/CssBaseline' 10 | import { ThemeProvider } from '@mui/material/styles' 11 | import { QueryClient, QueryClientProvider } from 'react-query' 12 | import App from './App.jsx' 13 | import theme from './theme' 14 | 15 | const queryClient = new QueryClient() 16 | 17 | ReactDOM.createRoot(document.getElementById('root')).render( 18 | 19 | 20 | 21 | {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} 22 | 23 | 24 | 25 | 26 | , 27 | ) 28 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/src/theme.js: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | import { red } from '@mui/material/colors' 8 | import { createTheme } from '@mui/material/styles' 9 | 10 | // A custom theme for this app 11 | const theme = createTheme({ 12 | palette: { 13 | primary: { 14 | main: '#556cd6', 15 | }, 16 | secondary: { 17 | main: '#19857b', 18 | }, 19 | error: { 20 | main: red.A400, 21 | }, 22 | }, 23 | }) 24 | 25 | export default theme 26 | -------------------------------------------------------------------------------- /frontend/bored-apes-ui/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /graphArchitecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-graph-blockchain-indexer/c680c7c0b109cc6aa8ab42658caf010803e83614/graphArchitecture.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node' 3 | } 4 | -------------------------------------------------------------------------------- /lib/blockchain-node-stack.js: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | const { Stack, Duration, CfnOutput, Tags } = require('aws-cdk-lib') 8 | const { CfnNode, CfnAccessor } = require('aws-cdk-lib/aws-managedblockchain') 9 | 10 | class BlockchainNodeStack extends Stack { 11 | /** 12 | * 13 | * @param {Construct} scope 14 | * @param {string} id 15 | * @param {StackProps=} props 16 | */ 17 | constructor(scope, id, props) { 18 | super(scope, id, props) 19 | 20 | const networkNames = new Map([ 21 | [1, 'n-ethereum-mainnet'], 22 | [5, 'n-ethereum-goerli'], 23 | [4, 'n-ethereum-rinkeby'], 24 | [137, 'matic'], 25 | ]) 26 | 27 | const networkName = networkNames.get(props.chainId) 28 | 29 | // Create Blockchain node with AMB 30 | const blockchainNode = new CfnNode(this, 'BlockchainNode', { 31 | networkId: networkName, 32 | nodeConfiguration: { 33 | availabilityZone: Stack.of(this).availabilityZones[0], 34 | instanceType: props.blockchainInstanceType, 35 | }, 36 | }) 37 | 38 | Tags.of(blockchainNode).add('Name', 'BlockchainNodeCNI') 39 | 40 | // Create Accessor 41 | const accessor = new CfnAccessor(this, 'Accessor', { 42 | accessorType: 'BILLING_TOKEN', 43 | }) 44 | 45 | new CfnOutput(this, 'BlockchainNodeUrl', { 46 | value: `https://${blockchainNode.attrNodeId}.ethereum.managedblockchain.${ 47 | Stack.of(this).region 48 | }.amazonaws.com`, 49 | }) 50 | 51 | new CfnOutput(this, 'AccessorUrl', { 52 | value: `https://${ 53 | blockchainNode.attrNodeId 54 | }.t.ethereum.managedblockchain.${ 55 | Stack.of(this).region 56 | }.amazonaws.com?billingtoken=${accessor.attrBillingToken}`, 57 | }) 58 | 59 | this.clientUrl = `https://${ 60 | blockchainNode.attrNodeId 61 | }.t.ethereum.managedblockchain.${ 62 | Stack.of(this).region 63 | }.amazonaws.com?billingtoken=${accessor.attrBillingToken}` 64 | } 65 | } 66 | 67 | module.exports = { BlockchainNodeStack } 68 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const dotenv = require('dotenv').config({ 3 | path: path.resolve(__dirname, '../.env'), 4 | }) 5 | 6 | // 1. Configure dotenv to read from our `.env` file 7 | // dotenv.config({ path: path.resolve(__dirname, '../.env') }) 8 | 9 | // 2. Define a function to retrieve our env variables 10 | function getConfig() { 11 | const config = { 12 | clientUrl: process.env.CLIENT_URL || 'http://localhost:8545', 13 | allowedIP: process.env.ALLOWED_IP || '', 14 | allowedSG: process.env.ALLOWED_SG || '', 15 | logLevel: process.env.LOG_LEVEL || 'info', 16 | chainId: Number(process.env.CHAIN_ID || '1'), 17 | apiKey: process.env.API_KEY || 'secretToken', 18 | graphInstanceType: process.env.GRAPH_INSTANCE_TYPE || 't3a.xlarge', 19 | blockchainInstanceType: 20 | process.env.BLOCKCHAIN_INSTANCE_TYPE || 'bc.m5.xlarge', 21 | awsAccount: process.env.AWS_ACCOUNT || process.env.CDK_DEFAULT_ACCOUNT, 22 | awsRegion: 23 | process.env.AWS_REGION || process.env.CDK_DEFAULT_REGION || 'us-east-1', 24 | } 25 | return config 26 | } 27 | 28 | exports.getConfig = getConfig 29 | -------------------------------------------------------------------------------- /lib/theGraphCluster-construct.js: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 3 | // available at http://aws.amazon.com/agreement or other written agreement between 4 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 5 | 6 | const path = require('path') 7 | const { 8 | RemovalPolicy, 9 | Duration, 10 | CustomResource, 11 | Stack, 12 | Tags, 13 | } = require('aws-cdk-lib') 14 | const { AutoScalingGroup } = require('aws-cdk-lib/aws-autoscaling') 15 | const { 16 | Vpc, 17 | Peer, 18 | Port, 19 | InstanceType, 20 | SecurityGroup, 21 | SubnetType, 22 | LaunchTemplate, 23 | UserData, 24 | InterfaceVpcEndpointAwsService, 25 | EbsDeviceVolumeType, 26 | BlockDeviceVolume, 27 | } = require('aws-cdk-lib/aws-ec2') 28 | const { 29 | Cluster, 30 | EcsOptimizedImage, 31 | AsgCapacityProvider, 32 | Ec2TaskDefinition, 33 | LogDrivers, 34 | ContainerImage, 35 | Protocol, 36 | Secret, 37 | ContainerDependencyCondition, 38 | Ec2Service, 39 | } = require('aws-cdk-lib/aws-ecs') 40 | const { 41 | Role, 42 | ServicePrincipal, 43 | ManagedPolicy, 44 | PolicyStatement, 45 | AnyPrincipal, 46 | } = require('aws-cdk-lib/aws-iam') 47 | const { Runtime } = require('aws-cdk-lib/aws-lambda') 48 | const { NodejsFunction } = require('aws-cdk-lib/aws-lambda-nodejs') 49 | const { 50 | DatabaseCluster, 51 | DatabaseClusterEngine, 52 | AuroraPostgresEngineVersion, 53 | ParameterGroup, 54 | ClusterInstance, 55 | } = require('aws-cdk-lib/aws-rds') 56 | const { Construct } = require('constructs') 57 | const { Provider } = require('aws-cdk-lib/custom-resources') 58 | const { FileSystem, AccessPoint } = require('aws-cdk-lib/aws-efs') 59 | const { Bucket, BlockPublicAccess } = require('aws-cdk-lib/aws-s3') 60 | const { 61 | ApplicationLoadBalancer, 62 | ApplicationProtocol, 63 | } = require('aws-cdk-lib/aws-elasticloadbalancingv2') 64 | const { NagSuppressions } = require('cdk-nag') 65 | 66 | class TheGraphCluster extends Construct { 67 | /** 68 | * 69 | * @param {cdk.Construct} scope 70 | * @param {string} id 71 | * @param {cdk.StackProps=} props 72 | */ 73 | constructor(scope, id, props) { 74 | super(scope, id, props) 75 | 76 | // Map from chainId to networkName 77 | const networkNames = new Map([ 78 | [1, 'mainnet'], 79 | [3, 'ropsten'], 80 | [4, 'rinkeby'], 81 | [5, 'goerli'], 82 | [137, 'matic'], 83 | [80001, 'mumbai'], 84 | [11155111, 'sepolia'], 85 | ]) 86 | 87 | const networkName = networkNames.get(props.chainId) 88 | 89 | // use the default VPC 90 | const vpc = Vpc.fromLookup(this, 'Vpc', { isDefault: true }) 91 | 92 | // VPC Endpoint to SSM 93 | vpc.addInterfaceEndpoint('secretsmanagerVPCE', { 94 | service: InterfaceVpcEndpointAwsService.SECRETS_MANAGER, 95 | }) 96 | 97 | // // ALB SG open for queries from the internet to the ALB 98 | const albSg = new SecurityGroup(this, 'ALB-SG', { 99 | vpc: vpc, 100 | description: 'ALB SG', 101 | }) 102 | Tags.of(albSg).add('Name', 'ALB-SG') 103 | 104 | // // The Graph SG for ECS EC2 open for IPFS p2p communication and ALB SG 105 | const graphServiceSg = new SecurityGroup(this, `EC2SG`, { 106 | vpc: vpc, 107 | description: 'TheGraph SG', 108 | }) 109 | Tags.of(graphServiceSg).add('Name', 'TheGraph-SG') 110 | 111 | const dbName = 'the_graph_db' 112 | 113 | // Aurora serverless 114 | const dbEngine = DatabaseClusterEngine.auroraPostgres({ 115 | version: AuroraPostgresEngineVersion.VER_15_5, 116 | }) 117 | const dbParameterGroup = new ParameterGroup(this, 'DbParameterGroup', { 118 | engine: dbEngine, 119 | parameters: { 120 | client_encoding: 'UTF8', 121 | }, 122 | }) 123 | 124 | const dbInstance = new DatabaseCluster(this, 'DbCluster', { 125 | engine: dbEngine, 126 | parameterGroup: dbParameterGroup, 127 | removalPolicy: RemovalPolicy.DESTROY, 128 | vpc, 129 | storageEncrypted: true, 130 | vpcSubnets: { 131 | availabilityZones: [ 132 | Stack.of(this).availabilityZones[0], 133 | Stack.of(this).availabilityZones[1], 134 | ], 135 | }, 136 | serverlessV2MinCapacity: 0.5, 137 | serverlessV2MaxCapacity: 1, 138 | writer: ClusterInstance.serverlessV2('dbWriter', { 139 | autoMinorVersionUpgrade: true, 140 | publiclyAccessible: false, 141 | }), 142 | readers: [ 143 | ClusterInstance.serverlessV2('dbReader', { scaleWithWriter: true }), 144 | ], 145 | port: 5432, // postgres port 146 | }) 147 | 148 | const createDBLambda = new NodejsFunction(this, 'createDBLambda', { 149 | entry: path.join(__dirname, '../src/lambdas', 'dbCreation.js'), 150 | bundling: { 151 | externalModules: [ 152 | 'aws-sdk', // Use the 'aws-sdk' available in the Lambda runtime 153 | 'pg-native', 154 | ], 155 | }, 156 | // logRetention: RetentionDays.ONE_WEEK, 157 | runtime: Runtime.NODEJS_22_X, 158 | timeout: Duration.minutes(3), // Default is 3 seconds 159 | memorySize: 256, 160 | vpc, 161 | allowPublicSubnet: true, 162 | vpcSubnets: { 163 | subnetType: SubnetType.PUBLIC, 164 | availabilityZones: [ 165 | Stack.of(this).availabilityZones[0], 166 | Stack.of(this).availabilityZones[1], 167 | ], 168 | }, 169 | }) 170 | dbInstance.secret.grantRead(createDBLambda) 171 | dbInstance.connections.allowDefaultPortFrom(createDBLambda) 172 | 173 | // Define the custom resource 174 | const createDBCustomResourceProvider = new Provider( 175 | this, 176 | 'createDBCustomResourceProvider', 177 | { 178 | onEventHandler: createDBLambda, 179 | // logRetention: RetentionDays.ONE_DAY, 180 | } 181 | ) 182 | 183 | const createDBCustomResource = new CustomResource( 184 | this, 185 | 'createDBCustomResource', 186 | { 187 | serviceToken: createDBCustomResourceProvider.serviceToken, 188 | properties: { 189 | secretArn: dbInstance.secret.secretArn, 190 | dbName: dbName, 191 | }, 192 | } 193 | ) 194 | 195 | // Add a dependency to ensure that the custom resource runs after the cluster has been created 196 | createDBCustomResource.node.addDependency(dbInstance) 197 | 198 | // ECS Cluster 199 | const cluster = new Cluster(this, 'Ec2Cluster', { 200 | vpc: vpc, 201 | containerInsights: true, 202 | }) 203 | 204 | // Persistent volume: EFS 205 | const fileSystem = new FileSystem(this, 'EfsFileSystem', { 206 | vpc, 207 | removalPolicy: RemovalPolicy.DESTROY, 208 | encrypted: true, 209 | vpcSubnets: { 210 | subnetType: SubnetType.PUBLIC, 211 | availabilityZones: [ 212 | Stack.of(this).availabilityZones[0], 213 | Stack.of(this).availabilityZones[1], 214 | ], 215 | }, 216 | }) 217 | 218 | fileSystem.addToResourcePolicy( 219 | new PolicyStatement({ 220 | principals: [new AnyPrincipal()], 221 | actions: ['elasticfilesystem:ClientRootAccess'], 222 | resources: ['*'], 223 | }) 224 | ) 225 | 226 | const accessPoint = new AccessPoint(this, 'volumeAccessPoint', { 227 | fileSystem: fileSystem, 228 | path: '/data/ipfs', 229 | createAcl: { 230 | ownerUid: '1000', 231 | ownerGid: '100', 232 | permissions: '755', 233 | }, 234 | posixUser: { 235 | uid: '1000', 236 | gid: '100', 237 | }, 238 | }) 239 | 240 | const nodeClientRole = new Role(this, 'NodeClientRole', { 241 | assumedBy: new ServicePrincipal('ec2.amazonaws.com'), 242 | managedPolicies: [ 243 | ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'), 244 | ManagedPolicy.fromAwsManagedPolicyName('CloudWatchAgentServerPolicy'), 245 | ], 246 | }) 247 | 248 | const nodeClientLaunchTemplate = new LaunchTemplate( 249 | this, 250 | 'nodeClientLaunchTemplate', 251 | { 252 | machineImage: EcsOptimizedImage.amazonLinux2(), 253 | instanceType: new InstanceType(props.graphInstanceType), 254 | securityGroup: graphServiceSg, 255 | userData: UserData.forLinux(), 256 | role: nodeClientRole, 257 | blockDevices: [ 258 | { 259 | deviceName: '/dev/xvda', 260 | volume: BlockDeviceVolume.ebs(30, { 261 | encrypted: true, 262 | volumeType: EbsDeviceVolumeType.GP2, 263 | }), 264 | }, 265 | ], 266 | } 267 | ) 268 | fileSystem.connections.allowDefaultPortFrom(nodeClientLaunchTemplate) 269 | 270 | const autoScalingGroup = new AutoScalingGroup(this, 'ASG', { 271 | vpc, 272 | vpcSubnets: { 273 | subnetType: SubnetType.PUBLIC, 274 | availabilityZones: [ 275 | Stack.of(this).availabilityZones[0], 276 | Stack.of(this).availabilityZones[1], 277 | ], 278 | }, 279 | launchTemplate: nodeClientLaunchTemplate, 280 | minCapacity: 1, 281 | maxCapacity: 1, 282 | }) 283 | 284 | if (props.allowedSG) { 285 | // Allow connections from the specified Security Group 286 | const allowedSecurityGroup = SecurityGroup.fromSecurityGroupId( 287 | this, 288 | 'AllowedSG', 289 | props.allowedSG 290 | ); 291 | 292 | nodeClientLaunchTemplate.connections.allowFrom( 293 | allowedSecurityGroup, 294 | Port.tcp(80), 295 | 'graph queries from allowed SG' 296 | ); 297 | nodeClientLaunchTemplate.connections.allowFrom( 298 | allowedSecurityGroup, 299 | Port.tcp(8020), 300 | 'graph deployment from allowed SG' 301 | ); 302 | nodeClientLaunchTemplate.connections.allowFrom( 303 | allowedSecurityGroup, 304 | Port.tcp(8030), 305 | 'graph status queries from allowed SG' 306 | ); 307 | nodeClientLaunchTemplate.connections.allowFrom( 308 | allowedSecurityGroup, 309 | Port.tcp(5001), 310 | 'IPFS deployment from allowed SG' 311 | ); 312 | } 313 | 314 | if (props.allowedIP) { 315 | // Allow connections from the specified IP 316 | nodeClientLaunchTemplate.connections.allowFrom( 317 | Peer.ipv4(`${props.allowedIP}/32`), 318 | Port.tcp(80), 319 | 'graph queries from allowed IP' 320 | ); 321 | nodeClientLaunchTemplate.connections.allowFrom( 322 | Peer.ipv4(`${props.allowedIP}/32`), 323 | Port.tcp(8020), 324 | 'graph deployment from allowed IP' 325 | ); 326 | nodeClientLaunchTemplate.connections.allowFrom( 327 | Peer.ipv4(`${props.allowedIP}/32`), 328 | Port.tcp(8030), 329 | 'graph status queries from allowed IP' 330 | ); 331 | nodeClientLaunchTemplate.connections.allowFrom( 332 | Peer.ipv4(`${props.allowedIP}/32`), 333 | Port.tcp(5001), 334 | 'IPFS deployment from allowed IP' 335 | ); 336 | } 337 | 338 | if (!props.allowedSG && !props.allowedIP) { 339 | throw new Error('Either allowedSG or allowedIP must be set.'); 340 | } 341 | 342 | 343 | nodeClientLaunchTemplate.connections.allowFrom( 344 | Peer.anyIpv4(), 345 | Port.tcp(4001), 346 | 'IPFS P2P sync' 347 | ) 348 | nodeClientLaunchTemplate.connections.allowFrom( 349 | Peer.anyIpv4(), 350 | Port.udp(4001), 351 | 'IPFS P2P sync' 352 | ) 353 | dbInstance.connections.allowDefaultPortFrom(nodeClientLaunchTemplate) 354 | 355 | const capacityProvider = new AsgCapacityProvider(this, 'CapacityProvider', { 356 | autoScalingGroup: autoScalingGroup, 357 | capacityProviderName: cluster.cluster_name, 358 | enableManagedTerminationProtection: false, 359 | enableManagedScaling: false, 360 | }) 361 | 362 | cluster.addAsgCapacityProvider(capacityProvider) 363 | 364 | const efsVolumeName = 'efsDataVolume' 365 | 366 | const taskDefinition = new Ec2TaskDefinition(this, 'GraphNodeTaskDef', { 367 | volumes: [ 368 | { 369 | name: efsVolumeName, 370 | efsVolumeConfiguration: { 371 | fileSystemId: fileSystem.fileSystemId, 372 | transitEncryption: 'ENABLED', 373 | authorizationConfig: { 374 | accessPointId: accessPoint.accessPointId, 375 | iam: 'ENABLED', 376 | }, 377 | }, 378 | }, 379 | ], 380 | }) 381 | 382 | taskDefinition.addToTaskRolePolicy( 383 | new PolicyStatement({ 384 | actions: [ 385 | 'elasticfilesystem:ClientRootAccess', 386 | 'elasticfilesystem:ClientWrite', 387 | 'elasticfilesystem:ClientMount', 388 | 'elasticfilesystem:DescribeMountTargets', 389 | ], 390 | resources: [fileSystem.fileSystemArn], 391 | }) 392 | ) 393 | 394 | // Creates IPFS Container 395 | const ipfsContainer = taskDefinition.addContainer('ipfs', { 396 | logging: LogDrivers.awsLogs({ 397 | streamPrefix: 'IPFS', 398 | // logRetention: RetentionDays.ONE_WEEK, 399 | }), 400 | image: ContainerImage.fromRegistry('ipfs/kubo:v0.19.1'), 401 | memoryLimitMiB: 3072, 402 | healthCheck: { 403 | command: [ 404 | 'CMD-SHELL', 405 | 'ipfs dag stat /ipfs/QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn || exit 1', 406 | ], 407 | interval: Duration.seconds(120), 408 | retries: 10, 409 | }, 410 | portMappings: [ 411 | { 412 | containerPort: 5001, 413 | hostPort: 5001, 414 | protocol: Protocol.TCP, 415 | }, 416 | { 417 | containerPort: 4001, 418 | hostPort: 4001, 419 | protocol: Protocol.TCP, 420 | }, 421 | { 422 | containerPort: 4001, 423 | hostPort: 4001, 424 | protocol: Protocol.UDP, 425 | }, 426 | ], 427 | }) 428 | 429 | // Mounts the host ipfs volume onto the ipfs container 430 | const ipfsMountPoint = { 431 | containerPath: '/data/ipfs', 432 | sourceVolume: efsVolumeName, 433 | readOnly: false, 434 | } 435 | ipfsContainer.addMountPoints(ipfsMountPoint) 436 | 437 | const environmentVars = { 438 | GRAPH_LOG: props.logLevel, 439 | ipfs: '172.17.0.1:5001', 440 | ethereum: networkName + ':' + props.clientUrl, 441 | postgres_db: dbName, 442 | } 443 | 444 | // // Creates Graph Node Container 445 | const graphnodeContainer = taskDefinition.addContainer('graph-node', { 446 | logging: LogDrivers.awsLogs({ 447 | streamPrefix: 'TheGraph', 448 | // logRetention: RetentionDays.ONE_WEEK, 449 | }), 450 | image: ContainerImage.fromRegistry('graphprotocol/graph-node:v0.30.0'), 451 | memoryLimitMiB: 8192, 452 | environment: environmentVars, 453 | // healthCheck: { 454 | // command: ["CMD", "curl http://localhost:8040 || exit 1"], 455 | // // command: ["CMD-SHELL", "curl --location 'http://localhost:8030/graphql' --header 'Content-Type: application/json' --data '{\"query\":\"{ indexingStatuses { health chains { network latestBlock {number}lastHealthyBlock { number } } } }\",\"variables\":{}}' || exit 1"], 456 | // interval: Duration.seconds(300), 457 | // retries: 10 458 | // }, 459 | secrets: { 460 | postgres_host: Secret.fromSecretsManager(dbInstance.secret, 'host'), 461 | postgres_port: Secret.fromSecretsManager(dbInstance.secret, 'port'), 462 | postgres_user: Secret.fromSecretsManager(dbInstance.secret, 'username'), 463 | postgres_pass: Secret.fromSecretsManager(dbInstance.secret, 'password'), 464 | }, 465 | portMappings: [ 466 | { 467 | containerPort: 8000, 468 | hostPort: 80, 469 | protocol: Protocol.TCP, 470 | }, 471 | { 472 | containerPort: 8001, 473 | hostPort: 8001, 474 | protocol: Protocol.TCP, 475 | }, 476 | { 477 | containerPort: 8020, 478 | hostPort: 8020, 479 | protocol: Protocol.TCP, 480 | }, 481 | { 482 | containerPort: 8030, 483 | hostPort: 8030, 484 | protocol: Protocol.TCP, 485 | }, 486 | { 487 | containerPort: 8040, 488 | hostPort: 8040, 489 | protocol: Protocol.TCP, 490 | }, 491 | ], 492 | }) 493 | 494 | graphnodeContainer.addContainerDependencies({ 495 | container: ipfsContainer, 496 | condition: ContainerDependencyCondition.HEALTHY, 497 | }) 498 | 499 | // access log bucket 500 | const accessLogsBucket = new Bucket(this, 'accessLogsBucket', { 501 | blockPublicAccess: BlockPublicAccess.BLOCK_ALL, 502 | enforceSSL: true, 503 | serverAccessLogsPrefix: 'bucketaccesslogs/', 504 | }) 505 | accessLogsBucket.grantPut( 506 | new ServicePrincipal('delivery.logs.amazonaws.com') 507 | ) 508 | 509 | const service = new Ec2Service(this, 'EC2-Service', { 510 | cluster: cluster, 511 | taskDefinition: taskDefinition, 512 | desiredCount: 1, 513 | }) 514 | 515 | service.node.addDependency(createDBCustomResource) 516 | 517 | const alb_port80 = new ApplicationLoadBalancer(this, 'ALB-Port80', { 518 | vpc: vpc, 519 | internetFacing: false, 520 | vpcSubnets: { 521 | subnetType: SubnetType.PUBLIC, 522 | availabilityZones: [ 523 | Stack.of(this).availabilityZones[0], 524 | Stack.of(this).availabilityZones[1], 525 | ], 526 | }, 527 | dropInvalidHeaderFields: true, 528 | }) 529 | alb_port80.connections.allowTo( 530 | graphServiceSg, 531 | Port.tcp(80), 532 | 'ALB for port 80' 533 | ) 534 | 535 | const listener_port80 = alb_port80.addListener('Listener', { 536 | port: 80, 537 | protocol: ApplicationProtocol.HTTP, 538 | open: false, 539 | }) 540 | 541 | listener_port80.connections.addSecurityGroup(albSg) 542 | listener_port80.connections.allowInternally(Port.tcp(80), 'port80') 543 | 544 | const tg_p80 = listener_port80.addTargets('GraphECS', { 545 | port: 80, 546 | protocol: ApplicationProtocol.HTTP, 547 | targets: [autoScalingGroup], 548 | }) 549 | 550 | tg_p80.configureHealthCheck({ 551 | path: '/', 552 | port: '8030', 553 | interval: Duration.seconds(120), 554 | unhealthyThresholdCount: 5, 555 | }) 556 | 557 | alb_port80.logAccessLogs(accessLogsBucket, 'port80') 558 | 559 | const alb_port8030 = new ApplicationLoadBalancer(this, 'ALB-Port8030', { 560 | vpc: vpc, 561 | internetFacing: false, 562 | vpcSubnets: { 563 | subnetType: SubnetType.PUBLIC, 564 | availabilityZones: [ 565 | Stack.of(this).availabilityZones[0], 566 | Stack.of(this).availabilityZones[1], 567 | ], 568 | }, 569 | dropInvalidHeaderFields: true, 570 | }) 571 | 572 | alb_port8030.connections.allowTo( 573 | nodeClientLaunchTemplate, 574 | Port.tcp(8030), 575 | 'ALB for port 8030' 576 | ) 577 | 578 | const listener_port8030 = alb_port8030.addListener('Listener', { 579 | port: 8030, 580 | protocol: ApplicationProtocol.HTTP, 581 | open: false, 582 | }) 583 | 584 | listener_port8030.connections.addSecurityGroup(albSg) 585 | listener_port8030.connections.allowInternally(Port.tcp(8030), 'port8030') 586 | 587 | const tg_p8030 = listener_port8030.addTargets('GraphECS', { 588 | port: 8030, 589 | protocol: ApplicationProtocol.HTTP, 590 | targets: [autoScalingGroup], 591 | }) 592 | 593 | tg_p8030.configureHealthCheck({ 594 | path: '/', 595 | port: '8030', 596 | interval: Duration.seconds(120), 597 | unhealthyThresholdCount: 5, 598 | }) 599 | 600 | alb_port8030.logAccessLogs(accessLogsBucket, 'port8030') 601 | 602 | this.albListenerPort80 = listener_port80 603 | this.albListenerPort8030 = listener_port8030 604 | this.albPort80 = alb_port80 605 | this.albPort8030 = alb_port8030 606 | this.albSecurityGroup = albSg 607 | 608 | // cdk-nag suppressions 609 | NagSuppressions.addResourceSuppressions(graphServiceSg, [ 610 | { 611 | id: 'AwsSolutions-EC23', 612 | reason: '[FP] graph node must sync IPFS data across internet via P2P', 613 | }, 614 | ]) 615 | 616 | NagSuppressions.addResourceSuppressionsByPath( 617 | this, 618 | 'TheGraphServiceStack/GraphCluster/DbCluster/Secret/Resource', 619 | [ 620 | { 621 | id: 'AwsSolutions-SMG4', 622 | reason: 623 | '[TP-N] secrets rotation disabled because application expects secrets in env vars', 624 | }, 625 | ] 626 | ) 627 | 628 | NagSuppressions.addResourceSuppressionsByPath( 629 | this, 630 | 'TheGraphServiceStack/GraphCluster/DbCluster/Resource', 631 | [ 632 | { 633 | id: 'AwsSolutions-RDS6', 634 | reason: 635 | '[TP-C] Graph client does not support to retrieve token before DB access, compensation: Using Secrets Manager for user/password authentication', 636 | }, 637 | { 638 | id: 'AwsSolutions-RDS10', 639 | reason: 'Disable deletion protection for DEV environment', 640 | }, 641 | ] 642 | ) 643 | 644 | NagSuppressions.addResourceSuppressionsByPath( 645 | this, 646 | [ 647 | 'TheGraphServiceStack/GraphCluster/NodeClientRole/DefaultPolicy/Resource', 648 | '/TheGraphServiceStack/GraphCluster/createDBCustomResourceProvider/framework-onEvent/ServiceRole/DefaultPolicy/Resource', 649 | '/TheGraphServiceStack/GraphCluster/NodeClientRole/DefaultPolicy/Resource', 650 | '/TheGraphServiceStack/GraphCluster/ASG/DrainECSHook/Function/ServiceRole/DefaultPolicy/Resource', 651 | '/TheGraphServiceStack/GraphCluster/ASG/DrainECSHook/Function/ServiceRole/DefaultPolicy/Resource', 652 | ], 653 | [ 654 | { 655 | id: 'AwsSolutions-IAM5', 656 | reason: '[TP-N] IAM role policy created by custom resource framework', 657 | }, 658 | ] 659 | ) 660 | 661 | NagSuppressions.addResourceSuppressionsByPath( 662 | this, 663 | 'TheGraphServiceStack/GraphCluster/ASG/ASG', 664 | [ 665 | { 666 | id: 'AwsSolutions-AS3', 667 | reason: 668 | '[FP] No Auto Scaling Group notifications required for PoC-grade deployment', 669 | }, 670 | ] 671 | ) 672 | 673 | NagSuppressions.addResourceSuppressionsByPath( 674 | this, 675 | [ 676 | '/TheGraphServiceStack/GraphCluster/createDBCustomResourceProvider/framework-onEvent/ServiceRole/Resource', 677 | '/TheGraphServiceStack/GraphCluster/ASG/DrainECSHook/Function/ServiceRole/Resource', 678 | '/TheGraphServiceStack/GraphCluster/NodeClientRole/Resource', 679 | '/TheGraphServiceStack/GraphCluster/createDBLambda/ServiceRole/Resource', 680 | ], 681 | [ 682 | { 683 | id: 'AwsSolutions-IAM4', 684 | reason: 'roles created with minimal permissions by CDK', 685 | }, 686 | ] 687 | ) 688 | 689 | NagSuppressions.addResourceSuppressionsByPath( 690 | this, 691 | [ 692 | 'TheGraphServiceStack/GraphCluster/ASG/DrainECSHook/Function/Resource', 693 | 'TheGraphServiceStack/GraphCluster/createDBCustomResourceProvider/framework-onEvent/Resource', 694 | ], 695 | [ 696 | { 697 | id: 'AwsSolutions-L1', 698 | reason: 699 | '[TP-C] lambda function autogenerated, using the latest CDK version', 700 | }, 701 | ] 702 | ) 703 | 704 | NagSuppressions.addResourceSuppressionsByPath( 705 | this, 706 | 'TheGraphServiceStack/GraphCluster/ASG/LifecycleHookDrainHook/Topic/Resource', 707 | [ 708 | { 709 | id: 'AwsSolutions-SNS2', 710 | reason: 711 | '[FP] No server-side encryption needed for required for PoC-grade deployment', 712 | }, 713 | { 714 | id: 'AwsSolutions-SNS3', 715 | reason: '[FP] No SSL encryption needed for PoC-grade deployment', 716 | }, 717 | ] 718 | ) 719 | 720 | NagSuppressions.addResourceSuppressionsByPath( 721 | this, 722 | 'TheGraphServiceStack/GraphCluster/GraphNodeTaskDef/Resource', 723 | [ 724 | { 725 | id: 'AwsSolutions-ECS2', 726 | reason: 727 | '[FP] only non-sensitive data are passed in as environment variables, secrets only via secrets', 728 | }, 729 | ] 730 | ) 731 | } 732 | } 733 | 734 | module.exports = { TheGraphCluster } 735 | -------------------------------------------------------------------------------- /lib/the_graph-service-stack.js: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | const { Stack, CfnOutput, Duration } = require('aws-cdk-lib') 8 | const { NagSuppressions } = require('cdk-nag') 9 | const path = require('path') 10 | 11 | const { 12 | HttpApi, 13 | VpcLink, 14 | HttpMethod, 15 | CorsHttpMethod, 16 | } = require('aws-cdk-lib/aws-apigatewayv2') 17 | const { TheGraphCluster } = require('./theGraphCluster-construct') 18 | const { Vpc } = require('aws-cdk-lib/aws-ec2') 19 | 20 | const { 21 | HttpLambdaAuthorizer, 22 | HttpLambdaResponseType, 23 | } = require('aws-cdk-lib/aws-apigatewayv2-authorizers') 24 | const { StringParameter } = require('aws-cdk-lib/aws-ssm') 25 | const { LogGroup } = require('aws-cdk-lib/aws-logs') 26 | const { NodejsFunction } = require('aws-cdk-lib/aws-lambda-nodejs') 27 | const { Runtime } = require('aws-cdk-lib/aws-lambda') 28 | const { 29 | HttpAlbIntegration, 30 | } = require('aws-cdk-lib/aws-apigatewayv2-integrations') 31 | 32 | class TheGraphServiceStack extends Stack { 33 | /** 34 | * 35 | * @param {Construct} scope 36 | * @param {string} id 37 | * @param {StackProps=} props 38 | */ 39 | constructor(scope, id, props) { 40 | super(scope, id, props) 41 | 42 | const { 43 | clientUrl, 44 | chainId, 45 | graphInstanceType, 46 | dbInstanceType, 47 | allowedIP, 48 | allowedSG, 49 | } = props 50 | 51 | // Create the graph cluster 52 | const graphCluster = new TheGraphCluster(this, 'GraphCluster', { 53 | clientUrl, 54 | chainId, 55 | graphInstanceType, 56 | dbInstanceType, 57 | allowedIP, 58 | allowedSG, 59 | }) 60 | HttpAlbIntegration 61 | const vpc = Vpc.fromLookup(this, 'Vpc', { isDefault: true }) 62 | 63 | const vpcLink = new VpcLink(this, 'VpcLink', { 64 | vpc, 65 | subnets: { 66 | availabilityZones: [ 67 | Stack.of(this).availabilityZones[0], 68 | Stack.of(this).availabilityZones[1], 69 | ], 70 | }, 71 | securityGroups: [graphCluster.albSecurityGroup], 72 | description: 'VpcLink for the TheGraph service', 73 | }) 74 | 75 | // create lambda function for API Key authorization 76 | const authorizerLambda = new NodejsFunction( 77 | this, 78 | 'TheGraphAuthorizerLambda', 79 | { 80 | entry: path.join(__dirname, '../src/lambdas', 'apiKeyAuthorizer.js'), 81 | handler: 'handler', 82 | runtime: Runtime.NODEJS_22_X, 83 | environment: { 84 | API_TOKEN: props.apiKey, 85 | }, 86 | } 87 | ) 88 | 89 | // lambda authorizer for httpAPI 90 | const authorizer = new HttpLambdaAuthorizer( 91 | 'TheGraphAuthorizer', 92 | authorizerLambda, 93 | { 94 | responseTypes: [HttpLambdaResponseType.SIMPLE], 95 | } 96 | ) 97 | 98 | const httpAPI = new HttpApi(this, 'TheGraphAPI', { 99 | defaultAuthorizer: authorizer, 100 | corsPreflight: { 101 | allowHeaders: ['authorization', 'content-type'], 102 | allowMethods: [ 103 | CorsHttpMethod.GET, 104 | CorsHttpMethod.HEAD, 105 | CorsHttpMethod.OPTIONS, 106 | CorsHttpMethod.POST, 107 | ], 108 | allowOrigins: ['*'], 109 | maxAge: Duration.days(10), 110 | }, 111 | }) 112 | 113 | const httpApiLogGroup = new LogGroup(this, 'HttpApiAccessLogs', { 114 | retention: 7, 115 | }) 116 | 117 | const defaultStage = httpAPI.defaultStage.node.defaultChild 118 | defaultStage.accessLogSettings = { 119 | destinationArn: httpApiLogGroup.logGroupArn, 120 | format: JSON.stringify({ 121 | requestId: '$context.requestId', 122 | userAgent: '$context.identity.userAgent', 123 | sourceIp: '$context.identity.sourceIp', 124 | requestTime: '$context.requestTime', 125 | httpMethod: '$context.httpMethod', 126 | path: '$context.path', 127 | status: '$context.status', 128 | responseLength: '$context.responseLength', 129 | }), 130 | } 131 | 132 | const p80Integration = new HttpAlbIntegration( 133 | 'p80Integration', 134 | graphCluster.albListenerPort80, 135 | { 136 | vpcLink: vpcLink, 137 | method: HttpMethod.POST, 138 | } 139 | ) 140 | 141 | const p8030Integration = new HttpAlbIntegration( 142 | 'p8030Integration', 143 | graphCluster.albListenerPort8030, 144 | { 145 | vpcLink: vpcLink, 146 | method: HttpMethod.POST, 147 | } 148 | ) 149 | 150 | httpAPI.addRoutes({ 151 | path: '/graphql', 152 | methods: [HttpMethod.POST], 153 | integration: p8030Integration, 154 | }) 155 | 156 | httpAPI.addRoutes({ 157 | path: '/subgraphs/name/{subgraphName}', 158 | methods: [HttpMethod.POST], 159 | integration: p80Integration, 160 | }) 161 | 162 | new CfnOutput(this, 'apiGwEndpoint', { value: httpAPI.apiEndpoint }) 163 | 164 | new StringParameter(this, 'apiGwEndpointParameter', { 165 | parameterName: '/indexer/queryEndpoint', 166 | description: 'Endpoint for querying the indexer', 167 | stringValue: httpAPI.apiEndpoint, 168 | }) 169 | 170 | // Nag Suppressions 171 | NagSuppressions.addResourceSuppressionsByPath( 172 | this, 173 | '/TheGraphServiceStack/TheGraphAuthorizerLambda/ServiceRole/Resource', 174 | [ 175 | { 176 | id: 'AwsSolutions-IAM4', 177 | reason: 'roles created with minimal permissions by CDK', 178 | }, 179 | ] 180 | ) 181 | } 182 | } 183 | 184 | module.exports = { TheGraphServiceStack } 185 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "the_graph-service", 3 | "version": "0.1.0", 4 | "license": "MIT-0", 5 | "bin": { 6 | "the_graph-service": "bin/the_graph-service.js" 7 | }, 8 | "scripts": { 9 | "build": "echo \"The build step is not required when using JavaScript!\" && exit 0", 10 | "cdk": "cdk", 11 | "test": "jest" 12 | }, 13 | "devDependencies": { 14 | "aws-cdk": "^2.128.0" 15 | }, 16 | "dependencies": { 17 | "@aws-sdk/client-secrets-manager": "^3.734.0", 18 | "@emotion/react": "^11.11.3", 19 | "@emotion/styled": "^11.11.0", 20 | "@mui/material": "^5.15.10", 21 | "aws-cdk-lib": "^2.177.0", 22 | "cdk-nag": "^2.28.39", 23 | "constructs": "^10.3.0", 24 | "dotenv": "^16.4.4", 25 | "esbuild": "~0.21", 26 | "graphql": "^16.8.1", 27 | "graphql-request": "^6.1.0", 28 | "pg": "^8.11.3", 29 | "react-query": "^3.39.3", 30 | "react-router-dom": "^6.22.1" 31 | } 32 | } -------------------------------------------------------------------------------- /src/lambdas/apiKeyAuthorizer.js: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | export const handler = async event => { 8 | const apiToken = process.env.API_TOKEN 9 | return { 10 | isAuthorized: event.headers.authorization === apiToken, 11 | context: { 12 | apiKey: event.headers.authorization, 13 | }, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/lambdas/dbCreation.js: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | const { 8 | SecretsManagerClient, 9 | GetSecretValueCommand, 10 | } = require('@aws-sdk/client-secrets-manager') 11 | const { Client } = require('pg') 12 | 13 | const secretsManager = new SecretsManagerClient() 14 | 15 | exports.handler = async (event, context) => { 16 | // Retrieve the secret ARN from the environment variable 17 | // const secretArn = process.env.DB_SECRET_ARN 18 | // const dbName = process.env.DB_NAME 19 | 20 | const secretArn = event.ResourceProperties.secretArn 21 | const dbName = event.ResourceProperties.dbName 22 | 23 | console.log(`Parameters: { secretArn: ${secretArn}, dbName: ${dbName} }`) 24 | 25 | const response = {} 26 | 27 | try { 28 | switch (event.RequestType) { 29 | case 'Create': 30 | // Retrieve the secret value from Secrets Manager 31 | const response = await secretsManager.send( 32 | new GetSecretValueCommand({ 33 | SecretId: secretArn, 34 | }) 35 | ) 36 | 37 | // Parse the secret value as JSON 38 | const secretValue = JSON.parse(response.SecretString) 39 | 40 | // connect to DB cluster 41 | const dbClient = new Client({ 42 | host: secretValue.host, 43 | port: secretValue.port, 44 | user: secretValue.username, 45 | password: secretValue.password, 46 | database: 'postgres', 47 | }) 48 | 49 | // Connect to the PostgreSQL database 50 | await dbClient.connect() 51 | 52 | // Create a new database 53 | await dbClient.query( 54 | `CREATE DATABASE ${dbName} ENCODING = 'UTF8' LC_COLLATE = 'C' LC_CTYPE = 'C' TEMPLATE template0` 55 | ) 56 | 57 | // Disconnect from the PostgreSQL database 58 | await dbClient.end() 59 | 60 | // Return a success message as the function's output 61 | return 'Database created successfully' 62 | 63 | default: 64 | return {} 65 | } 66 | } catch (error) { 67 | console.error(error) 68 | 69 | // Return an error message as the function's output 70 | throw new Error(error.message) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /subgraph/README.md: -------------------------------------------------------------------------------- 1 | # Deploying a Subgraph 2 | The Graph node works with so-called _subgraphs_. They specifiy the smart contract(s) to index and the mapping that is used to store data in the DB. To define a subgraph, you need to define _GraphQL schema, subgraph configuration,_ and _mapping to DB_. Once you have a defined subgraph, it needs to be deployed to the graph node, so that it starts indexing. 3 | 4 | ## Prerequisites 5 | To manage subgraphs, you will need the Graph CLI. you can install it globally for easy access with: 6 | 7 | ```sh 8 | npm install -g @graphprotocol/graph-cli 9 | ``` 10 | 11 | ## Initializing a Subgraph 12 | There are two ways to create a new subgraph: 13 | 1. You can start from scratch and have the Graph CLI scaffold a folder for you. 14 | 2. You can copy the folder `subgraph/boredApes` (or `subgraph/boredApes_simple`) and use that as a starting point. 15 | 16 | ### Scaffolding 17 | With `graph init --allow-simple-name` you can scaffold a new folder for a subgraph. Run the command in the `subgraph` folder and answer all the questions. The second option is for the _Product for which to initialize_. Choose _hosted service_ here. We want to deploy the subgraph to our locally running node, so that is the correct option. 18 | 19 | Once the command has finished you will have a new folder with a basic template for a subgraph in it. `cd` into that folder. 20 | 21 | ### Using the Existing Subgraph 22 | If you want to start of with an existing example, you can use the `subgraph/boredApes`. It is a subgraph that indexes all transfers of the popular [Bored Ape Yacht Club](https://boredapeyachtclub.com) NFT collection. Specifically, it tracks the BAYC NFTs. You can copy that folder into a new one and then start modifying it. To avoid requiring an archive node as a data source, `boredApes` hardcodes some values and doesn't need to call the smart contract during indexing. It only relies on event data. That's why it only needs a full node. `cd` into the newly created folder. 23 | 24 | ## Defining a Subgraph 25 | In the folder there are three main files of interest: 26 | 1. `schema.graphql`: The file holds the GraphQL schema that will be used for quering the subgraph. This file defines *what* data will be stored in the DB. 27 | 2. `subgraph.yaml`: The file holds the subgraph configuration. It defines the data source to index, their starting block and the events that should be indexed. 28 | 3. `src/.ts`: This file defines *how* data is mapped into the GraphQL schema. The files holds functions for each event that you are indexing. The functions define how the data is stored in the DB. 29 | 30 | The three files define a subgraph. Because we are defining the mapping and the functions for the mapping ourselves (the _what_ and the _how)_, we have many liberties in creating complex subgraphs. Two things are worth mentioning: 31 | 1. We are _not_ restricted to the data that we are receiving for each event. Instead, in the mapping functions, we can query different data sources to enrich our data set. We can also call other smart contracts to query additional data. 32 | 2. We are _not_ restricted to events for triggering our functions. Instead we can define block handlers, which trigger potentially at every block. While possible, this it not advisable: Subgraphs that trigger on every block are slow and costly to compute. The [official graph documentation](https://thegraph.com/docs/en/) has a comprehensive guide on [creating a subgraph](https://thegraph.com/docs/en/developing/creating-a-subgraph). 33 | 34 | ## Deploying the Subgraph 35 | After a subgraph has been defined, it needs to be deployed to the node. This consists of three steps: _building, creating,_ and _deploying._ The Graph CLI helps with that. 36 | 37 | ### 1. Building the Subgraph 38 | `graph codegen` builds the subgraph. This will create a `generated`` folder, which has all the files needed to deploy the subgraph. Whenever there is a modification to the subgraph it needs to be re-built. 39 | 40 | ### 2. Creating the Subgraph on the Node 41 | The subgraph will be deployed to the graph node that is running already. As a prerequisite, we will query its IP address so that we can address it with our `graph` commands. You can either look up the IP on the AWS console with your EC2 instance or you can query it with the AWS CLI: 42 | 43 | ``` 44 | export GRAPH_IP=$(aws ec2 describe-instances --filters 'Name=tag:Name,Values=TheGraphServiceStack/GraphCluster/nodeClientLaunchTemplate' --query 'Reservations[0].Instances[0].PublicIpAddress' --output text) 45 | ``` 46 | 47 | To simplify deployments it's also useful to define the subgraph's name as environment variable: 48 | 49 | ``` 50 | export SUBGRAPH_NAME=mySubgraph 51 | ``` 52 | 53 | With both variables set, you can **create** your subgraph with: 54 | 55 | ``` 56 | graph create --node http://${GRAPH_IP}:8020 ${SUBGRAPH_NAME} 57 | ``` 58 | 59 | This is a one time action. 60 | 61 | ### 3. Deploy the Subgraph to the Node 62 | The subgraph needs to be deployed to the node with `graph deploy`. This is also the command to update the subgraph if there are any changes to it. The command is: 63 | 64 | ``` 65 | graph deploy --node http://${GRAPH_IP}:8020/ --ipfs http://${GRAPH_IP}:5001 ${SUBGRAPH_NAME} 66 | ``` 67 | 68 | Once the command has finished, the graph has been deployed to the node and will start indexing. 69 | 70 | The three commands used for deploying the subgraph need to communicate to the graph node on port 8020 and 5001. The CDK allows access on these ports from the `allowed IP` and the `allowedSG`. That is why we set them initially in `cdk.json`. 71 | 72 | # Querying a Subgraph 73 | Once the subgraph has been deployed, it can be queried via GraphQL. From the development machine, you can query the EC2 directly. Contrary to the output of the `graph deploy` command, the GraphQL is reachable on port 80 (i.e. directly on the EC2 IP) and not on port 8080 (which is used on the docker container, running on EC2). From the dev machine, the GraphQL endpoint is `http://${GRAPH_IP}/subgraphs/name/${SUBGRAPH_NAME}`. For external access to the graph node use the API Gateway as described in [project's README.md](../README.md#Access-to-the-GraphQL-API). 74 | -------------------------------------------------------------------------------- /subgraph/boredApes/abis/BoredApeYachtClub.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "inputs": [ 4 | { "internalType": "string", "name": "name", "type": "string" }, 5 | { "internalType": "string", "name": "symbol", "type": "string" }, 6 | { "internalType": "uint256", "name": "maxNftSupply", "type": "uint256" }, 7 | { "internalType": "uint256", "name": "saleStart", "type": "uint256" } 8 | ], 9 | "stateMutability": "nonpayable", 10 | "type": "constructor" 11 | }, 12 | { 13 | "anonymous": false, 14 | "inputs": [ 15 | { 16 | "indexed": true, 17 | "internalType": "address", 18 | "name": "owner", 19 | "type": "address" 20 | }, 21 | { 22 | "indexed": true, 23 | "internalType": "address", 24 | "name": "approved", 25 | "type": "address" 26 | }, 27 | { 28 | "indexed": true, 29 | "internalType": "uint256", 30 | "name": "tokenId", 31 | "type": "uint256" 32 | } 33 | ], 34 | "name": "Approval", 35 | "type": "event" 36 | }, 37 | { 38 | "anonymous": false, 39 | "inputs": [ 40 | { 41 | "indexed": true, 42 | "internalType": "address", 43 | "name": "owner", 44 | "type": "address" 45 | }, 46 | { 47 | "indexed": true, 48 | "internalType": "address", 49 | "name": "operator", 50 | "type": "address" 51 | }, 52 | { 53 | "indexed": false, 54 | "internalType": "bool", 55 | "name": "approved", 56 | "type": "bool" 57 | } 58 | ], 59 | "name": "ApprovalForAll", 60 | "type": "event" 61 | }, 62 | { 63 | "anonymous": false, 64 | "inputs": [ 65 | { 66 | "indexed": true, 67 | "internalType": "address", 68 | "name": "previousOwner", 69 | "type": "address" 70 | }, 71 | { 72 | "indexed": true, 73 | "internalType": "address", 74 | "name": "newOwner", 75 | "type": "address" 76 | } 77 | ], 78 | "name": "OwnershipTransferred", 79 | "type": "event" 80 | }, 81 | { 82 | "anonymous": false, 83 | "inputs": [ 84 | { 85 | "indexed": true, 86 | "internalType": "address", 87 | "name": "from", 88 | "type": "address" 89 | }, 90 | { 91 | "indexed": true, 92 | "internalType": "address", 93 | "name": "to", 94 | "type": "address" 95 | }, 96 | { 97 | "indexed": true, 98 | "internalType": "uint256", 99 | "name": "tokenId", 100 | "type": "uint256" 101 | } 102 | ], 103 | "name": "Transfer", 104 | "type": "event" 105 | }, 106 | { 107 | "inputs": [], 108 | "name": "BAYC_PROVENANCE", 109 | "outputs": [{ "internalType": "string", "name": "", "type": "string" }], 110 | "stateMutability": "view", 111 | "type": "function" 112 | }, 113 | { 114 | "inputs": [], 115 | "name": "MAX_APES", 116 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 117 | "stateMutability": "view", 118 | "type": "function" 119 | }, 120 | { 121 | "inputs": [], 122 | "name": "REVEAL_TIMESTAMP", 123 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 124 | "stateMutability": "view", 125 | "type": "function" 126 | }, 127 | { 128 | "inputs": [], 129 | "name": "apePrice", 130 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 131 | "stateMutability": "view", 132 | "type": "function" 133 | }, 134 | { 135 | "inputs": [ 136 | { "internalType": "address", "name": "to", "type": "address" }, 137 | { "internalType": "uint256", "name": "tokenId", "type": "uint256" } 138 | ], 139 | "name": "approve", 140 | "outputs": [], 141 | "stateMutability": "nonpayable", 142 | "type": "function" 143 | }, 144 | { 145 | "inputs": [ 146 | { "internalType": "address", "name": "owner", "type": "address" } 147 | ], 148 | "name": "balanceOf", 149 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 150 | "stateMutability": "view", 151 | "type": "function" 152 | }, 153 | { 154 | "inputs": [], 155 | "name": "baseURI", 156 | "outputs": [{ "internalType": "string", "name": "", "type": "string" }], 157 | "stateMutability": "view", 158 | "type": "function" 159 | }, 160 | { 161 | "inputs": [], 162 | "name": "emergencySetStartingIndexBlock", 163 | "outputs": [], 164 | "stateMutability": "nonpayable", 165 | "type": "function" 166 | }, 167 | { 168 | "inputs": [], 169 | "name": "flipSaleState", 170 | "outputs": [], 171 | "stateMutability": "nonpayable", 172 | "type": "function" 173 | }, 174 | { 175 | "inputs": [ 176 | { "internalType": "uint256", "name": "tokenId", "type": "uint256" } 177 | ], 178 | "name": "getApproved", 179 | "outputs": [{ "internalType": "address", "name": "", "type": "address" }], 180 | "stateMutability": "view", 181 | "type": "function" 182 | }, 183 | { 184 | "inputs": [ 185 | { "internalType": "address", "name": "owner", "type": "address" }, 186 | { "internalType": "address", "name": "operator", "type": "address" } 187 | ], 188 | "name": "isApprovedForAll", 189 | "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], 190 | "stateMutability": "view", 191 | "type": "function" 192 | }, 193 | { 194 | "inputs": [], 195 | "name": "maxApePurchase", 196 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 197 | "stateMutability": "view", 198 | "type": "function" 199 | }, 200 | { 201 | "inputs": [ 202 | { "internalType": "uint256", "name": "numberOfTokens", "type": "uint256" } 203 | ], 204 | "name": "mintApe", 205 | "outputs": [], 206 | "stateMutability": "payable", 207 | "type": "function" 208 | }, 209 | { 210 | "inputs": [], 211 | "name": "name", 212 | "outputs": [{ "internalType": "string", "name": "", "type": "string" }], 213 | "stateMutability": "view", 214 | "type": "function" 215 | }, 216 | { 217 | "inputs": [], 218 | "name": "owner", 219 | "outputs": [{ "internalType": "address", "name": "", "type": "address" }], 220 | "stateMutability": "view", 221 | "type": "function" 222 | }, 223 | { 224 | "inputs": [ 225 | { "internalType": "uint256", "name": "tokenId", "type": "uint256" } 226 | ], 227 | "name": "ownerOf", 228 | "outputs": [{ "internalType": "address", "name": "", "type": "address" }], 229 | "stateMutability": "view", 230 | "type": "function" 231 | }, 232 | { 233 | "inputs": [], 234 | "name": "renounceOwnership", 235 | "outputs": [], 236 | "stateMutability": "nonpayable", 237 | "type": "function" 238 | }, 239 | { 240 | "inputs": [], 241 | "name": "reserveApes", 242 | "outputs": [], 243 | "stateMutability": "nonpayable", 244 | "type": "function" 245 | }, 246 | { 247 | "inputs": [ 248 | { "internalType": "address", "name": "from", "type": "address" }, 249 | { "internalType": "address", "name": "to", "type": "address" }, 250 | { "internalType": "uint256", "name": "tokenId", "type": "uint256" } 251 | ], 252 | "name": "safeTransferFrom", 253 | "outputs": [], 254 | "stateMutability": "nonpayable", 255 | "type": "function" 256 | }, 257 | { 258 | "inputs": [ 259 | { "internalType": "address", "name": "from", "type": "address" }, 260 | { "internalType": "address", "name": "to", "type": "address" }, 261 | { "internalType": "uint256", "name": "tokenId", "type": "uint256" }, 262 | { "internalType": "bytes", "name": "_data", "type": "bytes" } 263 | ], 264 | "name": "safeTransferFrom", 265 | "outputs": [], 266 | "stateMutability": "nonpayable", 267 | "type": "function" 268 | }, 269 | { 270 | "inputs": [], 271 | "name": "saleIsActive", 272 | "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], 273 | "stateMutability": "view", 274 | "type": "function" 275 | }, 276 | { 277 | "inputs": [ 278 | { "internalType": "address", "name": "operator", "type": "address" }, 279 | { "internalType": "bool", "name": "approved", "type": "bool" } 280 | ], 281 | "name": "setApprovalForAll", 282 | "outputs": [], 283 | "stateMutability": "nonpayable", 284 | "type": "function" 285 | }, 286 | { 287 | "inputs": [ 288 | { "internalType": "string", "name": "baseURI", "type": "string" } 289 | ], 290 | "name": "setBaseURI", 291 | "outputs": [], 292 | "stateMutability": "nonpayable", 293 | "type": "function" 294 | }, 295 | { 296 | "inputs": [ 297 | { "internalType": "string", "name": "provenanceHash", "type": "string" } 298 | ], 299 | "name": "setProvenanceHash", 300 | "outputs": [], 301 | "stateMutability": "nonpayable", 302 | "type": "function" 303 | }, 304 | { 305 | "inputs": [ 306 | { 307 | "internalType": "uint256", 308 | "name": "revealTimeStamp", 309 | "type": "uint256" 310 | } 311 | ], 312 | "name": "setRevealTimestamp", 313 | "outputs": [], 314 | "stateMutability": "nonpayable", 315 | "type": "function" 316 | }, 317 | { 318 | "inputs": [], 319 | "name": "setStartingIndex", 320 | "outputs": [], 321 | "stateMutability": "nonpayable", 322 | "type": "function" 323 | }, 324 | { 325 | "inputs": [], 326 | "name": "startingIndex", 327 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 328 | "stateMutability": "view", 329 | "type": "function" 330 | }, 331 | { 332 | "inputs": [], 333 | "name": "startingIndexBlock", 334 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 335 | "stateMutability": "view", 336 | "type": "function" 337 | }, 338 | { 339 | "inputs": [ 340 | { "internalType": "bytes4", "name": "interfaceId", "type": "bytes4" } 341 | ], 342 | "name": "supportsInterface", 343 | "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], 344 | "stateMutability": "view", 345 | "type": "function" 346 | }, 347 | { 348 | "inputs": [], 349 | "name": "symbol", 350 | "outputs": [{ "internalType": "string", "name": "", "type": "string" }], 351 | "stateMutability": "view", 352 | "type": "function" 353 | }, 354 | { 355 | "inputs": [ 356 | { "internalType": "uint256", "name": "index", "type": "uint256" } 357 | ], 358 | "name": "tokenByIndex", 359 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 360 | "stateMutability": "view", 361 | "type": "function" 362 | }, 363 | { 364 | "inputs": [ 365 | { "internalType": "address", "name": "owner", "type": "address" }, 366 | { "internalType": "uint256", "name": "index", "type": "uint256" } 367 | ], 368 | "name": "tokenOfOwnerByIndex", 369 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 370 | "stateMutability": "view", 371 | "type": "function" 372 | }, 373 | { 374 | "inputs": [ 375 | { "internalType": "uint256", "name": "tokenId", "type": "uint256" } 376 | ], 377 | "name": "tokenURI", 378 | "outputs": [{ "internalType": "string", "name": "", "type": "string" }], 379 | "stateMutability": "view", 380 | "type": "function" 381 | }, 382 | { 383 | "inputs": [], 384 | "name": "totalSupply", 385 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 386 | "stateMutability": "view", 387 | "type": "function" 388 | }, 389 | { 390 | "inputs": [ 391 | { "internalType": "address", "name": "from", "type": "address" }, 392 | { "internalType": "address", "name": "to", "type": "address" }, 393 | { "internalType": "uint256", "name": "tokenId", "type": "uint256" } 394 | ], 395 | "name": "transferFrom", 396 | "outputs": [], 397 | "stateMutability": "nonpayable", 398 | "type": "function" 399 | }, 400 | { 401 | "inputs": [ 402 | { "internalType": "address", "name": "newOwner", "type": "address" } 403 | ], 404 | "name": "transferOwnership", 405 | "outputs": [], 406 | "stateMutability": "nonpayable", 407 | "type": "function" 408 | }, 409 | { 410 | "inputs": [], 411 | "name": "withdraw", 412 | "outputs": [], 413 | "stateMutability": "nonpayable", 414 | "type": "function" 415 | } 416 | ] 417 | -------------------------------------------------------------------------------- /subgraph/boredApes/networks.json: -------------------------------------------------------------------------------- 1 | { 2 | "mainnet": { 3 | "BoredApeYachtClub": { 4 | "address": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D", 5 | "startBlock": 12287507 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /subgraph/boredApes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boredapes", 3 | "license": "MIT-0", 4 | "scripts": { 5 | "codegen": "graph codegen", 6 | "build": "graph build", 7 | "deploy": "graph deploy --node https://api.thegraph.com/deploy/ boredapes", 8 | "create-local": "graph create --node http://localhost:8020/ boredapes", 9 | "remove-local": "graph remove --node http://localhost:8020/ boredapes", 10 | "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 boredapes", 11 | "test": "graph test" 12 | }, 13 | "dependencies": { 14 | "@babel/preset-typescript": "^7.23.3", 15 | "@graphprotocol/graph-cli": "^0.68.0", 16 | "@graphprotocol/graph-ts": "^0.33.0" 17 | }, 18 | "devDependencies": { 19 | "matchstick-as": "^0.6.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /subgraph/boredApes/schema.graphql: -------------------------------------------------------------------------------- 1 | # © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | # This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | # available at http://aws.amazon.com/agreement or other written agreement between 5 | # Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | type Approval @entity(immutable: true) { 8 | id: Bytes! 9 | owner: Bytes! # address 10 | approved: Bytes! # address 11 | token_id: BigInt! # uint256 12 | block_number: BigInt! 13 | block_timestamp: BigInt! 14 | transaction_hash: Bytes! 15 | } 16 | 17 | type ApprovalForAll @entity(immutable: true) { 18 | id: Bytes! 19 | owner: Bytes! # address 20 | operator: Bytes! # address 21 | approved: Boolean! # bool 22 | block_number: BigInt! 23 | block_timestamp: BigInt! 24 | transaction_hash: Bytes! 25 | } 26 | 27 | type OwnershipTransferred @entity(immutable: true) { 28 | id: Bytes! 29 | previous_owner: Bytes! # address 30 | new_owner: Bytes! # address 31 | block_number: BigInt! 32 | block_timestamp: BigInt! 33 | transaction_hash: Bytes! 34 | } 35 | 36 | type Transfer @entity(immutable: true) { 37 | id: Bytes! 38 | from: Bytes! # address 39 | to: Bytes! # address 40 | token_id: BigInt! # uint256 41 | block_number: BigInt! 42 | block_timestamp: BigInt! 43 | transaction_hash: Bytes! 44 | } 45 | 46 | type Contract @entity { 47 | id: Bytes! # address 48 | name: String! 49 | symbol: String! 50 | tokens: [Token!]! @derivedFrom(field: "contract") 51 | } 52 | 53 | type Token @entity { 54 | id: Bytes! 55 | token_id: BigInt! 56 | uri: String! 57 | metadata: TokenMetadata 58 | mint_tx: Transfer 59 | owner: Account! 60 | contract: Contract! 61 | updated_at_timestamp: BigInt! 62 | previous_owners: [PrevTokenAccount!]! @derivedFrom(field: "token") 63 | } 64 | 65 | type PrevTokenAccount @entity { 66 | id: Bytes! # Set to account.id.concat(token.id) 67 | account: Account! 68 | token: Token! 69 | } 70 | 71 | type TokenMetadata @entity(immutable: true) { 72 | id: Bytes! 73 | image: String! 74 | attributes: [Attribute!]! 75 | } 76 | 77 | type Attribute @entity(immutable: true) { 78 | id: Bytes! 79 | key: String! 80 | value: String! 81 | } 82 | 83 | type Account @entity { 84 | id: Bytes! # address 85 | tokens: [Token!]! @derivedFrom(field: "owner") 86 | } 87 | -------------------------------------------------------------------------------- /subgraph/boredApes/src/bored-ape-yacht-club.ts: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | import { log, Bytes, BigInt, Address, ByteArray } from '@graphprotocol/graph-ts' 8 | import { 9 | Approval as ApprovalEvent, 10 | ApprovalForAll as ApprovalForAllEvent, 11 | OwnershipTransferred as OwnershipTransferredEvent, 12 | Transfer as TransferEvent, 13 | BoredApeYachtClub, 14 | } from '../generated/BoredApeYachtClub/BoredApeYachtClub' 15 | import { 16 | Approval, 17 | ApprovalForAll, 18 | OwnershipTransferred, 19 | Transfer, 20 | Account, 21 | Token, 22 | Contract, 23 | PrevTokenAccount, 24 | } from '../generated/schema' 25 | 26 | import { TokenMetadata as TokenMetadataTemplate } from '../generated/templates' 27 | 28 | export function handleApproval(event: ApprovalEvent): void { 29 | let entity = new Approval( 30 | event.transaction.hash.concatI32(event.logIndex.toI32()), 31 | ) 32 | entity.owner = event.params.owner 33 | entity.approved = event.params.approved 34 | entity.token_id = event.params.tokenId 35 | 36 | entity.block_number = event.block.number 37 | entity.block_timestamp = event.block.timestamp 38 | entity.transaction_hash = event.transaction.hash 39 | 40 | entity.save() 41 | } 42 | 43 | export function handleApprovalForAll(event: ApprovalForAllEvent): void { 44 | let entity = new ApprovalForAll( 45 | event.transaction.hash.concatI32(event.logIndex.toI32()), 46 | ) 47 | entity.owner = event.params.owner 48 | entity.operator = event.params.operator 49 | entity.approved = event.params.approved 50 | 51 | entity.block_number = event.block.number 52 | entity.block_timestamp = event.block.timestamp 53 | entity.transaction_hash = event.transaction.hash 54 | 55 | entity.save() 56 | } 57 | 58 | export function handleOwnershipTransferred( 59 | event: OwnershipTransferredEvent, 60 | ): void { 61 | let entity = new OwnershipTransferred( 62 | event.transaction.hash.concatI32(event.logIndex.toI32()), 63 | ) 64 | entity.previous_owner = event.params.previousOwner 65 | entity.new_owner = event.params.newOwner 66 | 67 | entity.block_number = event.block.number 68 | entity.block_timestamp = event.block.timestamp 69 | entity.transaction_hash = event.transaction.hash 70 | 71 | entity.save() 72 | } 73 | 74 | function getContract(address: Bytes): Contract { 75 | let contract = Contract.load(address) 76 | 77 | if (!contract) { 78 | contract = new Contract(address) 79 | contract = new Contract(address) 80 | 81 | // if the underlying RPC node is an archive node, we can do contract calls too: 82 | // const onChainContract = BoredApeYachtClub.bind(Address.fromBytes(address)) 83 | // contract.name = onChainContract.name() 84 | // contract.symbol = onChainContract.symbol() 85 | 86 | // here we assume a full node only and set these attributes to fixed values. 87 | contract.name = 'Bored Ape Yacht Club' 88 | contract.symbol = 'BAYC' 89 | 90 | contract.save() 91 | log.info("Detected new contract '{}' at {} with Symbol {}", [ 92 | contract.name, 93 | contract.id.toHex(), 94 | contract.symbol, 95 | ]) 96 | } 97 | 98 | return contract 99 | } 100 | 101 | function getAccount(address: Bytes): Account { 102 | let account = Account.load(address) 103 | 104 | if (!account) { 105 | account = new Account(address) 106 | account.save() 107 | log.info('Detected new account {}', [account.id.toHex()]) 108 | } 109 | return account 110 | } 111 | 112 | function getToken( 113 | contract: Contract, 114 | owner: Account, 115 | tokenId: BigInt, 116 | timestamp: BigInt, 117 | ): Token { 118 | const id = contract.id.concatI32(tokenId.toI32()) 119 | let token = Token.load(id) 120 | 121 | if (!token) { 122 | token = new Token(id) 123 | token.token_id = tokenId 124 | token.contract = contract.id 125 | token.owner = owner.id 126 | token.updated_at_timestamp = timestamp 127 | token.uri = `ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/${token.token_id}` 128 | 129 | // index metadata with its own handler 130 | const ipfsHash = `${token.uri.substring(7)}` 131 | log.info('Adding ipfsHash {}', [ipfsHash]) 132 | token.metadata = Bytes.fromByteArray(ByteArray.fromUTF8(ipfsHash)) 133 | TokenMetadataTemplate.create(ipfsHash) 134 | token.save() 135 | 136 | log.info('Found new token {}/{}, {}', [ 137 | contract.symbol, 138 | token.token_id.toString(), 139 | ipfsHash, 140 | ]) 141 | } 142 | 143 | return token 144 | } 145 | 146 | export function handleTransfer(event: TransferEvent): void { 147 | let entity = new Transfer( 148 | event.transaction.hash.concatI32(event.logIndex.toI32()), 149 | ) 150 | entity.from = event.params.from 151 | entity.to = event.params.to 152 | entity.token_id = event.params.tokenId 153 | 154 | entity.block_number = event.block.number 155 | entity.block_timestamp = event.block.timestamp 156 | entity.transaction_hash = event.transaction.hash 157 | 158 | entity.save() 159 | 160 | // entities 161 | let contract = getContract(event.address) 162 | let owner = getAccount(event.params.to) 163 | 164 | let token = getToken( 165 | contract, 166 | owner, 167 | event.params.tokenId, 168 | event.block.timestamp, 169 | ) 170 | 171 | // add previous owners for non-mint transfers 172 | if ( 173 | event.params.from != 174 | Address.fromString('0x0000000000000000000000000000000000000000') 175 | ) { 176 | const prevOwnerId = token.owner.concat(token.id) 177 | let prevOwner = new PrevTokenAccount(prevOwnerId) 178 | prevOwner.account = token.owner 179 | prevOwner.token = token.id 180 | prevOwner.save() 181 | log.info('New previous owner {} for token {}', [ 182 | prevOwner.account.toHexString(), 183 | token.token_id.toString(), 184 | ]) 185 | } 186 | 187 | token.owner = owner.id 188 | token.updated_at_timestamp = event.block.timestamp 189 | token.save() 190 | } 191 | -------------------------------------------------------------------------------- /subgraph/boredApes/src/ipfs-handler.ts: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | import { 8 | log, 9 | json, 10 | Bytes, 11 | dataSource, 12 | JSONValueKind, 13 | ByteArray, 14 | } from '@graphprotocol/graph-ts' 15 | 16 | import { Attribute, TokenMetadata } from '../generated/schema' 17 | 18 | export function handleTokenMetadata(content: Bytes): void { 19 | let tokenMetadata = new TokenMetadata( 20 | Bytes.fromByteArray(ByteArray.fromUTF8(dataSource.stringParam())), 21 | ) 22 | 23 | const value = json.fromBytes(content).toObject() 24 | if (value) { 25 | const image = value.get('image') 26 | const attributesValue = value.get('attributes') 27 | 28 | let parsedAttributes = new Array() 29 | 30 | if (attributesValue && attributesValue.kind == JSONValueKind.ARRAY) { 31 | const attributesArray = attributesValue.toArray() 32 | for (let i = 0; i < attributesArray.length; i++) { 33 | const a = attributesArray[i] 34 | const attributeValue = a.toObject() 35 | const attributeTraitType = attributeValue.get('trait_type') 36 | const attributeTraitValue = attributeValue.get('value') 37 | 38 | if (attributeTraitType && attributeTraitValue) { 39 | const attributeId = content.concatI32(i) 40 | 41 | let attribute = Attribute.load(attributeId) 42 | if (!attribute) { 43 | attribute = new Attribute(attributeId) 44 | attribute.key = attributeTraitType.toString() 45 | attribute.value = attributeTraitValue.toString() 46 | attribute.save() 47 | } 48 | parsedAttributes.push(attributeId) 49 | } else { 50 | log.error('Error parsing attributes: {}', [content.toString()]) 51 | } 52 | } 53 | } 54 | 55 | if (image && attributesValue) { 56 | tokenMetadata.image = image.toString() 57 | tokenMetadata.attributes = parsedAttributes 58 | } 59 | 60 | tokenMetadata.save() 61 | log.info('TokenMetadata for {} saved', [dataSource.stringParam()]) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /subgraph/boredApes/subgraph.yaml: -------------------------------------------------------------------------------- 1 | specVersion: 0.0.5 2 | schema: 3 | file: ./schema.graphql 4 | dataSources: 5 | - kind: ethereum 6 | name: BoredApeYachtClub 7 | network: mainnet 8 | source: 9 | address: "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D" 10 | abi: BoredApeYachtClub 11 | startBlock: 12287507 12 | mapping: 13 | kind: ethereum/events 14 | apiVersion: 0.0.7 15 | language: wasm/assemblyscript 16 | entities: 17 | - Approval 18 | - ApprovalForAll 19 | - OwnershipTransferred 20 | - Transfer 21 | abis: 22 | - name: BoredApeYachtClub 23 | file: ./abis/BoredApeYachtClub.json 24 | eventHandlers: 25 | - event: Approval(indexed address,indexed address,indexed uint256) 26 | handler: handleApproval 27 | - event: ApprovalForAll(indexed address,indexed address,bool) 28 | handler: handleApprovalForAll 29 | - event: OwnershipTransferred(indexed address,indexed address) 30 | handler: handleOwnershipTransferred 31 | - event: Transfer(indexed address,indexed address,indexed uint256) 32 | handler: handleTransfer 33 | file: ./src/bored-ape-yacht-club.ts 34 | templates: 35 | - name: TokenMetadata 36 | kind: file/ipfs 37 | mapping: 38 | apiVersion: 0.0.7 39 | language: wasm/assemblyscript 40 | file: ./src/ipfs-handler.ts 41 | handler: handleTokenMetadata 42 | entities: 43 | - TokenMetadata 44 | - Attribute 45 | abis: 46 | - name: BoredApeYachtClub 47 | file: ./abis/BoredApeYachtClub.json 48 | -------------------------------------------------------------------------------- /subgraph/boredApes/tests/bored-ape-yacht-club-utils.ts: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | import { newMockEvent } from 'matchstick-as' 8 | import { ethereum, Address, BigInt } from '@graphprotocol/graph-ts' 9 | import { 10 | Approval, 11 | ApprovalForAll, 12 | OwnershipTransferred, 13 | Transfer 14 | } from '../generated/BoredApeYachtClub/BoredApeYachtClub' 15 | 16 | export function createApprovalEvent( 17 | owner: Address, 18 | approved: Address, 19 | tokenId: BigInt 20 | ): Approval { 21 | let approvalEvent = changetype(newMockEvent()) 22 | 23 | approvalEvent.parameters = new Array() 24 | 25 | approvalEvent.parameters.push( 26 | new ethereum.EventParam('owner', ethereum.Value.fromAddress(owner)) 27 | ) 28 | approvalEvent.parameters.push( 29 | new ethereum.EventParam('approved', ethereum.Value.fromAddress(approved)) 30 | ) 31 | approvalEvent.parameters.push( 32 | new ethereum.EventParam( 33 | 'tokenId', 34 | ethereum.Value.fromUnsignedBigInt(tokenId) 35 | ) 36 | ) 37 | 38 | return approvalEvent 39 | } 40 | 41 | export function createApprovalForAllEvent( 42 | owner: Address, 43 | operator: Address, 44 | approved: boolean 45 | ): ApprovalForAll { 46 | let approvalForAllEvent = changetype(newMockEvent()) 47 | 48 | approvalForAllEvent.parameters = new Array() 49 | 50 | approvalForAllEvent.parameters.push( 51 | new ethereum.EventParam('owner', ethereum.Value.fromAddress(owner)) 52 | ) 53 | approvalForAllEvent.parameters.push( 54 | new ethereum.EventParam('operator', ethereum.Value.fromAddress(operator)) 55 | ) 56 | approvalForAllEvent.parameters.push( 57 | new ethereum.EventParam('approved', ethereum.Value.fromBoolean(approved)) 58 | ) 59 | 60 | return approvalForAllEvent 61 | } 62 | 63 | export function createOwnershipTransferredEvent( 64 | previousOwner: Address, 65 | newOwner: Address 66 | ): OwnershipTransferred { 67 | let ownershipTransferredEvent = changetype( 68 | newMockEvent() 69 | ) 70 | 71 | ownershipTransferredEvent.parameters = new Array() 72 | 73 | ownershipTransferredEvent.parameters.push( 74 | new ethereum.EventParam( 75 | 'previousOwner', 76 | ethereum.Value.fromAddress(previousOwner) 77 | ) 78 | ) 79 | ownershipTransferredEvent.parameters.push( 80 | new ethereum.EventParam('newOwner', ethereum.Value.fromAddress(newOwner)) 81 | ) 82 | 83 | return ownershipTransferredEvent 84 | } 85 | 86 | export function createTransferEvent( 87 | from: Address, 88 | to: Address, 89 | tokenId: BigInt 90 | ): Transfer { 91 | let transferEvent = changetype(newMockEvent()) 92 | 93 | transferEvent.parameters = new Array() 94 | 95 | transferEvent.parameters.push( 96 | new ethereum.EventParam('from', ethereum.Value.fromAddress(from)) 97 | ) 98 | transferEvent.parameters.push( 99 | new ethereum.EventParam('to', ethereum.Value.fromAddress(to)) 100 | ) 101 | transferEvent.parameters.push( 102 | new ethereum.EventParam( 103 | 'tokenId', 104 | ethereum.Value.fromUnsignedBigInt(tokenId) 105 | ) 106 | ) 107 | 108 | return transferEvent 109 | } 110 | -------------------------------------------------------------------------------- /subgraph/boredApes/tests/bored-ape-yacht-club.test.ts: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | import { 8 | assert, 9 | describe, 10 | test, 11 | clearStore, 12 | beforeAll, 13 | afterAll 14 | } from 'matchstick-as' 15 | import { Address, BigInt } from '@graphprotocol/graph-ts' 16 | import { Approval } from '../generated/schema' 17 | import { Approval as ApprovalEvent } from '../generated/BoredApeYachtClub/BoredApeYachtClub' 18 | import { handleApproval } from '../src/bored-ape-yacht-club' 19 | import { createApprovalEvent } from './bored-ape-yacht-club-utils' 20 | 21 | // Tests structure (matchstick-as >=0.5.0) 22 | // https://thegraph.com/docs/en/developer/matchstick/#tests-structure-0-5-0 23 | 24 | describe('Describe entity assertions', () => { 25 | beforeAll(() => { 26 | let owner = Address.fromString('0x0000000000000000000000000000000000000001') 27 | let approved = Address.fromString( 28 | '0x0000000000000000000000000000000000000001' 29 | ) 30 | let tokenId = BigInt.fromI32(234) 31 | let newApprovalEvent = createApprovalEvent(owner, approved, tokenId) 32 | handleApproval(newApprovalEvent) 33 | }) 34 | 35 | afterAll(() => { 36 | clearStore() 37 | }) 38 | 39 | // For more test scenarios, see: 40 | // https://thegraph.com/docs/en/developer/matchstick/#write-a-unit-test 41 | 42 | test('Approval created and stored', () => { 43 | assert.entityCount('Approval', 1) 44 | 45 | // 0xa16081f360e3847006db660bae1c6d1b2e17ec2a is the default address used in newMockEvent() function 46 | assert.fieldEquals( 47 | 'Approval', 48 | '0xa16081f360e3847006db660bae1c6d1b2e17ec2a-1', 49 | 'owner', 50 | '0x0000000000000000000000000000000000000001' 51 | ) 52 | assert.fieldEquals( 53 | 'Approval', 54 | '0xa16081f360e3847006db660bae1c6d1b2e17ec2a-1', 55 | 'approved', 56 | '0x0000000000000000000000000000000000000001' 57 | ) 58 | assert.fieldEquals( 59 | 'Approval', 60 | '0xa16081f360e3847006db660bae1c6d1b2e17ec2a-1', 61 | 'tokenId', 62 | '234' 63 | ) 64 | 65 | // More assert options: 66 | // https://thegraph.com/docs/en/developer/matchstick/#asserts 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /subgraph/boredApes/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@graphprotocol/graph-ts/types/tsconfig.base.json", 3 | "include": ["src", "tests"] 4 | } 5 | -------------------------------------------------------------------------------- /test/the_graph-service.test.js: -------------------------------------------------------------------------------- 1 | // © 2023 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | 3 | // This AWS Content is provided subject to the terms of the AWS Customer Agreement 4 | // available at http://aws.amazon.com/agreement or other written agreement between 5 | // Customer and either Amazon Web Services, Inc. or Amazon Web Services EMEA SARL or both. 6 | 7 | const cdk = require('aws-cdk-lib') 8 | const { Template } = require('aws-cdk-lib/assertions') 9 | const TheGraphService = require('../lib/the_graph-service-stack') 10 | 11 | // example test. To run these tests, uncomment this file along with the 12 | // example resource in lib/the_graph-service-stack.js 13 | test('Graph Created', () => { 14 | const app = new cdk.App() 15 | // WHEN 16 | const stack = new TheGraphService.TheGraphServiceStack(app, 'MyTestStack') 17 | // THEN 18 | const template = Template.fromStack(stack) 19 | template.hasResourceProperties('AWS::ApiGatewayV2::Api ', { 20 | VisibilityTimeout: 300, 21 | }) 22 | }) 23 | --------------------------------------------------------------------------------