├── .dev.vars.example ├── .dockerignore ├── .editorconfig ├── .env.example ├── .gitattributes ├── .github ├── CONTRIBUTING.md └── workflows │ ├── checks.yml │ └── deploy.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── biome.json ├── bun.lockb ├── compose.yml ├── environment.d.ts ├── funding.json ├── notes.md ├── package.json ├── reset.d.ts ├── scripts ├── demo.sh ├── ping.sh └── update-dependencies.ts ├── src ├── constant.ts ├── database.ts ├── demo │ └── index.ts ├── index.ts ├── logger.ts ├── router │ ├── api │ │ └── v1 │ │ │ ├── debug │ │ │ ├── index.ts │ │ │ ├── num-events │ │ │ │ └── index.ts │ │ │ ├── num-list-ops │ │ │ │ └── index.ts │ │ │ └── total-supply │ │ │ │ └── index.ts │ │ │ ├── discover │ │ │ └── index.ts │ │ │ ├── exportState │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── leaderboard │ │ │ ├── all │ │ │ │ └── index.ts │ │ │ ├── blocked │ │ │ │ └── index.ts │ │ │ ├── blocks │ │ │ │ └── index.ts │ │ │ ├── count │ │ │ │ └── index.ts │ │ │ ├── followers │ │ │ │ └── index.ts │ │ │ ├── following │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── muted │ │ │ │ └── index.ts │ │ │ ├── mutes │ │ │ │ └── index.ts │ │ │ ├── ranked │ │ │ │ └── index.ts │ │ │ ├── search │ │ │ │ └── index.ts │ │ │ └── validators.ts │ │ │ ├── lists │ │ │ ├── account │ │ │ │ └── index.ts │ │ │ ├── allFollowers │ │ │ │ └── index.ts │ │ │ ├── allFollowing │ │ │ │ └── index.ts │ │ │ ├── allFollowingAddresses │ │ │ │ └── index.ts │ │ │ ├── buttonState │ │ │ │ └── index.ts │ │ │ ├── details │ │ │ │ └── index.ts │ │ │ ├── followerState │ │ │ │ └── index.ts │ │ │ ├── followers │ │ │ │ └── index.ts │ │ │ ├── following │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── latestFollowers │ │ │ │ └── index.ts │ │ │ ├── poap │ │ │ │ └── index.ts │ │ │ ├── recommended │ │ │ │ └── index.ts │ │ │ ├── records │ │ │ │ └── index.ts │ │ │ ├── searchFollowers │ │ │ │ └── index.ts │ │ │ ├── searchFollowing │ │ │ │ └── index.ts │ │ │ ├── stats │ │ │ │ └── index.ts │ │ │ ├── taggedAs │ │ │ │ └── index.ts │ │ │ └── tags │ │ │ │ └── index.ts │ │ │ ├── minters │ │ │ └── index.ts │ │ │ ├── serviceHealth │ │ │ └── index.ts │ │ │ ├── stats │ │ │ └── index.ts │ │ │ ├── token │ │ │ ├── image │ │ │ │ ├── index.ts │ │ │ │ └── tokenImage.ts │ │ │ ├── index.ts │ │ │ └── metadata │ │ │ │ └── index.ts │ │ │ └── users │ │ │ ├── account │ │ │ └── index.ts │ │ │ ├── allFollowers │ │ │ └── index.ts │ │ │ ├── allFollowing │ │ │ └── index.ts │ │ │ ├── commonFollowers │ │ │ └── index.ts │ │ │ ├── details │ │ │ └── index.ts │ │ │ ├── ens │ │ │ └── index.ts │ │ │ ├── followerState │ │ │ └── index.ts │ │ │ ├── followers │ │ │ └── index.ts │ │ │ ├── following │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── latestFollowers │ │ │ └── index.ts │ │ │ ├── list-records │ │ │ └── index.ts │ │ │ ├── lists │ │ │ └── index.ts │ │ │ ├── notifications │ │ │ └── index.ts │ │ │ ├── poap │ │ │ └── index.ts │ │ │ ├── primary-list │ │ │ └── index.ts │ │ │ ├── qr │ │ │ └── index.ts │ │ │ ├── recommended │ │ │ └── index.ts │ │ │ ├── relationships │ │ │ └── index.ts │ │ │ ├── searchFollowers │ │ │ └── index.ts │ │ │ ├── searchFollowing │ │ │ └── index.ts │ │ │ ├── stats │ │ │ └── index.ts │ │ │ ├── taggedAs │ │ │ └── index.ts │ │ │ └── tags │ │ │ └── index.ts │ └── middleware.ts ├── service │ ├── cache │ │ └── service.ts │ ├── efp-indexer │ │ └── service.ts │ ├── ens-metadata │ │ ├── service.ts │ │ └── types.ts │ └── index.ts ├── types │ ├── generated │ │ └── index.ts │ ├── index.ts │ ├── list-location-type.ts │ ├── list-op.ts │ └── list-record.ts └── utilities.ts ├── tests ├── endpoints.sh ├── index.test.ts └── setup.ts ├── tsconfig.json ├── vitest.config.ts └── wrangler.toml /.dev.vars.example: -------------------------------------------------------------------------------- 1 | NODE_ENV="development" 2 | ENVIRONMENT="development" 3 | PORT="8787" 4 | IS_DEMO="false" 5 | 6 | # only available in production. Set in CI during deployment 7 | COMMIT_SHA="ONLY AVAILABLE IN PRODUCTION" 8 | 9 | # https://llamanodes.com 10 | LLAMAFOLIO_ID="" 11 | # https://www.ankr.com 12 | ANKR_ID="" 13 | # https://www.alchemy.com (optional) 14 | ALCHEMY_ID="" 15 | # https://www.infura.io (optional) 16 | INFURA_ID="" 17 | 18 | DATABASE_URL="" 19 | 20 | # only available in deployed worker (development and production) 21 | COMMIT_SHA="NOT_AVAILABLE_IN_LOCAL_DEVELOPMENT" 22 | 23 | # Supabase studio > Settings > General > Project Settings > Reference ID 24 | SUPABASE_PROJECT_REF="" 25 | # The unique Supabase URL which is supplied when you create a new project in your project dashboard 26 | SUPABASE_URL="" 27 | # The unique Supabase Key which is supplied when you create a new project in your project dashboard. 28 | SUPABASE_SECRET_KEY="" 29 | # Supabase > Settings > API > Project ID 30 | SUPABASE_PROJECT_ID="" 31 | # https://.supabase.co/graphql/v1 32 | SUPABASE_GRAPHQL_URL="" 33 | 34 | ENABLE_DATABASE_LOGGING="false" 35 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # ignore everything 2 | * 3 | # except for 4 | !.dev.vars 5 | !.npmrc 6 | !bun.lockb 7 | !environment.d.ts 8 | !package.json 9 | !reset.d.ts 10 | !scripts/ 11 | !src/ 12 | !tsconfig.json 13 | !wrangler.json 14 | 15 | # needed for forge 16 | !.git 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | max_line_length = 120 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = false 11 | 12 | [*.{md}] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV="development" 2 | PORT="8787" 3 | 4 | # only available in production 5 | COMMIT_SHA="ONLY AVAILABLE IN PRODUCTION" 6 | 7 | # https://llamanodes.com 8 | LLAMAFOLIO_ID="" 9 | # https://www.ankr.com 10 | ANKR_ID="" 11 | # https://www.alchemy.com (optional) 12 | ALCHEMY_ID="" 13 | # https://www.infura.io (optional) 14 | INFURA_ID="" 15 | 16 | DATABASE_URL="" 17 | 18 | # Supabase studio > Settings > General > Project Settings > Reference ID 19 | SUPABASE_PROJECT_REF="" 20 | # The unique Supabase URL which is supplied when you create a new project in your project dashboard 21 | SUPABASE_URL="" 22 | # The unique Supabase Key which is supplied when you create a new project in your project dashboard. 23 | SUPABASE_SECRET_KEY="" 24 | # Supabase > Settings > API > Project ID 25 | SUPABASE_PROJECT_ID="" 26 | # https://.supabase.co/graphql/v1 27 | SUPABASE_GRAPHQL_URL="" 28 | 29 | ENABLE_DATABASE_LOGGING="false" 30 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | .vscode/*.json linguist-language=JSON-with-Comments 3 | biome.json linguist-language=JSON-with-Comments 4 | .dev.vars linguist-language=dotenv 5 | .dev.vars.example linguist-language=dotenv 6 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # good vibes only 2 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | 3 | on: 4 | pull_request: 5 | # allow workflow to be called from other workflows 6 | workflow_call: 7 | # allow workflow to be called from github.com UI 8 | workflow_dispatch: 9 | push: 10 | branches-ignore: [develop] 11 | 12 | concurrency: 13 | group: checks-${{ github.workflow }}-${{ github.ref }} 14 | 15 | defaults: 16 | run: 17 | shell: bash 18 | 19 | env: 20 | NODE_OPTIONS: '--no-warnings' 21 | ACTIONS_RUNNER_DEBUG: true 22 | 23 | jobs: 24 | checks: 25 | name: '🔎 Checks' 26 | timeout-minutes: 3 27 | runs-on: ['ubuntu-latest'] 28 | steps: 29 | - name: '🔑 Checkout' 30 | uses: actions/checkout@v4.1.1 31 | 32 | - name: '🐰 Setup Bun' 33 | uses: oven-sh/setup-bun@v1 34 | with: 35 | bun-version: 'latest' 36 | 37 | - name: 'Setup Biome' 38 | uses: biomejs/setup-biome@v1 39 | with: 40 | version: 'latest' 41 | 42 | # if lint fails no need to continue 43 | - name: '🧹 Lint' 44 | continue-on-error: false 45 | run: biome ci . 46 | 47 | - name: '📦 Install Dependencies' 48 | run: bun install --frozen-lockfile 49 | 50 | - name: 'Format' 51 | run: bun format 52 | 53 | - name: 'Typecheck' 54 | continue-on-error: true # temp 55 | run: bun typecheck 56 | 57 | - name: '🔧 Build' 58 | run: bun run build 59 | 60 | - name: '🧪 Test' 61 | # TODO: remove this once all paths are testing-ready 62 | continue-on-error: true 63 | env: 64 | API_URL: http://localhost:8787/api/v1 65 | IS_DEMO: true 66 | VERBOSE: true 67 | run: | 68 | bunx wrangler dev --latest=true & 69 | sleep 3 70 | /bin/bash ./tests/endpoints.sh 71 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | development: 7 | type: boolean 8 | description: 'Deploy to development environment' 9 | required: false 10 | default: true 11 | production: 12 | type: boolean 13 | description: 'Deploy to production environment' 14 | required: false 15 | default: true 16 | stage: 17 | type: boolean 18 | description: 'Deploy to stage environment' 19 | required: false 20 | default: false 21 | test: 22 | type: boolean 23 | description: 'Deploy to test environment' 24 | required: false 25 | default: false 26 | all: 27 | type: boolean 28 | description: 'Deploy to all environments' 29 | required: false 30 | default: false 31 | 32 | push: 33 | branches: [develop] 34 | 35 | concurrency: 36 | group: deploy-${{ github.workflow }}-${{ github.ref }} 37 | cancel-in-progress: true 38 | 39 | defaults: 40 | run: 41 | shell: bash 42 | 43 | env: 44 | ACTIONS_RUNNER_DEBUG: true 45 | NODE_OPTIONS: '--no-warnings' 46 | 47 | jobs: 48 | checks: 49 | uses: './.github/workflows/checks.yml' 50 | 51 | deploy-workers: 52 | if: ${{ !contains(github.event.head_commit.message, '[skip-deploy]') }} 53 | needs: [checks] 54 | timeout-minutes: 3 55 | runs-on: ['ubuntu-latest'] 56 | env: 57 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 58 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 59 | # Supabase database url 60 | DEVELOPMENT_DATABASE: ${{ secrets.DATABASE_URL }} 61 | PRODUCTION_DATABASE: null 62 | steps: 63 | - name: '🔑 Checkout' 64 | uses: actions/checkout@v4.1.1 65 | 66 | - name: '🐰 Setup Bun' 67 | uses: oven-sh/setup-bun@v1 68 | with: 69 | bun-version: 'latest' 70 | 71 | - name: 'Install Dependencies' 72 | run: bun install 73 | 74 | - name: 'Get Commit SHA' 75 | id: get_commit_sha 76 | run: echo "COMMIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV 77 | 78 | # https://developers.cloudflare.com/workers/wrangler/commands/#deploy 79 | - name: '[development] Deploy Cloudflare Workers 🔶' 80 | uses: ethereumfollowprotocol/actions/.github/actions/deploy-cloudflare-worker@main 81 | with: 82 | env: "development" 83 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 84 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 85 | vars: '{"COMMIT_SHA": "${{ steps.get_commit_sha.outputs.COMMIT_SHA }}"}' 86 | 87 | # https://developers.cloudflare.com/workers/wrangler/commands/#deploy 88 | - name: '[production] Deploy Cloudflare Workers 🔶' 89 | uses: ethereumfollowprotocol/actions/.github/actions/deploy-cloudflare-worker@main 90 | with: 91 | env: "production" 92 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 93 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 94 | vars: '{"COMMIT_SHA": "${{ steps.get_commit_sha.outputs.COMMIT_SHA }}"}' 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # local env files 26 | .env 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | # vercel 36 | .vercel 37 | .wrangler 38 | data.sqld 39 | .data 40 | _ 41 | 42 | # wrangler 43 | dist 44 | .dev.vars 45 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | auto-install-peers=true 3 | enable-pre-post-scripts=true 4 | strict-peer-dependencies=false 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome", "mikestead.dotenv", "tamasfe.even-better-toml", "EditorConfig.EditorConfig"], 3 | "unwantedRecommendations": [ 4 | // we use Biome for linting and formatting so we don't need these 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "typescript.preferences.preferTypeOnlyAutoImports": true, 5 | "git.autofetch": true, 6 | "git.confirmSync": false, 7 | "git.enableCommitSigning": true, 8 | "editor.formatOnSave": true, 9 | "editor.codeActionsOnSave": { 10 | "quickfix.biome": "always", 11 | "source.fixAll.biome": "always", 12 | "source.organizeImports.biome": "always" 13 | }, 14 | "editor.defaultFormatter": "biomejs.biome", 15 | "[json]": { "editor.defaultFormatter": "biomejs.biome" }, 16 | "[jsonc]": { "editor.defaultFormatter": "biomejs.biome" }, 17 | "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, 18 | "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, 19 | "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml" }, 20 | "files.associations": { 21 | "biome.json": "jsonc", 22 | ".dev.vars": "dotenv", 23 | ".dev.vars.example": "dotenv" 24 | }, 25 | "search.exclude": { 26 | "_": true, 27 | "**/dist/**": true, 28 | "**/node_modules": true 29 | }, 30 | "files.exclude": { 31 | "**/.wrangler": true 32 | }, 33 | "evenBetterToml.schema.associations": { 34 | "wrangler.toml": "https://github.com/cloudflare/workers-sdk/files/12887590/wrangler.schema.json" 35 | }, 36 | // for github actions 37 | "yaml.completion": true, 38 | "yaml.format.enable": true, 39 | "yaml.schemaStore.enable": true, 40 | "yaml.schemaStore.url": "https://www.schemastore.org/api/json/catalog.json", 41 | // disable various telemetry services 42 | "redhat.telemetry.enabled": false, 43 | "gitlens.telemetry.enabled": false, 44 | "docsYaml.telemetry.enableTelemetry": false, 45 | "database-client.telemetry.usesOnlineServices": false 46 | } 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM oven/bun:slim 3 | 4 | WORKDIR /usr/src/app 5 | 6 | RUN apt-get update --yes \ 7 | && apt-get clean autoclean \ 8 | && apt-get autoremove --yes \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | COPY bun.lockb package.json ./ 12 | 13 | RUN bun install --production --frozen-lockfile 14 | 15 | COPY . . 16 | 17 | CMD ["bun", "./src/index.ts"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ethereum Follow Protocol 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!NOTE] 2 | > The project is under active development. 3 | 4 |
5 | 6 |

7 | 8 | EFP logo 9 | 10 |

11 |
12 |

13 | Start new PR in StackBlitz Codeflow 14 | discord chat 15 | x account 16 |

17 | 18 |

Ethereum Follow Protocol API

19 | 20 | > A native Ethereum protocol for following and tagging Ethereum accounts. 21 | 22 | ## Important links 23 | 24 | - Documentation: [**docs.ethfollow.xyz/api**](https://docs.ethfollow.xyz/api) 25 | 26 | ## Getting started with development 27 | 28 | ### Prerequisites 29 | 30 | - [Bun runtime](https://bun.sh/) 31 | - [Cloudflare Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) 32 | - [Ethereum Follow Protocol Indexer](https://github.com/ethereumfollowprotocol/indexer) 33 | 34 | ### Setup 35 | 36 | Assuming you have an indexer running and postgres database setup, follow these steps to get started with development: 37 | 38 | 0. Ensure development tools are up to date 39 | 40 | ```bash 41 | bun upgrade 42 | ``` 43 | ```bash 44 | bun add --global wrangler@latest 45 | ``` 46 | 47 | 1. Clone the repository (I'm using [**cli.github.com**](https://cli.github.com)) 48 | 49 | ```bash 50 | gh repo clone ethereumfollowprotocol/api 51 | ``` 52 | 53 | 2. Install dependencies 54 | 55 | ```bash 56 | bun install 57 | ``` 58 | 59 | 4. Setup environment variables 60 | 61 | ```bash 62 | cp .dev.vars.example .dev.vars 63 | ``` 64 | > [!NOTE] 65 | > `.dev.vars` is Cloudflare Workers' equivalent of `.env` ([learn more](https://developers.cloudflare.com/workers/configuration/environment-variables/#interact-with-environment-variables-locally)). 66 | > Check `.dev.vars` for required variables and how to get them. 67 | 68 | 5. Start development server and make requests 69 | 70 | ```bash 71 | bun dev 72 | ``` 73 | Make a request to the health endpoint to check if server is running 74 | ```bash 75 | curl 'http://localhost:8787/v1/health' 76 | # should return 'ok' 77 | ``` 78 | Make a request to the postgres health endpoint to check if the database is connected 79 | ```bash 80 | curl 'http://localhost:8787/v1/postgres-health' 81 | # should return 'ok' 82 | ``` 83 | 84 | ### [Wrangler](https://developers.cloudflare.com/workers/wrangler/) 85 | 86 | Wrangler is a CLI tool through which you interact with Cloudflare Workers runtime and Cloudflare Platform. It is used to: 87 | - run the development server, 88 | - publish the API to Cloudflare Workers, 89 | - CRUD KV namespaces, R2 Buckets, D1 Database, and a number of other CF platform resources, 90 | 91 | See a list of all Wrangler commands [here](https://developers.cloudflare.com/workers/cli-wrangler/commands). 92 | ____ 93 | TODO: Continue documentation 94 | ____ 95 | 96 |
97 | 98 | Follow [**@ethfollowpr**](https://x.com/ethfollowpr) on **𝕏** for updates and join the [**Discord**](https://discord.ethfollow.xyz) to get involved. 99 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "vcs": { 4 | "root": ".", 5 | "enabled": true, 6 | "clientKind": "git" 7 | }, 8 | "files": { 9 | "include": ["./**/*.ts", "./**/*.js", "./**/*.cjs", "./**/*.mjs", "./**/*.d.ts", "./**/*.json", "./**/*.jsonc"], 10 | "ignoreUnknown": true, 11 | "ignore": ["node_modules", "dist", "_", "./src/demo/data.json", "./tests/index.test.ts"] 12 | }, 13 | "organizeImports": { 14 | "enabled": true 15 | }, 16 | "formatter": { 17 | "enabled": true, 18 | "lineWidth": 120, 19 | "indentWidth": 2, 20 | "indentStyle": "space", 21 | "formatWithErrors": true, 22 | "include": ["./**/*.ts", "./**/*.js", "./**/*.cjs", "./**/*.mjs", "./**/*.d.ts", "./**/*.json", "./**/*.jsonc"], 23 | "ignore": ["node_modules", "dist", "_", "./src/demo/data.json"] 24 | }, 25 | "linter": { 26 | "ignore": ["node_modules", "dist", "_", "./src/demo/data.json"], 27 | "enabled": true, 28 | "rules": { 29 | "all": true, 30 | "style": { 31 | "useBlockStatements": "off", 32 | "useSelfClosingElements": "off", 33 | "noUnusedTemplateLiteral": "off", 34 | "useConsistentArrayType": "off", 35 | "noDefaultExport": "off", 36 | "useNamingConvention": "off", 37 | "noNamespaceImport": "off" 38 | }, 39 | "performance": { "noAccumulatingSpread": "off" }, 40 | "nursery": { 41 | "all": true, 42 | "useExplicitType": "off", 43 | "noNestedTernary": "off" 44 | }, 45 | "complexity": { 46 | "noBannedTypes": "off", 47 | "noUselessFragments": "off", 48 | "useLiteralKeys": "off" 49 | }, 50 | "correctness": { 51 | "noUndeclaredDependencies": "off", 52 | "noUnusedImports": "off", 53 | "useImportExtensions": "off" 54 | }, 55 | "suspicious": { 56 | "noConsole": "off", 57 | "noEmptyBlockStatements": "off", 58 | "noEmptyInterface": "off", 59 | "noExplicitAny": "off", 60 | "noConsoleLog": "off" 61 | } 62 | } 63 | }, 64 | "json": { 65 | "parser": { 66 | "allowComments": true 67 | }, 68 | "formatter": { 69 | "enabled": true, 70 | "lineWidth": 120, 71 | "indentWidth": 2 72 | } 73 | }, 74 | "javascript": { 75 | "parser": { 76 | "unsafeParameterDecoratorsEnabled": true 77 | }, 78 | "formatter": { 79 | "enabled": true, 80 | "lineWidth": 120, 81 | "indentWidth": 2, 82 | "indentStyle": "space", 83 | "quoteStyle": "single", 84 | "trailingCommas": "none", 85 | "semicolons": "asNeeded", 86 | "jsxQuoteStyle": "single", 87 | "quoteProperties": "asNeeded", 88 | "arrowParentheses": "asNeeded" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethereumfollowprotocol/api/55388610de7b27eb3f420e4d675d85d7f0c3a8b1/bun.lockb -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | name: efp-api 4 | 5 | networks: 6 | default: 7 | driver: bridge 8 | 9 | services: 10 | ping: 11 | container_name: api-ping 12 | image: bash:latest 13 | build: 14 | dockerfile_inline: | 15 | FROM bash:latest 16 | WORKDIR /usr/src/app 17 | RUN apk add --no-cache curl && rm -rf /var/cache/apk/* 18 | COPY ./scripts/ping.sh /usr/src/app/ping.sh 19 | command: ./ping.sh 20 | entrypoint: /usr/local/bin/bash 21 | networks: 22 | - default 23 | 24 | api: 25 | container_name: api-dev 26 | build: 27 | context: . 28 | dockerfile: Dockerfile 29 | command: > 30 | bun ./src/index.ts 31 | tty: true 32 | stdin_open: true 33 | expose: 34 | - 8787 35 | ports: 36 | - 8787:8787/tcp 37 | networks: 38 | - default 39 | environment: 40 | - IS_DEMO=${IS_DEMO:-false} 41 | - DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@efp-database:5432/efp?sslmode=disable} 42 | -------------------------------------------------------------------------------- /environment.d.ts: -------------------------------------------------------------------------------- 1 | interface EnvironmentVariables { 2 | readonly ALLOW_TTL_MOD: 'true' | 'false' 3 | readonly NODE_ENV: 'development' | 'production' | 'test' 4 | readonly ENVIRONMENT: 'development' | 'production' | 'stage' | 'test' 5 | readonly PORT: string 6 | readonly LLAMAFOLIO_ID: string 7 | readonly ANKR_ID: string 8 | readonly ALCHEMY_ID: string 9 | readonly INFURA_ID: string 10 | readonly DATABASE_URL: string 11 | readonly ENABLE_DATABASE_LOGGING: 'true' | 'false' 12 | readonly IS_DEMO: 'true' | 'false' 13 | readonly AIRSTACK_API_KEY: string 14 | readonly CACHE_TTL: number 15 | readonly POAP_API_TOKEN: string 16 | readonly REDIS_URL: string 17 | readonly ENS_API_URL: string 18 | } 19 | 20 | // Cloudflare Workers 21 | interface Env extends EnvironmentVariables { 22 | // ens is a binded service in wrangler.toml 23 | readonly ens: Record 24 | // EFP_DEMO_KV is a binded production service in wrangler.toml 25 | // biome-ignore lint/correctness/noUndeclaredVariables: 26 | readonly EFP_DEMO_KV: KVNamespace 27 | // EFP_DATA_CACHE is a binded production service in wrangler.toml 28 | // biome-ignore lint/correctness/noUndeclaredVariables: 29 | readonly EFP_DATA_CACHE: KVNamespace 30 | // generated in ci during deployment 31 | readonly COMMIT_SHA: string 32 | } 33 | 34 | // Node.js 35 | // biome-ignore lint/style/noNamespace: 36 | declare namespace NodeJS { 37 | interface ProcessEnv extends EnvironmentVariables {} 38 | } 39 | -------------------------------------------------------------------------------- /funding.json: -------------------------------------------------------------------------------- 1 | { 2 | "opRetro": { 3 | "projectId": "0x800707befd6c94e7cea794200a6fee1155e54ca94bc10ee85d15c3f23845b10c" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | ### Metrics Summary Table 2 | 3 | | Run | Total Supply | Num List Ops | Num Events | 4 | |------|--------------|--------------|------------| 5 | | Run 1| 51 | 94 | 407 | 6 | | Run 2| 75 | 1594 | 2051 | 7 | | Run 3| 100 | 3769 | 4376 | 8 | | Run 4| 125 | 6569 | 7326 | 9 | | Run 5| 150 | 9994 | 10901 | 10 | | Run 6| 200 | 18719 | 19926 | 11 | 12 | 13 | ### Endpoint Performance Table 14 | 15 | | Endpoint | Run 1 (ms) | Run 2 (ms) | Run 3 (ms) | Run 4 (ms) | Run 5 (ms) | Run 6 (ms) | 16 | |---------------------------------------------|----------------|----------------|----------------|----------------|----------------|----------------| 17 | | `/debug/total-supply` | 13 | 14 | 13 | 13 | 14 | 15 | 18 | | `/debug/num-list-ops` | 12 | 13 | 13 | 15 | 13 | 15 | 19 | | `/debug/num-events` | 13 | 13 | 14 | 14 | 13 | 14 | 20 | | | | | | | | | 21 | | | | | | | | | 22 | | `/users/dr3a.eth/ens` | 88 | 112 | 100 | 236 | 86 | 87 | 23 | | `/users/dr3a.eth/primary-list` | 76 | 83 | 99 | 238 | 130 | 181 | 24 | | `/users/dr3a.eth/following` | 79 | 112 | 138 | 154 | 166 | 264 | 25 | | `/users/dr3a.eth/followers` | **1653** | **3280** | **5359** | **7828** | **10262** | **15821** | 26 | | `/users/dr3a.eth/stats` | **1675** | **3334** | **5396** | **8006** | **10823** | **16049** | 27 | | | | | | | | | 28 | | | | | | | | | 29 | | `/lists/0/records?includeTags=false` | 18 | 35 | 59 | 91 | 130 | 221 | 30 | | `/lists/0/records?includeTags=true` | 28 | **2030** | **9486** | **28215** | TIMEOUT | TIMEOUT | 31 | | | | | | | | | 32 | | | | | | | | | 33 | | `/leaderboard/blocked?limit=10` | 113 | **2611** | **10610** | TIMEOUT | TIMEOUT | TIMEOUT | 34 | | `/leaderboard/blocks?limit=10` | 113 | **2249** | **10590** | TIMEOUT | TIMEOUT | TIMEOUT | 35 | | `/leaderboard/muted?limit=10` | 113 | **2244** | **10659** | TIMEOUT | TIMEOUT | TIMEOUT | 36 | | `/leaderboard/mutes?limit=10` | 112 | **2250** | **10605** | TIMEOUT | TIMEOUT | TIMEOUT | 37 | | `/leaderboard/followers?limit=10` | **1003** | TIMEOUT | TIMEOUT | TIMEOUT | TIMEOUT | TIMEOUT | 38 | | `/leaderboard/following?limit=10` | **1006** | TIMEOUT | TIMEOUT | TIMEOUT | TIMEOUT | TIMEOUT | 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "efp-public-api", 3 | "version": "0.0.0", 4 | "description": "Ethereum Follow Protocol Public API", 5 | "type": "module", 6 | "scripts": { 7 | "build": "bunx wrangler deploy --dry-run --outdir=dist --minify=true --latest=true", 8 | "clean": "rm -rf dist .wrangler node_modules", 9 | "dev": "bunx wrangler dev --latest=true", 10 | "dev:remote": "bun dev --remote", 11 | "database:generate-types": "kysely-codegen --dialect='postgres' --type-only-imports --log-level='error' --out-file='./src/types/generated/index.ts' && bun format", 12 | "docker:api": "docker compose --file='compose.yml' --project-name='efp-api' up --build --force-recreate --remove-orphans --always-recreate-deps --renew-anon-volumes api", 13 | "docker:ping": "docker compose --file='compose.yml' --project-name='efp-api' up --build --force-recreate --remove-orphans --always-recreate-deps --renew-anon-volumes ping", 14 | "format": "bunx @biomejs/biome format . --write", 15 | "fix": "bun lint && bun format", 16 | "lint": "bunx @biomejs/biome check --write . && bun typecheck", 17 | "typecheck": "tsc --project tsconfig.json --noEmit", 18 | "test": "vitest --run", 19 | "test:typecheck": "vitest --typecheck", 20 | "update-dependencies": "bun ./scripts/update-dependencies.ts" 21 | }, 22 | "dependencies": { 23 | "@adraffy/ens-normalize": "^1.10.1", 24 | "@cf-wasm/photon": "^0.1.20", 25 | "@types/supertest": "^6.0.3", 26 | "aws4fetch": "^1.0.18", 27 | "consola": "^3.2.3", 28 | "hono": "^3.12.7", 29 | "kysely": "^0.27.2", 30 | "kysely-postgres-js": "^2.0.0", 31 | "postgres": "^3.4.3", 32 | "qr-image": "^3.2.0", 33 | "redis": "^4.7.0", 34 | "supertest": "^7.1.0" 35 | }, 36 | "devDependencies": { 37 | "@biomejs/biome": "^1.9.4", 38 | "@cloudflare/workers-types": "^4.20240117.0", 39 | "@total-typescript/ts-reset": "^0.5.1", 40 | "@types/bun": "^1.0.4", 41 | "@types/node": "^20.11.6", 42 | "@types/qr-image": "^3.2.9", 43 | "bun": "^1.0.25", 44 | "kysely-codegen": "^0.11.0", 45 | "pg": "^8.11.3", 46 | "typescript": "^5.3.3", 47 | "vitest": "^1.2.1", 48 | "wrangler": "4" 49 | }, 50 | "repository": "github:ethereumfollowprotocol/api", 51 | "homepage": "github:ethereumfollowprotocol/api/README.md", 52 | "contributors": ["Omar Aziz esm.eth", "Cory Gabrielsen cory.eth"], 53 | "sideEffects": false 54 | } 55 | -------------------------------------------------------------------------------- /reset.d.ts: -------------------------------------------------------------------------------- 1 | /* https://github.com/total-typescript/ts-reset */ 2 | import '@total-typescript/ts-reset' 3 | -------------------------------------------------------------------------------- /scripts/demo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This script performs API calls and can operate in two modes: 'print' or 'time'. 4 | # In 'print' mode, it prints the API call responses. 5 | # In 'time' mode, it times how long each API call takes. 6 | 7 | # Usage: 8 | # - Run in print mode (default): ./scripts/demo.sh 9 | # - Run in time mode: ./scripts/demo.sh time 10 | 11 | set -eou pipefail 12 | 13 | # source .env and .dev.vars if they exist 14 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 15 | [ -f "${SCRIPT_DIR}/../.env" ] && source "${SCRIPT_DIR}/../.env" 16 | [ -f "${SCRIPT_DIR}/../.dev.vars" ] && source "${SCRIPT_DIR}/../.dev.vars" 17 | 18 | # Check if IS_DEMO is set 19 | [ -z "${IS_DEMO+x}" ] && echo "IS_DEMO is not set. Exiting..." && exit 1 20 | # [ "$IS_DEMO" != "true" ] && echo "IS_DEMO=${IS_DEMO}" 21 | 22 | HOSTNAME=localhost 23 | PORT=8787 24 | API_URL="http://$HOSTNAME:$PORT/api/v1" 25 | 26 | # Parameters 27 | MODE=${1:-print} # Default mode is 'print'. Use 'time' for timing tests. 28 | 29 | # Function to print header 30 | function print_header() { 31 | printf "\n%-50s %10s\n" "Endpoint" "Time (ms)" 32 | printf "%-50s %10s\n" "--------------------------------------------------" "----------" 33 | } 34 | 35 | # Helper function to calculate average time 36 | function calculate_average_time() { 37 | local times=("$@") 38 | local num_times=${#times[@]} 39 | local sum=0 40 | 41 | for ((i=0; i { 12 | console.error(error) 13 | process.exit(1) 14 | }) 15 | 16 | async function main() { 17 | const updated = await bumpDependencies() 18 | if (updated) console.log('Dependencies updated') 19 | else console.log('Dependencies are up to date') 20 | 21 | const { stdout, success } = bun.spawnSync(['bun', 'install', '--no-cache', '--force']) 22 | console.log(`success: ${success}`, stdout.toString()) 23 | } 24 | 25 | async function bumpDependencies() { 26 | const unstableDependenciesNames = getUnstableDependencies(dependencies) 27 | const unstableDevDependenciesNames = getUnstableDependencies(devDependencies) 28 | 29 | // filter out packages whose version is beta 30 | const dependenciesNames = Object.keys(dependencies).filter(name => !Object.hasOwn(unstableDependenciesNames, name)) 31 | const latestDependenciesVersions = await Promise.all(dependenciesNames.map(name => fetchPackageLatestVersion(name))) 32 | 33 | const updatedDependencies = Object.fromEntries( 34 | dependenciesNames.map((name, index) => [name, `^${latestDependenciesVersions[index]}`]) 35 | ) 36 | 37 | for (const [name, version] of Object.entries(unstableDependenciesNames)) { 38 | updatedDependencies[name] = version 39 | } 40 | 41 | const devDependenciesNames = Object.keys(devDependencies).filter( 42 | name => !Object.hasOwn(unstableDevDependenciesNames, name) 43 | ) 44 | 45 | const latestDevDependenciesVersions = await Promise.all( 46 | devDependenciesNames.map(name => fetchPackageLatestVersion(name)) 47 | ) 48 | 49 | const updatedDevDependencies = Object.fromEntries( 50 | devDependenciesNames.map((name, index) => [name, `^${latestDevDependenciesVersions[index]}`]) 51 | ) 52 | 53 | for (const [name, version] of Object.entries(unstableDevDependenciesNames)) { 54 | updatedDevDependencies[name] = version 55 | } 56 | 57 | const updatedPackageJson = { 58 | name, 59 | version, 60 | description, 61 | type, 62 | scripts, 63 | dependencies: updatedDependencies, 64 | devDependencies: updatedDevDependencies, 65 | ...rest 66 | } 67 | 68 | const numBytesWritten = await bun.write( 69 | `${import.meta.dir}/../package.json`, 70 | `${JSON.stringify(updatedPackageJson, undefined, 2)}\n` 71 | ) 72 | 73 | if (numBytesWritten) { 74 | return true 75 | } 76 | return false 77 | } 78 | 79 | async function fetchPackageLatestVersion(name: string) { 80 | const response = await fetch(`https://registry.npmjs.org/${name}/latest`) 81 | const { version } = (await response.json()) as { version: string } 82 | return version 83 | } 84 | const betaRegexPattern = /beta/ 85 | function getUnstableDependencies(dependencies: Record) { 86 | return Object.entries(dependencies) 87 | .filter(([, version]) => betaRegexPattern.test(version)) 88 | .reduce((acc, [name, version]) => ({ ...acc, [name]: version }), {}) as Record 89 | } 90 | -------------------------------------------------------------------------------- /src/constant.ts: -------------------------------------------------------------------------------- 1 | export const API_URL = 'https://api.ethfollow.xyz/v1' 2 | 3 | export const DOCS_URL = 'https://docs.ethfollow.xyz/api' 4 | 5 | export const SOURCE_CODE_URL = 'https://github.com/ethereumfollowprotocol/api' 6 | // Muted by user 7 | // biome-ignore lint/nursery/noSecrets: 8 | export const TEAM_BRANTLY = '0x983110309620D911731Ac0932219af06091b6744' 9 | // Muted by user 10 | // biome-ignore lint/nursery/noSecrets: 11 | export const TEAM_ENCRYPTEDDEGEN = '0x5B0f3DBdD49614476e4f5fF5Db6fe13d41fCB516' 12 | // Muted by user 13 | // biome-ignore lint/nursery/noSecrets: 14 | export const TEAM_THROW = '0xC9C3A4337a1bba75D0860A1A81f7B990dc607334' 15 | // Muted by user 16 | // biome-ignore lint/nursery/noSecrets: 17 | export const NETWORKED_WALLET = '0x3Ff35a585dA47785cd70492921BfE3C8b97C7Aa9' 18 | -------------------------------------------------------------------------------- /src/database.ts: -------------------------------------------------------------------------------- 1 | import { type InsertObject, Kysely } from 'kysely' 2 | import { PostgresJSDialect } from 'kysely-postgres-js' 3 | import postgres from 'postgres' 4 | 5 | import type { DB, Environment } from '#/types' 6 | 7 | export type Row = InsertObject 8 | 9 | export function database(env: Environment) { 10 | return new Kysely({ 11 | dialect: new PostgresJSDialect({ 12 | // postgres: postgres(env.DATABASE_URL, {fetch_types: false}) 13 | postgres: postgres(env.DATABASE_URL) 14 | }) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/demo/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import type { Environment } from '#/types' 3 | 4 | const DEMO_NAME = 'dr3a.eth' 5 | // Muted by user 6 | // biome-ignore lint/nursery/noSecrets: 7 | const DEMO_ADDRESS = '0xeb6b293E9bB1d71240953c8306aD2c8aC523516a' 8 | 9 | export const demoRouter = new Hono<{ Bindings: Environment }>().basePath('/v1') 10 | 11 | demoRouter.get('/following/:addressOrENS', async context => { 12 | const id = context.req.param('addressOrENS') 13 | if (id !== DEMO_NAME && id !== DEMO_ADDRESS) return context.json({ data: [] }, 200) 14 | 15 | const demoKV = context.env.EFP_DEMO_KV 16 | const data = await demoKV.get('following', 'json') 17 | return context.json({ data }, 200) 18 | }) 19 | 20 | demoRouter.get('/followers/:addressOrENS', async context => { 21 | const id = context.req.param('addressOrENS') 22 | if (id !== DEMO_NAME && id !== DEMO_ADDRESS) return context.json({ data: [] }, 200) 23 | 24 | const demoKV = context.env.EFP_DEMO_KV 25 | const data = await demoKV.get('followers', 'json') 26 | return context.json({ data }, 200) 27 | }) 28 | 29 | demoRouter.get('/stats/:addressOrENS', async context => { 30 | const id = context.req.param('addressOrENS') 31 | if (id !== DEMO_NAME && id !== DEMO_ADDRESS) { 32 | return context.json({ data: { followersCount: 0, followingCount: 0 } }, 200) 33 | } 34 | 35 | const demoKV = context.env.EFP_DEMO_KV 36 | const data = await demoKV.get('stats', 'json') 37 | return context.json({ data }, 200) 38 | }) 39 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { cors } from 'hono/cors' 3 | import { HTTPException } from 'hono/http-exception' 4 | import { logger } from 'hono/logger' 5 | import { prettyJSON } from 'hono/pretty-json' 6 | import { secureHeaders } from 'hono/secure-headers' 7 | 8 | import { env } from 'hono/adapter' 9 | import { DOCS_URL, SOURCE_CODE_URL } from '#/constant.ts' 10 | import { demoRouter } from '#/demo' 11 | import { apiLogger } from '#/logger.ts' 12 | import { api } from '#/router/api/v1' 13 | import { errorHandler, errorLogger } from '#/router/middleware' 14 | import { CacheService } from '#/service/cache/service' 15 | import { EFPIndexerService } from '#/service/efp-indexer/service' 16 | import { ENSMetadataService } from '#/service/ens-metadata/service' 17 | import type { Environment } from '#/types' 18 | import type { Services } from './service' 19 | 20 | const app = new Hono<{ Bindings: Environment }>() 21 | 22 | app.use('*', async (context, next) => { 23 | const { COMMIT_SHA } = env(context) 24 | context.res.headers.set('X-Commit-SHA', COMMIT_SHA) 25 | const start = Date.now() 26 | await next() 27 | const end = Date.now() 28 | context.res.headers.set('X-Response-Time', `${end - start}ms`) 29 | }) 30 | 31 | app.use('*', logger()) 32 | 33 | app.use('*', cors({ origin: '*', allowMethods: ['GET', 'HEAD', 'OPTIONS'] })) 34 | 35 | /* append `?pretty` to any request to get prettified JSON */ 36 | app.use('*', prettyJSON({ space: 2 })) 37 | 38 | /** @docs https://hono.dev/middleware/builtin/secure-headers */ 39 | app.use( 40 | '*', 41 | secureHeaders({ 42 | xXssProtection: '1', 43 | xFrameOptions: 'DENY', 44 | strictTransportSecurity: 'max-age=63072000; includeSubDomains; preload' 45 | }) 46 | ) 47 | 48 | app.notFound(context => { 49 | const errorMessage = `${context.req.url} is not a valid path. Visit ${DOCS_URL} for documentation` 50 | apiLogger.error(errorMessage) 51 | return context.json({ error: errorMessage }, 404) 52 | }) 53 | 54 | app.onError((error, context) => { 55 | apiLogger.error(`[onError: ${context.req.url}]: ${error}`, context.error) 56 | if (error instanceof HTTPException) return error.getResponse() 57 | return context.json({ message: error.message }, 500) 58 | }) 59 | 60 | app.get('/', context => context.redirect('/api/v1')) 61 | 62 | app.get('/health', context => context.text('ok')) 63 | 64 | app.get('/docs', context => context.redirect('https://docs.ethfollow.xyz/api', 301)) 65 | 66 | app.get('/build-version', context => context.text(env(context).COMMIT_SHA)) 67 | 68 | app.get('/api/v1', context => 69 | context.json({ 70 | sha: env(context).COMMIT_SHA, 71 | name: 'efp-public-api', 72 | version: 'v1', 73 | docs: DOCS_URL, 74 | source: SOURCE_CODE_URL 75 | }) 76 | ) 77 | 78 | /** Logs all registered routes to the console. */ 79 | app.get('/routes', async context => { 80 | const verbose = context.req.query('verbose') 81 | const { ENVIRONMENT } = env(context) 82 | if (ENVIRONMENT === 'development') { 83 | const { showRoutes } = await import('hono/dev') 84 | showRoutes(app, { verbose: verbose === 'true' || verbose === '1' }) 85 | return new Response(JSON.stringify([...new Set(app.routes.map(({ path }) => path))], null, 2)) 86 | } 87 | return new Response(null, { status: 418 }) 88 | }) 89 | 90 | const services: Services = { 91 | ens: (env: Environment) => new ENSMetadataService(env), 92 | cache: (env: Environment) => new CacheService(env), 93 | efp: (env: Environment) => new EFPIndexerService(env) 94 | } 95 | app.route('/', api(services)) 96 | 97 | /** DEMO START */ 98 | app.route('/', demoRouter) 99 | /** DEMO END */ 100 | 101 | // Error handling middleware should be at the end 102 | app.use('*', errorLogger) 103 | app.use('*', errorHandler) 104 | 105 | const PORT = 8_787 106 | 107 | apiLogger.box(`🚀 API running on http://localhost:${PORT}`) 108 | 109 | export default { 110 | port: PORT, 111 | fetch: app.fetch 112 | } 113 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { createConsola } from 'consola' 2 | 3 | export const apiLogger = createConsola({ 4 | defaults: { tag: '@efp/api' }, 5 | formatOptions: { 6 | date: true, 7 | colors: true 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /src/router/api/v1/debug/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | 3 | import type { Services } from '#/service' 4 | import type { Environment } from '#/types' 5 | import { numEvents } from './num-events' 6 | import { numListOps } from './num-list-ops' 7 | import { totalSupply } from './total-supply' 8 | 9 | export function debug(services: Services): Hono<{ Bindings: Environment }> { 10 | const debug = new Hono<{ Bindings: Environment }>() 11 | 12 | numEvents(debug, services) 13 | numListOps(debug, services) 14 | totalSupply(debug, services) 15 | 16 | return debug 17 | } 18 | -------------------------------------------------------------------------------- /src/router/api/v1/debug/num-events/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { Environment } from '#/types' 5 | 6 | export function numEvents(users: Hono<{ Bindings: Environment }>, services: Services) { 7 | users.get('/num-events', async context => { 8 | const numEvents = await services.efp(env(context)).getDebugNumEvents() 9 | 10 | return context.json({ num_events: numEvents }, 200) 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/router/api/v1/debug/num-list-ops/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { Environment } from '#/types' 5 | 6 | export function numListOps(users: Hono<{ Bindings: Environment }>, services: Services) { 7 | users.get('/num-list-ops', async context => { 8 | const numListOps = await services.efp(env(context)).getDebugNumListOps() 9 | 10 | return context.json({ num_list_ops: numListOps }, 200) 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/router/api/v1/debug/total-supply/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { Environment } from '#/types' 5 | 6 | export function totalSupply(users: Hono<{ Bindings: Environment }>, services: Services) { 7 | users.get('/total-supply', async context => { 8 | const totalSupply = await services.efp(env(context)).getDebugTotalSupply() 9 | 10 | return context.json({ total_supply: totalSupply }, 200) 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/router/api/v1/discover/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { DiscoverRow, IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { Environment } from '#/types' 7 | 8 | export function discover(services: Services): Hono<{ Bindings: Environment }> { 9 | const discover = new Hono<{ Bindings: Environment }>() 10 | 11 | discover.get('/', includeValidator, async context => { 12 | let { offset, limit } = context.req.valid('query') 13 | if (!limit) limit = '10' 14 | if (!offset) offset = '0' 15 | const efp: IEFPIndexerService = services.efp(env(context)) 16 | const latestFollows: DiscoverRow[] = await efp.getDiscoverAccounts(limit as string, offset as string) 17 | 18 | return context.json({ latestFollows }, 200) 19 | }) 20 | return discover 21 | } 22 | -------------------------------------------------------------------------------- /src/router/api/v1/exportState/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { FollowingResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { Environment } from '#/types' 6 | import { prettifyListRecord } from '#/types/list-record' 7 | 8 | export function exportState(services: Services): Hono<{ Bindings: Environment }> { 9 | const exportState = new Hono<{ Bindings: Environment }>() 10 | 11 | exportState.get('/:token_id', async context => { 12 | const { token_id } = context.req.param() 13 | if (!token_id || Number.isNaN(token_id)) { 14 | return context.json({ response: 'Invalid Token Id' }, 404) 15 | } 16 | const efp: IEFPIndexerService = services.efp(env(context)) 17 | const followingListRecords: FollowingResponse[] = await efp.getUserFollowingByListRaw(token_id) 18 | const response = followingListRecords.map(prettifyListRecord) 19 | return context.json({ following: response }, 200) 20 | }) 21 | return exportState 22 | } 23 | -------------------------------------------------------------------------------- /src/router/api/v1/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | 3 | import { env } from 'hono/adapter' 4 | // api/v1/index.ts 5 | import { database } from '#/database' 6 | import { apiLogger } from '#/logger' 7 | import type { Services } from '#/service' 8 | import type { Environment } from '#/types' 9 | import { debug } from './debug' 10 | import { discover } from './discover' 11 | import { exportState } from './exportState' 12 | import { leaderboard } from './leaderboard' 13 | import { lists } from './lists' 14 | import { minters } from './minters' 15 | import { serviceHealth } from './serviceHealth' 16 | import { stats } from './stats' 17 | import { token } from './token' 18 | import { users } from './users' 19 | 20 | export function api(services: Services): Hono<{ Bindings: Environment }> { 21 | const api = new Hono<{ Bindings: Environment }>().basePath('/api/v1') 22 | 23 | api.get('/docs', context => context.redirect('https://docs.ethfollow.xyz/api', 301)) 24 | 25 | api.get('/database/health', async context => { 26 | const db = database(env(context)) 27 | 28 | // do a simple query to check if the database is up 29 | try { 30 | await db.selectFrom('events').select('event_name').limit(1).execute() 31 | } catch (error) { 32 | apiLogger.error(`error while checking postgres health: ${JSON.stringify(error, undefined, 2)}`) 33 | return context.text('error while checking postgres health', 500) 34 | } 35 | // database is up 36 | return context.text('ok', 200) 37 | }) 38 | 39 | api.get('/health', context => context.text('ok')) 40 | 41 | api.route('/debug', debug(services)) 42 | api.route('/serviceHealth', serviceHealth(services)) 43 | api.route('/discover', discover(services)) 44 | api.route('/exportState', exportState(services)) 45 | api.route('/leaderboard', leaderboard(services)) 46 | api.route('/lists', lists(services)) 47 | api.route('/minters', minters(services)) 48 | api.route('/stats', stats(services)) 49 | api.route('/token', token(services)) 50 | api.route('/users', users(services)) 51 | 52 | return api 53 | } 54 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/all/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { LeaderBoardRow } from '#/service/efp-indexer/service' 6 | import type { Environment } from '#/types' 7 | 8 | export function all(leaderboard: Hono<{ Bindings: Environment }>, services: Services) { 9 | leaderboard.get('/all', async context => { 10 | const cache = context.req.query('cache') 11 | 12 | const cacheService = services.cache(env(context)) 13 | const cacheTarget = `leaderboard/all` 14 | if (cache !== 'fresh') { 15 | const cacheHit = await cacheService.get(cacheTarget) 16 | if (cacheHit) { 17 | return context.json({ ...cacheHit }, 200) 18 | } 19 | } 20 | 21 | const efp = services.efp(env(context)) 22 | const results: { address: `0x${string}`; name: string }[] = await efp.getLeaderboardAll() 23 | 24 | const packagedResponse = { results } 25 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 26 | return context.json(packagedResponse, 200) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/blocked/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { Environment } from '#/types' 6 | import type { IncludeValidator, LimitValidator } from '../validators' 7 | 8 | /** 9 | * TODO: add support for whether :addressOrENS is followed, is following, is muting, is blocking, is blocked by 10 | */ 11 | export function blocked( 12 | leaderboard: Hono<{ Bindings: Environment }>, 13 | services: Services, 14 | limitValidator: LimitValidator, 15 | includeValidator: IncludeValidator 16 | ) { 17 | /** 18 | * Same as /followers, but for blocked. 19 | */ 20 | leaderboard.get('/blocked', limitValidator, includeValidator, async context => { 21 | const { include, limit } = context.req.valid('query') 22 | const parsedLimit = Number.parseInt(limit?.toString() || '10', 10) 23 | let mostBlocked: { address: string; blocked_by_count: number }[] = await services 24 | .efp(env(context)) 25 | .getLeaderboardBlocked(parsedLimit) 26 | if (include?.includes('ens')) { 27 | const ens = services.ens(env(context)) 28 | const ensProfiles = await Promise.all(mostBlocked.map(user => ens.getENSProfile(user.address))) 29 | mostBlocked = mostBlocked.map((user, index) => ({ 30 | ...user, 31 | ens: ensProfiles[index] 32 | })) 33 | } 34 | return context.json(mostBlocked, 200) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/blocks/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { Environment } from '#/types' 6 | import type { IncludeValidator, LimitValidator } from '../validators' 7 | 8 | /** 9 | * TODO: add support for whether :addressOrENS is followed, is following, is muting, is blocking, is blocked by 10 | */ 11 | export function blocks( 12 | leaderboard: Hono<{ Bindings: Environment }>, 13 | services: Services, 14 | limitValidator: LimitValidator, 15 | includeValidator: IncludeValidator 16 | ) { 17 | /** 18 | * Same as /followers, but for following. 19 | */ 20 | leaderboard.get('/blocks/:addressOrENS?', limitValidator, includeValidator, async context => { 21 | const { include, limit } = context.req.valid('query') 22 | const parsedLimit = Number.parseInt(limit?.toString() || '10', 10) 23 | let mostBlocks: { address: string; blocks_count: number }[] = await services 24 | .efp(env(context)) 25 | .getLeaderboardBlocks(parsedLimit) 26 | if (include?.includes('ens')) { 27 | const ens = services.ens(env(context)) 28 | const ensProfiles = await Promise.all(mostBlocks.map(user => ens.getENSProfile(user.address))) 29 | mostBlocks = mostBlocks.map((user, index) => ({ 30 | ...user, 31 | ens: ensProfiles[index] 32 | })) 33 | } 34 | return context.json(mostBlocks, 200) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/count/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { LeaderBoardRow } from '#/service/efp-indexer/service' 6 | import type { Environment } from '#/types' 7 | import type { IncludeValidator, LimitValidator } from '../validators' 8 | 9 | export function count( 10 | leaderboard: Hono<{ Bindings: Environment }>, 11 | services: Services, 12 | limitValidator: LimitValidator, 13 | includeValidator: IncludeValidator 14 | ) { 15 | leaderboard.get('/count', limitValidator, includeValidator, async context => { 16 | const efp = await services.efp(env(context)) 17 | const leaderboardCount: number = await efp.getLeaderboardCount() 18 | 19 | return context.json({ leaderboardCount }, 200) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/followers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { Environment } from '#/types' 6 | import type { IncludeValidator, LimitValidator } from '../validators' 7 | 8 | /** 9 | * TODO: add support for whether :addressOrENS is followed, is following, is muting, is blocking, is blocked by 10 | */ 11 | export function followers( 12 | leaderboard: Hono<{ Bindings: Environment }>, 13 | services: Services, 14 | limitValidator: LimitValidator, 15 | includeValidator: IncludeValidator 16 | ) { 17 | /** 18 | * By default, only returns leaderboard with address and followers_count/following_count of each user. 19 | * If include=ens, also returns ens profile of each user. 20 | * If include=muted, also returns how many users each user has muted. 21 | * If include=blocked, also returns how many users each user has blocked. 22 | * If addressOrENS path param is provided AND include=mutuals query param is provided, returns mutuals between addressOrENS and each user. 23 | */ 24 | leaderboard.get('/followers/:addressOrENS?', limitValidator, includeValidator, async context => { 25 | const { include, limit } = context.req.valid('query') 26 | const parsedLimit = Number.parseInt(limit?.toString() || '10', 10) 27 | let mostFollowers: { address: string; followers_count: number }[] = await services 28 | .efp(env(context)) 29 | .getLeaderboardFollowers(parsedLimit) 30 | if (include?.includes('ens')) { 31 | const ens = services.ens(env(context)) 32 | const ensProfiles = await Promise.all(mostFollowers.map(user => ens.getENSProfile(user.address))) 33 | mostFollowers = mostFollowers.map((user, index) => ({ 34 | ...user, 35 | ens: ensProfiles[index] 36 | })) 37 | } 38 | return context.json(mostFollowers, 200) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/following/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { Environment } from '#/types' 6 | import type { IncludeValidator, LimitValidator } from '../validators' 7 | 8 | /** 9 | * TODO: add support for whether :addressOrENS is followed, is following, is muting, is blocking, is blocked by 10 | */ 11 | export function following( 12 | leaderboard: Hono<{ Bindings: Environment }>, 13 | services: Services, 14 | limitValidator: LimitValidator, 15 | includeValidator: IncludeValidator 16 | ) { 17 | /** 18 | * Same as /followers, but for following. 19 | */ 20 | leaderboard.get('/following/:addressOrENS?', limitValidator, includeValidator, async context => { 21 | const { include, limit } = context.req.valid('query') 22 | const parsedLimit = Number.parseInt(limit?.toString() || '10', 10) 23 | let mostFollowing: { address: string; following_count: number }[] = await services 24 | .efp(env(context)) 25 | .getLeaderboardFollowing(parsedLimit) 26 | if (include?.includes('ens')) { 27 | const ens = services.ens(env(context)) 28 | const ensProfiles = await Promise.all(mostFollowing.map(user => ens.getENSProfile(user.address))) 29 | mostFollowing = mostFollowing.map((user, index) => ({ 30 | ...user, 31 | ens: ensProfiles[index] 32 | })) 33 | } 34 | return context.json(mostFollowing, 200) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | 3 | import type { Services } from '#/service' 4 | import type { Environment } from '#/types' 5 | import { all } from './all' 6 | import { blocked } from './blocked' 7 | import { blocks } from './blocks' 8 | import { count } from './count' 9 | import { followers } from './followers' 10 | import { following } from './following' 11 | import { muted } from './muted' 12 | import { mutes } from './mutes' 13 | import { ranked } from './ranked' 14 | import { search } from './search' 15 | import { includeValidator, limitValidator } from './validators' 16 | 17 | export function leaderboard(services: Services): Hono<{ Bindings: Environment }> { 18 | const leaderboard = new Hono<{ Bindings: Environment }>() 19 | 20 | all(leaderboard, services) 21 | blocked(leaderboard, services, limitValidator, includeValidator) 22 | blocks(leaderboard, services, limitValidator, includeValidator) 23 | count(leaderboard, services, limitValidator, includeValidator) 24 | followers(leaderboard, services, limitValidator, includeValidator) 25 | following(leaderboard, services, limitValidator, includeValidator) 26 | muted(leaderboard, services, limitValidator, includeValidator) 27 | mutes(leaderboard, services, limitValidator, includeValidator) 28 | ranked(leaderboard, services, limitValidator, includeValidator) 29 | search(leaderboard, services, limitValidator, includeValidator) 30 | 31 | return leaderboard 32 | } 33 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/muted/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { Environment } from '#/types' 6 | import type { IncludeValidator, LimitValidator } from '../validators' 7 | 8 | /** 9 | * TODO: add support for whether :addressOrENS is followed, is following, is muting, is blocking, is blocked by 10 | */ 11 | export function muted( 12 | leaderboard: Hono<{ Bindings: Environment }>, 13 | services: Services, 14 | limitValidator: LimitValidator, 15 | includeValidator: IncludeValidator 16 | ) { 17 | /** 18 | * Same as /followers, but for muted. 19 | */ 20 | leaderboard.get('/muted?', limitValidator, includeValidator, async context => { 21 | const { include, limit } = context.req.valid('query') 22 | const parsedLimit = Number.parseInt(limit?.toString() || '10', 10) 23 | let mostMuted: { address: string; muted_by_count: number }[] = await services 24 | .efp(env(context)) 25 | .getLeaderboardMuted(parsedLimit) 26 | if (include?.includes('ens')) { 27 | const ens = services.ens(env(context)) 28 | const ensProfiles = await Promise.all(mostMuted.map(user => ens.getENSProfile(user.address))) 29 | mostMuted = mostMuted.map((user, index) => ({ 30 | ...user, 31 | ens: ensProfiles[index] 32 | })) 33 | } 34 | return context.json(mostMuted, 200) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/mutes/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { Environment } from '#/types' 6 | import type { IncludeValidator, LimitValidator } from '../validators' 7 | 8 | /** 9 | * TODO: add support for whether :addressOrENS is followed, is following, is muting, is blocking, is blocked by 10 | */ 11 | export function mutes( 12 | leaderboard: Hono<{ Bindings: Environment }>, 13 | services: Services, 14 | limitValidator: LimitValidator, 15 | includeValidator: IncludeValidator 16 | ) { 17 | /** 18 | * Same as /followers, but for following. 19 | */ 20 | leaderboard.get('/mutes/:addressOrENS?', limitValidator, includeValidator, async context => { 21 | const { include, limit } = context.req.valid('query') 22 | const parsedLimit = Number.parseInt(limit?.toString() || '10', 10) 23 | let mostMutes: { address: string; mutes_count: number }[] = await services 24 | .efp(env(context)) 25 | .getLeaderboardMutes(parsedLimit) 26 | if (include?.includes('ens')) { 27 | const ens = services.ens(env(context)) 28 | const ensProfiles = await Promise.all(mostMutes.map(user => ens.getENSProfile(user.address))) 29 | mostMutes = mostMutes.map((user, index) => ({ 30 | ...user, 31 | ens: ensProfiles[index] 32 | })) 33 | } 34 | return context.json(mostMutes, 200) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/ranked/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { LeaderBoardRow } from '#/service/efp-indexer/service' 6 | import type { Environment } from '#/types' 7 | import type { IncludeValidator, LimitValidator } from '../validators' 8 | 9 | export function ranked( 10 | leaderboard: Hono<{ Bindings: Environment }>, 11 | services: Services, 12 | limitValidator: LimitValidator, 13 | includeValidator: IncludeValidator 14 | ) { 15 | leaderboard.get('/ranked', limitValidator, includeValidator, async context => { 16 | let { limit, offset, cache } = context.req.valid('query') 17 | 18 | if (!limit) limit = '50' 19 | if (!offset) offset = '0' 20 | const sort = context.req.query('sort') ? context.req.query('sort') : 'mutuals' 21 | const direction = context.req.query('direction') ? context.req.query('direction') : 'DESC' 22 | 23 | const cacheService = services.cache(env(context)) 24 | const cacheTarget = `leaderboard/ranked?limit=${limit}&offset=${offset}&sort=${sort}&direction=${direction}` 25 | if (cache !== 'fresh') { 26 | const cacheHit = await cacheService.get(cacheTarget) 27 | if (cacheHit) { 28 | return context.json({ ...cacheHit }, 200) 29 | } 30 | } 31 | 32 | const parsedLimit = Number.parseInt(limit as string, 10) 33 | const offsetLimit = Number.parseInt(offset as string, 10) 34 | const efp = services.efp(env(context)) 35 | const results: LeaderBoardRow[] = await efp.getLeaderboardRanked(parsedLimit, offsetLimit, sort, direction) 36 | const last_updated = results.length > 0 ? results[0]?.updated_at : '0' 37 | 38 | const packagedResponse = { last_updated, results } 39 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 40 | return context.json(packagedResponse, 200) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/search/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { LeaderBoardRow } from '#/service/efp-indexer/service' 6 | import type { Environment } from '#/types' 7 | import { isAddress, textOrEmojiPattern } from '#/utilities' 8 | import type { IncludeValidator, LimitValidator } from '../validators' 9 | 10 | export function search( 11 | leaderboard: Hono<{ Bindings: Environment }>, 12 | services: Services, 13 | limitValidator: LimitValidator, 14 | includeValidator: IncludeValidator 15 | ) { 16 | leaderboard.get('/search', limitValidator, includeValidator, async context => { 17 | let term = context.req.query('term') 18 | 19 | if (!term?.match(textOrEmojiPattern)) { 20 | return context.json({ results: [] }, 200) 21 | } 22 | if (!isAddress(term as string)) { 23 | term = term?.toLowerCase() 24 | } 25 | const efp = services.efp(env(context)) 26 | const results: LeaderBoardRow[] = await efp.searchLeaderboard(term as string) 27 | const last_updated = results.length > 0 ? results[0]?.updated_at : '0' 28 | 29 | return context.json({ last_updated, results }, 200) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/router/api/v1/leaderboard/validators.ts: -------------------------------------------------------------------------------- 1 | import { validator } from 'hono/validator' 2 | import { ensureArray } from '#/utilities' 3 | 4 | export const limitValidator = validator('query', value => { 5 | const { limit } = value 6 | if (limit !== undefined && !Number.isSafeInteger(Number.parseInt(limit as string, 10))) { 7 | return new Response(JSON.stringify({ message: 'Accepted format for limit: ?limit=50' }), { status: 400 }) 8 | } 9 | return value 10 | }) 11 | 12 | export type LimitValidator = typeof limitValidator 13 | 14 | export const includeValidator = validator('query', value => { 15 | const allFilters = ['ens', 'mutuals', 'blocked', 'muted'] 16 | // if only one include query param, type is string, if 2+ then type is array, if none then undefined 17 | const { include } = >value 18 | // if no include query param, return minimal data 19 | if (!include) return value 20 | if (ensureArray(include).every(filter => allFilters.includes(filter))) { 21 | return value 22 | } 23 | return new Response( 24 | JSON.stringify({ 25 | // Muted by user 26 | // biome-ignore lint/nursery/noSecrets: 27 | message: 'Accepted format for include: ?include=ens&include=mutuals&include=blocked&include=muted' 28 | }), 29 | { status: 400 } 30 | ) 31 | }) 32 | 33 | export type IncludeValidator = typeof includeValidator 34 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/account/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { ENSProfile } from '#/service/ens-metadata/types' 6 | import type { Address, Environment } from '#/types' 7 | 8 | export function account(lists: Hono<{ Bindings: Environment }>, services: Services) { 9 | lists.get('/:token_id/account', async context => { 10 | const { token_id } = context.req.param() 11 | const ensService = services.ens(env(context)) 12 | const efp: IEFPIndexerService = services.efp(env(context)) 13 | const listUser: Address | undefined = await services.efp(env(context)).getAddressByList(token_id) 14 | if (!listUser) { 15 | return context.json({ response: 'No User Found' }, 404) 16 | } 17 | const { address, ...ens }: ENSProfile = await ensService.getENSProfile(listUser.toLowerCase()) 18 | const primaryList = await efp.getUserPrimaryList(address) 19 | 20 | const response = { address } as Record 21 | const is_primary_list = (primaryList ? Number.parseInt(primaryList.toString()) : null) === Number.parseInt(token_id) 22 | return context.json({ ...response, ens, is_primary_list, primary_list: primaryList?.toString() ?? null }, 200) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/allFollowers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { FollowerResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { Address, Environment } from '#/types' 8 | import { textOrEmojiPattern } from '#/utilities' 9 | 10 | export type ENSFollowerResponse = FollowerResponse & { ens?: ENSProfileResponse } 11 | 12 | export function allFollowers(lists: Hono<{ Bindings: Environment }>, services: Services) { 13 | lists.get('/:token_id/allFollowers', includeValidator, async context => { 14 | const { token_id } = context.req.param() 15 | let { include, offset, limit } = context.req.valid('query') 16 | if (!limit) limit = '10' 17 | if (!offset) offset = '0' 18 | 19 | const listUser: Address | undefined = await services.efp(env(context)).getAddressByList(token_id) 20 | // const address: Address = await ensService.getAddress(addressOrENS) 21 | if (!listUser) { 22 | return context.json({ response: 'No User Found' }, 404) 23 | } 24 | 25 | const tagsQuery = context.req.query('tags') 26 | let tagsToSearch: string[] = [] 27 | if (tagsQuery) { 28 | const tagsArray = tagsQuery.split(',') 29 | tagsToSearch = tagsArray.filter((tag: any) => tag.match(textOrEmojiPattern)) 30 | } 31 | 32 | const direction = context.req.query('sort') === 'latest' ? 'DESC' : 'ASC' 33 | 34 | const efp: IEFPIndexerService = services.efp(env(context)) 35 | 36 | const followers: FollowerResponse[] = await efp.getAllUserFollowersByListTagSort( 37 | token_id, 38 | limit, 39 | offset, 40 | tagsToSearch, 41 | direction 42 | ) 43 | 44 | let response: ENSFollowerResponse[] = followers 45 | 46 | if (include?.includes('ens')) { 47 | const ensService = services.ens(env(context)) 48 | const ensProfilesForFollowers: ENSProfileResponse[] = await ensService.batchGetENSProfiles( 49 | followers.map(follower => follower.address) 50 | ) 51 | 52 | response = followers.map((follower, index) => { 53 | const ens: ENSProfileResponse = ensProfilesForFollowers[index] as ENSProfileResponse 54 | const ensFollowerResponse: ENSFollowerResponse = { 55 | ...follower, 56 | ens 57 | } 58 | return ensFollowerResponse 59 | }) 60 | } 61 | 62 | return context.json({ followers: response }, 200) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/allFollowing/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { FollowingResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { ENSProfile } from '#/service/ens-metadata/types' 8 | import type { Address, Environment } from '#/types' 9 | import { type PrettyTaggedListRecord, hexlify, prettifyListRecord } from '#/types/list-record' 10 | import { textOrEmojiPattern } from '#/utilities' 11 | 12 | export type ENSFollowingResponse = PrettyTaggedListRecord & { 13 | ens?: ENSProfileResponse 14 | } 15 | 16 | /** 17 | * Enhanced to add ENS support 18 | */ 19 | export function allFollowing(lists: Hono<{ Bindings: Environment }>, services: Services) { 20 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 21 | lists.get('/:token_id/allFollowing', includeValidator, async context => { 22 | const { token_id } = context.req.param() 23 | let { offset, limit } = context.req.valid('query') 24 | 25 | if (!limit || Number.isNaN(limit)) limit = '10' 26 | if (!offset || Number.isNaN(offset)) offset = '0' 27 | 28 | const listUser: Address | undefined = await services.efp(env(context)).getAddressByList(token_id) 29 | 30 | if (!listUser) { 31 | return context.json({ response: 'No User Found' }, 404) 32 | } 33 | 34 | const tagsQuery = context.req.query('tags') 35 | let tagsToSearch: string[] = [] 36 | if (tagsQuery) { 37 | const tagsArray = tagsQuery.split(',') 38 | tagsToSearch = tagsArray.filter((tag: any) => tag.match(textOrEmojiPattern)) 39 | } 40 | 41 | const direction = context.req.query('sort') === 'latest' ? 'DESC' : 'ASC' 42 | 43 | const efp: IEFPIndexerService = services.efp(env(context)) 44 | const followingListRecords: FollowingResponse[] = await efp.getAllUserFollowingByListTagSort( 45 | token_id, 46 | limit as string, 47 | offset as string, 48 | tagsToSearch, 49 | direction 50 | ) 51 | 52 | let response: ENSFollowingResponse[] 53 | 54 | // Check if 'ens' information should be included 55 | if (context.req.query('include')?.includes('ens')) { 56 | const ensService = services.ens(env(context)) 57 | // Filter for address records 58 | const addressRecords = followingListRecords.filter(record => record.recordType === 1) 59 | 60 | // Fetch ENS profiles in batch 61 | const addresses: Address[] = addressRecords.map(record => hexlify(record.data)) 62 | const ensProfiles: ENSProfile[] = [] 63 | for (const address of addresses) { 64 | const profile = await ensService.getENSProfile(address) 65 | ensProfiles.push(profile) 66 | } 67 | // Collect ENS profiles into a lookup map by address 68 | const ensMap: Map = new Map( 69 | addresses.map((address, index) => { 70 | if (!ensProfiles[index]?.name) { 71 | return [address, { name: '', address: address, avatar: null } as unknown as ENSProfileResponse] 72 | } 73 | return [address, ensProfiles[index] as ENSProfileResponse] 74 | }) 75 | ) 76 | 77 | // Aggregate ENS profiles back into the full list 78 | response = followingListRecords.map(record => { 79 | return record.recordType !== 1 80 | ? prettifyListRecord(record) 81 | : { ...prettifyListRecord(record), ens: ensMap.get(hexlify(record.data)) as ENSProfileResponse } 82 | }) 83 | } else { 84 | // If ENS is not included, just map the following list records to the pretty format 85 | response = followingListRecords.map(prettifyListRecord) 86 | } 87 | 88 | return context.json({ following: response }, 200) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/allFollowingAddresses/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { Address, Environment } from '#/types' 8 | import { type PrettyTaggedListRecord, hexlify, prettifyListRecord } from '#/types/list-record' 9 | 10 | export type ENSFollowingResponse = PrettyTaggedListRecord & { 11 | ens?: ENSProfileResponse 12 | } 13 | 14 | export function allFollowingAddresses(lists: Hono<{ Bindings: Environment }>, services: Services) { 15 | // Muted by user 16 | // biome-ignore lint/nursery/noSecrets: 17 | lists.get('/:token_id/allFollowingAddresses', includeValidator, async context => { 18 | const { token_id } = context.req.param() 19 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 20 | return context.json({ response: 'Invalid list id' }, 400) 21 | } 22 | const { cache } = context.req.valid('query') 23 | const cacheService = services.cache(env(context)) 24 | const cacheTarget = `lists/${token_id}/allFollowingAddresses` 25 | if (cache !== 'fresh') { 26 | const cacheHit = await cacheService.get(cacheTarget) 27 | if (cacheHit) { 28 | return context.json({ ...cacheHit }, 200) 29 | } 30 | } 31 | const listUser: Address | undefined = await services.efp(env(context)).getAddressByList(token_id) 32 | if (!listUser) { 33 | return context.json({ response: 'No User Found' }, 404) 34 | } 35 | 36 | const efp: IEFPIndexerService = services.efp(env(context)) 37 | const followingAddresses: Address[] = await efp.getAllUserFollowingAddresses(token_id) 38 | 39 | const packagedResponse = followingAddresses 40 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 41 | 42 | return context.json(packagedResponse, 200) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/buttonState/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { FollowStateResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | import { isAddress } from '#/utilities' 7 | 8 | export function buttonState(lists: Hono<{ Bindings: Environment }>, services: Services) { 9 | lists.get('/:token_id/:addressOrENS/buttonState', async context => { 10 | const { token_id, addressOrENS } = context.req.param() 11 | const { cache } = context.req.query() 12 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 13 | return context.json({ response: 'Invalid list id' }, 400) 14 | } 15 | const cacheService = services.cache(env(context)) 16 | const cacheTarget = `lists/${token_id}/${addressOrENS}/buttonState`.toLowerCase() 17 | if (cache !== 'fresh') { 18 | const cacheHit = await cacheService.get(cacheTarget) 19 | if (cacheHit) { 20 | return context.json({ ...cacheHit }, 200) 21 | } 22 | } 23 | 24 | const ensService = services.ens(env(context)) 25 | const address: Address = await ensService.getAddress(addressOrENS) 26 | if (!isAddress(address)) { 27 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 28 | } 29 | const efp: IEFPIndexerService = services.efp(env(context)) 30 | const state: FollowStateResponse = await efp.getListFollowingState(token_id, address) 31 | const packagedResponse = { token_id, address, state } 32 | if (env(context).ALLOW_TTL_MOD === 'true') { 33 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse), 0) 34 | } else { 35 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 36 | } 37 | return context.json(packagedResponse, 200) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/details/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { ENSProfile } from '#/service/ens-metadata/types' 6 | import type { Address, Environment } from '#/types' 7 | 8 | export function details(lists: Hono<{ Bindings: Environment }>, services: Services) { 9 | lists.get('/:token_id/details', async context => { 10 | const { token_id } = context.req.param() 11 | const { cache } = context.req.query() 12 | // const { live } = context.req.query() 13 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 14 | return context.json({ response: 'Invalid list id' }, 400) 15 | } 16 | 17 | const cacheService = services.cache(env(context)) 18 | const cacheTarget = `lists/${token_id}/details` 19 | if (cache !== 'fresh') { 20 | const cacheHit = await cacheService.get(cacheTarget) 21 | if (cacheHit) { 22 | return context.json({ ...cacheHit }, 200) 23 | } 24 | } 25 | const refreshENS = !!cache 26 | const ensService = services.ens(env(context)) 27 | const efp: IEFPIndexerService = services.efp(env(context)) 28 | const listUser: Address | undefined = await services.efp(env(context)).getAddressByList(token_id) 29 | if (!listUser) { 30 | return context.json({ response: 'No User Found' }, 404) 31 | } 32 | const { address, ...ens }: ENSProfile = await ensService.getENSProfile(listUser.toLowerCase(), refreshENS) 33 | const primaryList = await efp.getUserPrimaryList(address) 34 | 35 | const ranksAndCounts = await efp.getUserRanksCounts(address) 36 | 37 | const ranks = { 38 | mutuals_rank: ranksAndCounts.mutuals_rank, 39 | followers_rank: ranksAndCounts.followers_rank, 40 | following_rank: ranksAndCounts.following_rank, 41 | top8_rank: ranksAndCounts.top8_rank, 42 | blocks_rank: ranksAndCounts.blocks_rank 43 | } 44 | const response = { address } as Record 45 | const packagedResponse = { ...response, ens, ranks, primary_list: primaryList?.toString() ?? null } 46 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 47 | return context.json(packagedResponse, 200) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/followerState/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { FollowStateResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | import { isAddress } from '#/utilities' 7 | 8 | export function followerState(lists: Hono<{ Bindings: Environment }>, services: Services) { 9 | lists.get('/:token_id/:addressOrENS/followerState', async context => { 10 | const { token_id, addressOrENS } = context.req.param() 11 | const { cache } = context.req.query() 12 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 13 | return context.json({ response: 'Invalid list id' }, 400) 14 | } 15 | const cacheService = services.cache(env(context)) 16 | const cacheTarget = `lists/${token_id}/${addressOrENS}/followerState`.toLowerCase() 17 | if (cache !== 'fresh') { 18 | const cacheHit = await cacheService.get(cacheTarget) 19 | if (cacheHit) { 20 | return context.json({ ...cacheHit }, 200) 21 | } 22 | } 23 | 24 | const ensService = services.ens(env(context)) 25 | const address: Address = await ensService.getAddress(addressOrENS) 26 | if (!isAddress(address)) { 27 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 28 | } 29 | const efp: IEFPIndexerService = services.efp(env(context)) 30 | const state: FollowStateResponse = await efp.getListFollowerState(token_id, address) 31 | const packagedResponse = { token_id, address, state } 32 | if (env(context).ALLOW_TTL_MOD === 'true') { 33 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse), 0) 34 | } else { 35 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 36 | } 37 | return context.json(packagedResponse, 200) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/followers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { FollowerResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { Address, Environment } from '#/types' 8 | import { textOrEmojiPattern } from '#/utilities' 9 | 10 | export type ENSFollowerResponse = FollowerResponse & { ens?: ENSProfileResponse } 11 | 12 | export function followers(lists: Hono<{ Bindings: Environment }>, services: Services) { 13 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 14 | lists.get('/:token_id/followers', includeValidator, async context => { 15 | const { token_id } = context.req.param() 16 | 17 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 18 | return context.json({ response: 'Invalid list id' }, 400) 19 | } 20 | 21 | let { include, offset, limit, cache } = context.req.valid('query') 22 | 23 | if (!limit) limit = '10' 24 | if (!offset) offset = '0' 25 | let direction = 'latest' 26 | if (context.req.query('sort')?.toLowerCase() === 'followers') { 27 | direction = 'followers' 28 | } else if (context.req.query('sort')?.toLowerCase() === 'earliest') { 29 | direction = 'earliest' 30 | } 31 | 32 | const tagsQuery = context.req.query('tags') 33 | let tagsToSearch: string[] = [] 34 | if (tagsQuery) { 35 | const tagsArray = tagsQuery.split(',') 36 | tagsToSearch = tagsArray.filter((tag: any) => tag.match(textOrEmojiPattern)) 37 | } 38 | 39 | const cacheService = services.cache(env(context)) 40 | const cacheTarget = `lists/${token_id}/followers?limit=${limit}&offset=${offset}&sort=${direction}&tags=${tagsToSearch.join(',')}` 41 | if (cache !== 'fresh') { 42 | const cacheHit = await cacheService.get(cacheTarget) 43 | if (cacheHit) { 44 | return context.json({ ...cacheHit }, 200) 45 | } 46 | } 47 | 48 | const listUser: Address | undefined = await services.efp(env(context)).getAddressByList(token_id) 49 | // const address: Address = await ensService.getAddress(addressOrENS) 50 | if (!listUser) { 51 | return context.json({ response: 'No User Found' }, 404) 52 | } 53 | 54 | const efp: IEFPIndexerService = services.efp(env(context)) 55 | const followers: FollowerResponse[] = await efp.getUserFollowersByListTagSort( 56 | token_id, 57 | limit, 58 | offset, 59 | tagsToSearch, 60 | direction 61 | ) 62 | 63 | let response: ENSFollowerResponse[] = followers 64 | 65 | if (include?.includes('ens')) { 66 | const ensService = services.ens(env(context)) 67 | const ensProfilesForFollowers: ENSProfileResponse[] = await ensService.batchGetENSProfiles( 68 | followers.map(follower => follower.address) 69 | ) 70 | 71 | response = followers.map((follower, index) => { 72 | const ens: ENSProfileResponse = ensProfilesForFollowers[index] as ENSProfileResponse 73 | const ensFollowerResponse: ENSFollowerResponse = { 74 | ...follower, 75 | ens 76 | } 77 | return ensFollowerResponse 78 | }) 79 | } 80 | const packagedResponse = { followers: response } 81 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 82 | 83 | return context.json(packagedResponse, 200) 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/following/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { FollowingResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { ENSProfile } from '#/service/ens-metadata/types' 8 | import type { Address, Environment } from '#/types' 9 | import { type PrettyTaggedListRecord, hexlify, prettifyListRecord } from '#/types/list-record' 10 | import { textOrEmojiPattern } from '#/utilities' 11 | 12 | export type ENSFollowingResponse = PrettyTaggedListRecord & { 13 | ens?: ENSProfileResponse 14 | } 15 | 16 | /** 17 | * Enhanced to add ENS support 18 | */ 19 | export function following(lists: Hono<{ Bindings: Environment }>, services: Services) { 20 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 21 | lists.get('/:token_id/following', includeValidator, async context => { 22 | const { token_id } = context.req.param() 23 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 24 | return context.json({ response: 'Invalid list id' }, 400) 25 | } 26 | 27 | let { offset, limit, cache } = context.req.valid('query') 28 | if (!limit || Number.isNaN(limit)) limit = '10' 29 | if (!offset || Number.isNaN(offset)) offset = '0' 30 | 31 | let direction = 'latest' 32 | if (context.req.query('sort')?.toLowerCase() === 'followers') { 33 | direction = 'followers' 34 | } else if (context.req.query('sort')?.toLowerCase() === 'earliest') { 35 | direction = 'earliest' 36 | } 37 | 38 | const tagsQuery = context.req.query('tags') 39 | let tagsToSearch: string[] = [] 40 | if (tagsQuery) { 41 | const tagsArray = tagsQuery.split(',') 42 | tagsToSearch = tagsArray.filter((tag: any) => tag.match(textOrEmojiPattern)) 43 | } 44 | 45 | const cacheService = services.cache(env(context)) 46 | const cacheTarget = `lists/${token_id}/following?limit=${limit}&offset=${offset}&sort=${direction}&tags=${tagsToSearch.join(',')}` 47 | if (cache !== 'fresh') { 48 | const cacheHit = await cacheService.get(cacheTarget) 49 | if (cacheHit) { 50 | return context.json({ ...cacheHit }, 200) 51 | } 52 | } 53 | const listUser: Address | undefined = await services.efp(env(context)).getAddressByList(token_id) 54 | 55 | if (!listUser) { 56 | return context.json({ response: 'No User Found' }, 404) 57 | } 58 | 59 | const efp: IEFPIndexerService = services.efp(env(context)) 60 | const followingListRecords: FollowingResponse[] = await efp.getUserFollowingByListTagSort( 61 | token_id, 62 | limit as string, 63 | offset as string, 64 | tagsToSearch, 65 | direction 66 | ) 67 | 68 | let response: ENSFollowingResponse[] 69 | 70 | // Check if 'ens' information should be included 71 | if (context.req.query('include')?.includes('ens')) { 72 | const ensService = services.ens(env(context)) 73 | // Filter for address records 74 | const addressRecords = followingListRecords.filter(record => record.recordType === 1) 75 | 76 | // Fetch ENS profiles in batch 77 | const addresses: Address[] = addressRecords.map(record => hexlify(record.data)) 78 | const ensProfiles: ENSProfile[] = [] 79 | for (const address of addresses) { 80 | const profile = await ensService.getENSProfile(address) 81 | ensProfiles.push(profile) 82 | } 83 | // Collect ENS profiles into a lookup map by address 84 | const ensMap: Map = new Map( 85 | addresses.map((address, index) => { 86 | if (!ensProfiles[index]?.name) { 87 | return [address, { name: '', address: address, avatar: null } as unknown as ENSProfileResponse] 88 | } 89 | return [address, ensProfiles[index] as ENSProfileResponse] 90 | }) 91 | ) 92 | 93 | // Aggregate ENS profiles back into the full list 94 | response = followingListRecords.map(record => { 95 | return record.recordType !== 1 96 | ? prettifyListRecord(record) 97 | : { ...prettifyListRecord(record), ens: ensMap.get(hexlify(record.data)) as ENSProfileResponse } 98 | }) 99 | } else { 100 | // If ENS is not included, just map the following list records to the pretty format 101 | response = followingListRecords.map(prettifyListRecord) 102 | } 103 | const packagedResponse = { following: response } 104 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 105 | 106 | return context.json(packagedResponse, 200) 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | 3 | import type { Services } from '#/service' 4 | import type { Environment } from '#/types' 5 | import { account } from './account' 6 | import { allFollowers } from './allFollowers' 7 | import { allFollowing } from './allFollowing' 8 | import { allFollowingAddresses } from './allFollowingAddresses' 9 | import { buttonState } from './buttonState' 10 | import { details } from './details' 11 | import { followerState } from './followerState' 12 | import { followers } from './followers' 13 | import { following } from './following' 14 | import { latestFollowers } from './latestFollowers' 15 | import { poap } from './poap' 16 | import { recommended } from './recommended' 17 | import { records } from './records' 18 | import { searchFollowers } from './searchFollowers' 19 | import { searchFollowing } from './searchFollowing' 20 | import { stats } from './stats' 21 | import { taggedAs } from './taggedAs' 22 | import { tags } from './tags' 23 | 24 | export function lists(services: Services): Hono<{ Bindings: Environment }> { 25 | const lists = new Hono<{ Bindings: Environment }>() 26 | account(lists, services) 27 | allFollowers(lists, services) 28 | allFollowing(lists, services) 29 | allFollowingAddresses(lists, services) 30 | buttonState(lists, services) 31 | details(lists, services) 32 | followers(lists, services) 33 | followerState(lists, services) 34 | following(lists, services) 35 | latestFollowers(lists, services) 36 | poap(lists, services) 37 | recommended(lists, services) 38 | records(lists, services) 39 | searchFollowers(lists, services) 40 | searchFollowing(lists, services) 41 | stats(lists, services) 42 | taggedAs(lists, services) 43 | tags(lists, services) 44 | 45 | return lists 46 | } 47 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/latestFollowers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { LatestFollowerResponse } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { Address, Environment } from '#/types' 8 | import { isAddress, textOrEmojiPattern } from '#/utilities' 9 | import { lists } from '..' 10 | 11 | export type ENSFollowerResponse = LatestFollowerResponse & { ens?: ENSProfileResponse } 12 | 13 | export function latestFollowers(lists: Hono<{ Bindings: Environment }>, services: Services) { 14 | lists.get('/:token_id/latestFollowers', includeValidator, async context => { 15 | const { token_id } = context.req.param() 16 | const { cache } = context.req.query() 17 | let { offset, limit } = context.req.valid('query') 18 | if (!limit) limit = '10' 19 | if (!offset) offset = '0' 20 | 21 | const listUser: Address | undefined = await services.efp(env(context)).getAddressByList(token_id) 22 | if (!listUser) { 23 | return context.json({ response: 'No User Found' }, 404) 24 | } 25 | 26 | const cacheService = services.cache(env(context)) 27 | const cacheTarget = `lists/${token_id}/latestFollowers?limit=${limit}&offset=${offset}` 28 | if (cache !== 'fresh') { 29 | const cacheHit = await cacheService.get(cacheTarget) 30 | if (cacheHit) { 31 | return context.json({ ...cacheHit }, 200) 32 | } 33 | } 34 | 35 | const followers: LatestFollowerResponse[] = await services 36 | .efp(env(context)) 37 | .getLatestFollowersByList(token_id, limit as string, offset as string) 38 | 39 | const packagedResponse = { followers: followers } 40 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 41 | 42 | return context.json(packagedResponse, 200) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/poap/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | 7 | export function poap(lists: Hono<{ Bindings: Environment }>, services: Services) { 8 | lists.get('/:token_id/badges', async context => { 9 | const { token_id } = context.req.param() 10 | const { cache } = context.req.query() 11 | 12 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 13 | return context.json({ response: 'Invalid list id' }, 400) 14 | } 15 | 16 | const cacheService = services.cache(env(context)) 17 | const cacheTarget = `lists/${token_id}/badges` 18 | if (cache !== 'fresh') { 19 | const cacheHit = await cacheService.get(cacheTarget) 20 | if (cacheHit) { 21 | return context.json({ ...cacheHit }, 200) 22 | } 23 | } 24 | 25 | const efp: IEFPIndexerService = services.efp(env(context)) 26 | const listUser: Address | undefined = await efp.getAddressByList(token_id) 27 | if (!listUser) { 28 | return context.json({ response: 'No User Found' }, 404) 29 | } 30 | 31 | const headers = { 32 | method: 'GET', 33 | headers: { 34 | 'X-API-Key': `${env(context).POAP_API_TOKEN}`, 35 | 'Content-Type': 'application/json' 36 | } 37 | } 38 | 39 | const collections = ['177709', '178064', '178065', '178066', '183182'] 40 | const data = await Promise.all( 41 | collections.map(async collection => { 42 | const response = await fetch(`https://api.poap.tech/actions/scan/${listUser}/${collection}`, headers) 43 | return response.json() 44 | }) 45 | ) 46 | const poaps = data.map((_collection, index) => { 47 | return { 48 | eventId: collections[index], 49 | participated: !!(data[index] as any).tokenId, 50 | collection: (data[index] as any).tokenId ? data[index] : null 51 | } 52 | }) 53 | 54 | const packagedResponse = { poaps } 55 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 56 | return context.json(packagedResponse, 200) 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/recommended/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { NETWORKED_WALLET } from '#/constant' 4 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 5 | import type { Services } from '#/service' 6 | import type { IEFPIndexerService, RecommendedDetailsRow, RecommendedRow } from '#/service/efp-indexer/service' 7 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 8 | import type { ENSProfile } from '#/service/ens-metadata/types' 9 | import type { Address, Environment } from '#/types' 10 | import { type PrettyTaggedListRecord, hexlify, prettifyListRecord } from '#/types/list-record' 11 | import { isAddress } from '#/utilities' 12 | 13 | export type ENSFollowingResponse = PrettyTaggedListRecord & { 14 | ens?: ENSProfileResponse 15 | } 16 | 17 | export function recommended(users: Hono<{ Bindings: Environment }>, services: Services) { 18 | users.get('/:token_id/recommended', includeValidator, async context => { 19 | const { token_id } = context.req.param() 20 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 21 | return context.json({ response: 'Invalid list id' }, 400) 22 | } 23 | let { offset, limit, cache } = context.req.valid('query') 24 | if (!limit) limit = '10' 25 | if (!offset) offset = '0' 26 | 27 | const cacheService = services.cache(env(context)) 28 | const cacheTarget = `lists/${token_id}/recommended?limit=${limit}&offset=${offset}` 29 | if (cache !== 'fresh') { 30 | const cacheHit = await cacheService.get(cacheTarget) 31 | if (cacheHit) { 32 | return context.json({ ...cacheHit }, 200) 33 | } 34 | } 35 | 36 | const seed = context.req.query('seed') ? (context.req.query('seed') as Address) : (NETWORKED_WALLET as Address) 37 | const efp: IEFPIndexerService = services.efp(env(context)) 38 | const recommendedAddresses: RecommendedRow[] = await efp.getRecommendedByList( 39 | token_id, 40 | seed, 41 | limit as string, 42 | offset as string 43 | ) 44 | 45 | const packagedResponse = { recommended: recommendedAddresses } 46 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 47 | return context.json(packagedResponse, 200) 48 | }) 49 | 50 | users.get('/:token_id/recommended/details', includeValidator, async context => { 51 | const { token_id } = context.req.param() 52 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 53 | return context.json({ response: 'Invalid list id' }, 400) 54 | } 55 | let { offset, limit, cache } = context.req.valid('query') 56 | if (!limit) limit = '10' 57 | if (!offset) offset = '0' 58 | 59 | const cacheService = services.cache(env(context)) 60 | const cacheTarget = `lists/${token_id}/recommended/details?limit=${limit}&offset=${offset}` 61 | if (cache !== 'fresh') { 62 | const cacheHit = await cacheService.get(cacheTarget) 63 | if (cacheHit) { 64 | return context.json({ ...cacheHit }, 200) 65 | } 66 | } 67 | 68 | const efp: IEFPIndexerService = services.efp(env(context)) 69 | const recommendedAddresses: RecommendedDetailsRow[] = await efp.getRecommendedStackByList( 70 | token_id, 71 | Number.parseInt(limit as string), 72 | Number.parseInt(offset as string) 73 | ) 74 | const formattedRecommendations = recommendedAddresses.map(rec => { 75 | return { 76 | address: rec.address, 77 | ens: { 78 | name: rec.name, 79 | avatar: rec.avatar, 80 | records: JSON.parse(rec?.records) as string 81 | }, 82 | stats: { 83 | followers_count: rec.followers, 84 | following_count: rec.following 85 | }, 86 | ranks: { 87 | mutuals_rank: rec.mutuals_rank, 88 | followers_rank: rec.followers_rank, 89 | following_rank: rec.following_rank, 90 | top8_rank: rec.top8_rank, 91 | blocks_rank: rec.blocks_rank 92 | } 93 | } 94 | }) 95 | 96 | const packagedResponse = { recommended: formattedRecommendations } 97 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 98 | return context.json(packagedResponse, 200) 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/records/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { validator } from 'hono/validator' 4 | import type { Services } from '#/service' 5 | import type { Environment } from '#/types' 6 | import type { ListRecord, TaggedListRecord } from '#/types/list-record' 7 | 8 | export function records(users: Hono<{ Bindings: Environment }>, services: Services) { 9 | users.get( 10 | '/:token_id/records', 11 | validator('query', value => { 12 | const { includeTags } = >value 13 | if (includeTags !== undefined && includeTags !== 'true' && includeTags !== 'false') { 14 | // Muted by user 15 | // biome-ignore lint/nursery/noSecrets: 16 | throw new Error('Accepted format: ?includeTags=true or ?includeTags=false') 17 | } 18 | return value 19 | }), 20 | async context => { 21 | const token_id = BigInt(context.req.param().token_id) 22 | const includeTags = context.req.query('includeTags') 23 | 24 | const records: ListRecord[] = 25 | includeTags === 'false' 26 | ? await services.efp(env(context)).getListRecords(token_id) 27 | : await services.efp(env(context)).getListRecordsWithTags(token_id) 28 | 29 | const formattedRecords = records.map((record: ListRecord) => ({ 30 | version: record.version, 31 | record_type: record.recordType === 1 ? 'address' : `${record.recordType}`, 32 | data: `0x${record.data.toString('hex')}`, 33 | ...(includeTags !== 'false' && { tags: (record as TaggedListRecord).tags }) 34 | })) 35 | 36 | return context.json({ records: formattedRecords }, 200) 37 | } 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/searchFollowers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { FollowerResponse } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { Address, Environment } from '#/types' 8 | import { isAddress, textOrEmojiPattern } from '#/utilities' 9 | 10 | export type ENSFollowerResponse = FollowerResponse & { ens?: ENSProfileResponse } 11 | 12 | export function searchFollowers(users: Hono<{ Bindings: Environment }>, services: Services) { 13 | users.get('/:token_id/searchFollowers', includeValidator, async context => { 14 | const { token_id } = context.req.param() 15 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 16 | return context.json({ response: 'Invalid list id' }, 400) 17 | } 18 | let { offset, limit } = context.req.valid('query') 19 | if (!limit) limit = '10' 20 | if (!offset) offset = '0' 21 | 22 | let term = context.req.query('term') 23 | 24 | if (!term?.match(textOrEmojiPattern)) { 25 | return context.json({ results: [] }, 200) 26 | } 27 | if (!isAddress(term as string)) { 28 | term = term?.toLowerCase() 29 | } 30 | const followers: ENSFollowerResponse[] = await services 31 | .efp(env(context)) 32 | .searchUserFollowersByList(token_id, limit as string, offset as string, term) 33 | const response: ENSFollowerResponse[] = followers 34 | 35 | return context.json({ followers: response }, 200) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/searchFollowing/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { ENSTaggedListRecord, FollowingResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { ENSProfile } from '#/service/ens-metadata/types' 8 | import type { Address, Environment } from '#/types' 9 | import { type PrettyTaggedListRecord, hexlify, prettifyListRecord } from '#/types/list-record' 10 | import { isAddress, textOrEmojiPattern } from '#/utilities' 11 | 12 | export type ENSFollowingResponse = PrettyTaggedListRecord & { 13 | ens?: ENSProfileResponse 14 | } 15 | 16 | export function searchFollowing(users: Hono<{ Bindings: Environment }>, services: Services) { 17 | users.get('/:token_id/searchFollowing', includeValidator, async context => { 18 | const { token_id } = context.req.param() 19 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 20 | return context.json({ response: 'Invalid list id' }, 400) 21 | } 22 | let { offset, limit } = context.req.valid('query') 23 | if (!limit) limit = '10' 24 | if (!offset) offset = '0' 25 | 26 | let term = context.req.query('term') 27 | 28 | if (!term?.match(textOrEmojiPattern)) { 29 | return context.json({ results: [] }, 200) 30 | } 31 | if (!isAddress(term as string)) { 32 | term = term?.toLowerCase() 33 | } 34 | const efp: IEFPIndexerService = services.efp(env(context)) 35 | const followingListRecords: ENSTaggedListRecord[] = await efp.searchUserFollowingByList( 36 | token_id, 37 | limit as string, 38 | offset as string, 39 | term 40 | ) 41 | 42 | const response = followingListRecords.map(record => { 43 | return { ...prettifyListRecord(record), ens: { name: record.ens?.name, avatar: record.ens?.avatar } } 44 | }) 45 | 46 | return context.json({ following: response }, 200) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/stats/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { ENSProfile } from '#/service/ens-metadata/types' 6 | import type { Address, Environment } from '#/types' 7 | 8 | export function stats(lists: Hono<{ Bindings: Environment }>, services: Services) { 9 | lists.get('/:token_id/stats', async context => { 10 | const { token_id } = context.req.param() 11 | const { cache } = context.req.query() 12 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 13 | return context.json({ response: 'Invalid list id' }, 400) 14 | } 15 | const cacheService = services.cache(env(context)) 16 | const cacheTarget = `lists/${token_id}/stats`.toLowerCase() 17 | if (cache !== 'fresh') { 18 | const cacheHit = await cacheService.get(cacheTarget) 19 | if (cacheHit) { 20 | return context.json({ ...cacheHit }, 200) 21 | } 22 | } 23 | const efp: IEFPIndexerService = services.efp(env(context)) 24 | 25 | const stats = { 26 | followers_count: await efp.getUserFollowersCountByList(token_id), 27 | following_count: await efp.getUserFollowingCountByList(token_id) 28 | } 29 | 30 | if (env(context).ALLOW_TTL_MOD === 'true') { 31 | await cacheService.put(cacheTarget, JSON.stringify(stats), 0) 32 | } else { 33 | await cacheService.put(cacheTarget, JSON.stringify(stats)) 34 | } 35 | return context.json(stats, 200) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/taggedAs/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService, TagResponse, TagsResponse } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | import { isAddress } from '#/utilities' 7 | 8 | export function taggedAs(lists: Hono<{ Bindings: Environment }>, services: Services) { 9 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 10 | lists.get('/:token_id/taggedAs', async context => { 11 | const { token_id } = context.req.param() 12 | const { cache } = context.req.query() 13 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 14 | return context.json({ response: 'Invalid list id' }, 400) 15 | } 16 | const cacheService = services.cache(env(context)) 17 | const cacheTarget = `lists/${token_id}/taggedAs` 18 | if (cache !== 'fresh') { 19 | const cacheHit = await cacheService.get(cacheTarget) 20 | if (cacheHit) { 21 | return context.json({ ...cacheHit }, 200) 22 | } 23 | } 24 | const efp: IEFPIndexerService = services.efp(env(context)) 25 | const address = await efp.getAddressByList(token_id) 26 | 27 | if (!(address && isAddress(address))) { 28 | return context.json({ response: 'Primary List Not Found' }, 404) 29 | } 30 | 31 | const tagsResponse: TagResponse[] = await efp.getListFollowerTags(token_id) 32 | 33 | const tags: string[] = [] 34 | const counts: any[] = [] 35 | for (const tagResponse of tagsResponse) { 36 | if (!tags.includes(tagResponse.tag)) { 37 | tags.push(tagResponse.tag) 38 | ;(counts as any)[tagResponse.tag] = 0 39 | } 40 | ;(counts as any)[tagResponse.tag]++ 41 | } 42 | const tagCounts = tags.map(tag => { 43 | return { tag: tag, count: (counts as any)[tag] } 44 | }) 45 | const packagedResponse = { token_id, tags, tagCounts, taggedAddresses: tagsResponse } 46 | 47 | if (env(context).ALLOW_TTL_MOD === 'true') { 48 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse), 0) 49 | } else { 50 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 51 | } 52 | return context.json(packagedResponse, 200) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /src/router/api/v1/lists/tags/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService, TagResponse, TagsResponse } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | 7 | const onlyLettersPattern = /^[A-Za-z]+$/ 8 | 9 | export function tags(lists: Hono<{ Bindings: Environment }>, services: Services) { 10 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 11 | lists.get('/:token_id/tags', async context => { 12 | const { token_id } = context.req.param() 13 | const { cache } = context.req.query() 14 | if (Number.isNaN(Number(token_id)) || Number(token_id) <= 0) { 15 | return context.json({ response: 'Invalid list id' }, 400) 16 | } 17 | const tagsQuery = context.req.query('include') 18 | let tagsToSearch: string[] = [] 19 | if (tagsQuery) { 20 | const tagsArray = tagsQuery.split(',') 21 | tagsToSearch = tagsArray.filter(tag => tag.match(onlyLettersPattern)) 22 | } 23 | 24 | const cacheService = services.cache(env(context)) 25 | const cacheTarget = `lists/${token_id}/tags` 26 | if (cache !== 'fresh' && !tagsQuery) { 27 | const cacheHit = await cacheService.get(cacheTarget) 28 | if (cacheHit) { 29 | return context.json({ ...cacheHit }, 200) 30 | } 31 | } 32 | const efp: IEFPIndexerService = services.efp(env(context)) 33 | 34 | if (tagsToSearch.length > 0) { 35 | const tagsResponse: TagsResponse[] = await efp.getTaggedAddressesByTags(token_id, tagsToSearch) 36 | return context.json({ token_id, tagsToSearch, taggedAddresses: tagsResponse }, 200) 37 | } 38 | 39 | const tagsResponse: TagResponse[] = await efp.getTaggedAddressesByList(token_id) 40 | const tags: string[] = [] 41 | const counts: any[] = [] 42 | for (const tagResponse of tagsResponse) { 43 | if (!tags.includes(tagResponse.tag)) { 44 | tags.push(tagResponse.tag) 45 | ;(counts as any)[tagResponse.tag] = 0 46 | } 47 | ;(counts as any)[tagResponse.tag]++ 48 | } 49 | const tagCounts = tags.map(tag => { 50 | return { tag: tag, count: (counts as any)[tag] } 51 | }) 52 | const packagedResponse = { token_id, tags, tagCounts, taggedAddresses: tagsResponse } 53 | if (env(context).ALLOW_TTL_MOD === 'true') { 54 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse), 0) 55 | } else { 56 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 57 | } 58 | return context.json(packagedResponse, 200) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /src/router/api/v1/minters/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { DiscoverRow, IEFPIndexerService, MintersRow } from '#/service/efp-indexer/service' 5 | import type { Environment } from '#/types' 6 | import type { Address } from '#/types/index' 7 | 8 | export function minters(services: Services): Hono<{ Bindings: Environment }> { 9 | const minters = new Hono<{ Bindings: Environment }>() 10 | 11 | minters.get('/', async context => { 12 | let { cache, limit, offset } = context.req.query() 13 | 14 | if (!limit) limit = '10' 15 | if (!offset) offset = '0' 16 | const cacheService = services.cache(env(context)) 17 | const cacheTarget = `minters?limit=${limit}&offset=${offset}` 18 | if (cache !== 'fresh') { 19 | const cacheHit = await cacheService.get(cacheTarget) 20 | if (cacheHit) { 21 | return context.json({ ...cacheHit }, 200) 22 | } 23 | } 24 | 25 | const efp: IEFPIndexerService = services.efp(env(context)) 26 | const minters: MintersRow[] = await efp.getUniqueMinters(Number.parseInt(limit), Number.parseInt(offset)) 27 | await cacheService.put(cacheTarget, JSON.stringify({ minters })) 28 | return context.json({ minters }, 200) 29 | }) 30 | return minters 31 | } 32 | -------------------------------------------------------------------------------- /src/router/api/v1/serviceHealth/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { Environment } from '#/types' 5 | 6 | export function serviceHealth(services: Services): Hono<{ Bindings: Environment }> { 7 | const serviceHealth = new Hono<{ Bindings: Environment }>() 8 | 9 | serviceHealth.get('/', async context => { 10 | try { 11 | const cacheService = services.cache(env(context)) 12 | const cacheTarget = `serviceHealth` 13 | await cacheService.put(cacheTarget, JSON.stringify('ok')) 14 | const cacheHit = await cacheService.get(cacheTarget) 15 | if (!cacheHit) { 16 | return context.json({ response: 'No Response from Cache Service' }, 500) 17 | } 18 | } catch (e) { 19 | console.log(e) 20 | return context.json({ response: 'No Response from Cache Service' }, 500) 21 | } 22 | 23 | try { 24 | const response = await fetch(`${env(context).ENS_API_URL}/u/efp.eth`) 25 | if (!response.ok) { 26 | return context.json({ response: 'No Response from ENS Service' }, 500) 27 | } 28 | } catch (e) { 29 | console.log(e) 30 | return context.json({ response: 'No Response from ENS Service' }, 500) 31 | } 32 | 33 | return context.text('ok') 34 | }) 35 | return serviceHealth 36 | } 37 | -------------------------------------------------------------------------------- /src/router/api/v1/stats/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { DiscoverRow, IEFPIndexerService, StatsRow } from '#/service/efp-indexer/service' 5 | import type { Environment } from '#/types' 6 | import type { Address } from '#/types/index' 7 | 8 | export function stats(services: Services): Hono<{ Bindings: Environment }> { 9 | const stats = new Hono<{ Bindings: Environment }>() 10 | 11 | stats.get('/', async context => { 12 | const { cache } = context.req.query() 13 | const cacheService = services.cache(env(context)) 14 | const cacheTarget = `stats` 15 | 16 | if (cache !== 'fresh') { 17 | const cacheHit = await cacheService.get(cacheTarget) 18 | if (cacheHit) { 19 | return context.json({ ...cacheHit }, 200) 20 | } 21 | } 22 | 23 | const efp: IEFPIndexerService = services.efp(env(context)) 24 | const stats: StatsRow = await efp.getStats() 25 | await cacheService.put(cacheTarget, JSON.stringify({ stats })) 26 | return context.json({ stats }, 200) 27 | }) 28 | return stats 29 | } 30 | -------------------------------------------------------------------------------- /src/router/api/v1/token/image/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import type { Services } from '#/service' 3 | import type { Environment } from '#/types' 4 | import { formatSVG } from './tokenImage' 5 | 6 | export function image(token: Hono<{ Bindings: Environment }>): Hono<{ Bindings: Environment }> { 7 | token.get('/image/:token_id', context => { 8 | const { token_id } = context.req.param() 9 | if (Number.isNaN(Number(token_id))) { 10 | return context.json({ response: 'Invalid list id' }, 400) 11 | } 12 | const svg = formatSVG(token_id) 13 | context.header('Content-Type', 'image/svg+xml;charset=utf-8') 14 | return context.body(svg) 15 | }) 16 | return token 17 | } 18 | -------------------------------------------------------------------------------- /src/router/api/v1/token/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | 3 | import type { Services } from '#/service' 4 | import type { Environment } from '#/types' 5 | import { image } from './image' 6 | import { metadata } from './metadata' 7 | 8 | export function token(services: Services): Hono<{ Bindings: Environment }> { 9 | const token = new Hono<{ Bindings: Environment }>() 10 | 11 | image(token) 12 | metadata(token, services) 13 | 14 | token.get('/:token_id', context => 15 | context.json( 16 | { 17 | message: `Not a valid endpoint. Available subpaths: ${['/image', '/metadata'].join(', ')}` 18 | }, 19 | 501 20 | ) 21 | ) 22 | 23 | return token 24 | } 25 | -------------------------------------------------------------------------------- /src/router/api/v1/token/metadata/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService, RankRow } from '#/service/efp-indexer/service' 5 | import type { ENSProfile } from '#/service/ens-metadata/types' 6 | import type { Environment } from '#/types' 7 | import type { Address } from '#/types/index' 8 | 9 | export function metadata(token: Hono<{ Bindings: Environment }>, _services: Services): Hono<{ Bindings: Environment }> { 10 | token.get('/metadata/:token_id', context => { 11 | const { token_id } = context.req.param() 12 | if (Number.isNaN(Number(token_id))) { 13 | return context.json({ response: 'Invalid list id' }, 400) 14 | } 15 | // const ensService = services.ens(env(context)) 16 | // const efp: IEFPIndexerService = services.efp(env(context)) 17 | // const listUser: Address | undefined = await services.efp(env(context)).getAddressByList(token_id) 18 | // if (!listUser) { 19 | // return context.json({ response: 'Not Found' }, 404) 20 | // } 21 | // const { address, ...ens }: ENSProfile = await ensService.getENSProfile(listUser.toLowerCase()) 22 | // const primaryList = await efp.getUserPrimaryList(address) 23 | // const isPrimary = primaryList?.toString() === token_id 24 | 25 | // const ranksAndCounts = await efp.getUserRanksCounts(address) 26 | // const data = { 27 | // ens: { address, ...ens }, 28 | // // ranks: { 29 | // // mutuals_rank: ranksAndCounts.mutuals_rank, 30 | // // followers_rank: ranksAndCounts.followers_rank, 31 | // // following_rank: ranksAndCounts.following_rank, 32 | // // top8_rank: ranksAndCounts.top8_rank, 33 | // // blocks_rank: ranksAndCounts.blocks_rank 34 | // // }, 35 | // // followers_count: ranksAndCounts.followers, 36 | // // following_count: ranksAndCounts.following, 37 | // is_primary: isPrimary 38 | // } 39 | const url = context.req.url 40 | const rootUrl = url.split('/token/metadata')[0] 41 | const metadata = { 42 | name: `EFP List #${token_id}`, 43 | description: 'Ethereum Follow Protocol (EFP) is an onchain social graph protocol for Ethereum accounts.', 44 | image: `${rootUrl}/token/image/${token_id}`, 45 | external_url: `https://testing.ethfollow.xyz/${token_id}`, 46 | attributes: [ 47 | // { 48 | // trait_type: 'User', 49 | // value: data.ens.name 50 | // }, 51 | // { 52 | // trait_type: 'Primary List', 53 | // value: data.is_primary 54 | // } 55 | // { 56 | // trait_type: 'Followers', 57 | // value: data.followers_count 58 | // }, 59 | // { 60 | // trait_type: 'Following', 61 | // value: data.following_count 62 | // }, 63 | // { 64 | // trait_type: 'Mutuals Rank', 65 | // value: data.ranks.mutuals_rank 66 | // }, 67 | // { 68 | // trait_type: 'Followers Rank', 69 | // value: data.ranks.followers_rank 70 | // }, 71 | // { 72 | // trait_type: 'Following Rank', 73 | // value: data.ranks.following_rank 74 | // }, 75 | // { 76 | // trait_type: 'Blocked Rank', 77 | // value: data.ranks.blocks_rank ?? '0' 78 | // } 79 | ] 80 | } 81 | 82 | return context.json(metadata, 200) 83 | }) 84 | return token 85 | } 86 | -------------------------------------------------------------------------------- /src/router/api/v1/users/account/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { ENSProfile } from '#/service/ens-metadata/types' 6 | import type { Address, Environment } from '#/types' 7 | import { isAddress } from '#/utilities' 8 | 9 | export function account(lists: Hono<{ Bindings: Environment }>, services: Services) { 10 | lists.get('/:addressOrENS/account', async context => { 11 | const { addressOrENS } = context.req.param() 12 | const { cache } = context.req.query() 13 | 14 | const cacheService = services.cache(env(context)) 15 | const cacheTarget = `users/${addressOrENS}/account` 16 | if (cache !== 'fresh') { 17 | const cacheHit = await cacheService.get(cacheTarget) 18 | if (cacheHit) { 19 | return context.json({ ...cacheHit }, 200) 20 | } 21 | } 22 | 23 | const ensService = services.ens(env(context)) 24 | const { address, ...ens }: ENSProfile = await ensService.getENSProfile(addressOrENS) 25 | const response = { address } as Record 26 | 27 | const packagedResponse = { ...response, ens } 28 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 29 | return context.json(packagedResponse, 200) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/router/api/v1/users/allFollowers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { FollowerResponse } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { Address, Environment } from '#/types' 8 | import { isAddress, textOrEmojiPattern } from '#/utilities' 9 | 10 | export type ENSFollowerResponse = FollowerResponse & { ens?: ENSProfileResponse } 11 | 12 | export function allFollowers(users: Hono<{ Bindings: Environment }>, services: Services) { 13 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 14 | users.get('/:addressOrENS/allFollowers', includeValidator, async context => { 15 | const { addressOrENS } = context.req.param() 16 | const { cache } = context.req.query() 17 | let { include, offset, limit } = context.req.valid('query') 18 | if (!limit) limit = '10' 19 | if (!offset) offset = '0' 20 | 21 | let direction = 'latest' 22 | if (context.req.query('sort')?.toLowerCase() === 'followers') { 23 | direction = 'followers' 24 | } else if (context.req.query('sort')?.toLowerCase() === 'earliest') { 25 | direction = 'earliest' 26 | } 27 | 28 | const tagsQuery = context.req.query('tags') 29 | let tagsToSearch: string[] = [] 30 | if (tagsQuery) { 31 | const tagsArray = tagsQuery.split(',') 32 | tagsToSearch = tagsArray.filter((tag: any) => tag.match(textOrEmojiPattern)) 33 | } 34 | 35 | const cacheService = services.cache(env(context)) 36 | const cacheTarget = `users/${addressOrENS}/allFollowers?limit=${limit}&offset=${offset}&sort=${direction}&tags=${tagsToSearch.join(',')}` 37 | if (cache !== 'fresh') { 38 | const cacheHit = await cacheService.get(cacheTarget) 39 | if (cacheHit) { 40 | return context.json({ ...cacheHit }, 200) 41 | } 42 | } 43 | const ensService = services.ens(env(context)) 44 | const address: Address = await ensService.getAddress(addressOrENS) 45 | if (!isAddress(address)) { 46 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 47 | } 48 | 49 | const followers: FollowerResponse[] = await services 50 | .efp(env(context)) 51 | .getAllUserFollowersByAddressTagSort(address, limit, offset, tagsToSearch, direction) 52 | let response: ENSFollowerResponse[] = followers 53 | 54 | if (include?.includes('ens')) { 55 | const ensProfilesForFollowers: ENSProfileResponse[] = await ensService.batchGetENSProfiles( 56 | followers.map(follower => follower.address) 57 | ) 58 | 59 | response = followers.map((follower, index) => { 60 | const ens: ENSProfileResponse = ensProfilesForFollowers[index] as ENSProfileResponse 61 | const ensFollowerResponse: ENSFollowerResponse = { 62 | ...follower, 63 | ens 64 | } 65 | return ensFollowerResponse 66 | }) 67 | } 68 | const packagedResponse = { followers: response } 69 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 70 | 71 | return context.json(packagedResponse, 200) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /src/router/api/v1/users/allFollowing/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { FollowingResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { ENSProfile } from '#/service/ens-metadata/types' 8 | import type { Address, Environment } from '#/types' 9 | import { type PrettyTaggedListRecord, hexlify, prettifyListRecord } from '#/types/list-record' 10 | import { isAddress, textOrEmojiPattern } from '#/utilities' 11 | 12 | export type ENSFollowingResponse = PrettyTaggedListRecord & { 13 | ens?: ENSProfileResponse 14 | } 15 | 16 | export function allFollowing(users: Hono<{ Bindings: Environment }>, services: Services) { 17 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 18 | users.get('/:addressOrENS/allFollowing', includeValidator, async context => { 19 | const { addressOrENS } = context.req.param() 20 | let { offset, limit, cache } = context.req.valid('query') 21 | if (!limit) limit = '10' 22 | if (!offset) offset = '0' 23 | 24 | let direction = 'latest' 25 | if (context.req.query('sort')?.toLowerCase() === 'followers') { 26 | direction = 'followers' 27 | } else if (context.req.query('sort')?.toLowerCase() === 'earliest') { 28 | direction = 'earliest' 29 | } 30 | 31 | const tagsQuery = context.req.query('tags') 32 | let tagsToSearch: string[] = [] 33 | if (tagsQuery) { 34 | const tagsArray = tagsQuery.split(',') 35 | tagsToSearch = tagsArray.filter((tag: any) => tag.match(textOrEmojiPattern)) 36 | } 37 | 38 | const cacheService = services.cache(env(context)) 39 | const cacheTarget = `users/${addressOrENS}/allFollowing?limit=${limit}&offset=${offset}&sort=${direction}&tags=${tagsToSearch.join(',')}` 40 | if (cache !== 'fresh') { 41 | const cacheHit = await cacheService.get(cacheTarget) 42 | if (cacheHit) { 43 | return context.json({ ...cacheHit }, 200) 44 | } 45 | } 46 | 47 | const ensService = services.ens(env(context)) 48 | const address: Address = await ensService.getAddress(addressOrENS) 49 | if (!isAddress(address)) { 50 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 51 | } 52 | 53 | const efp: IEFPIndexerService = services.efp(env(context)) 54 | const followingListRecords: FollowingResponse[] = await efp.getAllUserFollowingByAddressTagSort( 55 | address, 56 | limit, 57 | offset, 58 | tagsToSearch, 59 | direction 60 | ) 61 | 62 | let response: ENSFollowingResponse[] 63 | // Check if 'ens' information should be included 64 | if (context.req.query('include')?.includes('ens')) { 65 | // Filter for address records 66 | const addressRecords = followingListRecords.filter(record => record.recordType === 1) 67 | 68 | // Fetch ENS profiles in batch 69 | const addresses: Address[] = addressRecords.map(record => hexlify(record.data)) 70 | const ensProfiles: ENSProfile[] = [] 71 | for (const address of addresses) { 72 | const profile = await ensService.getENSProfile(address) 73 | ensProfiles.push(profile) 74 | } 75 | // Collect ENS profiles into a lookup map by address 76 | const ensMap: Map = new Map( 77 | addresses.map((address, index) => { 78 | if (!ensProfiles[index]?.name) { 79 | return [address, { name: '', address: address, avatar: null } as unknown as ENSProfileResponse] 80 | } 81 | return [address, ensProfiles[index] as ENSProfileResponse] 82 | }) 83 | ) 84 | 85 | // Aggregate ENS profiles back into the full list 86 | response = followingListRecords.map(record => { 87 | return record.recordType !== 1 88 | ? prettifyListRecord(record) 89 | : { ...prettifyListRecord(record), ens: ensMap.get(hexlify(record.data)) as ENSProfileResponse } 90 | }) 91 | } else { 92 | // If ENS is not included, just map the following list records to the pretty format 93 | response = followingListRecords.map(prettifyListRecord) 94 | } 95 | const packagedResponse = { following: response } 96 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 97 | 98 | return context.json(packagedResponse, 200) 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /src/router/api/v1/users/commonFollowers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { NETWORKED_WALLET } from '#/constant' 4 | import type { Services } from '#/service' 5 | import type { CommonFollowers, IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { ENSProfile } from '#/service/ens-metadata/types' 8 | import type { Address, Environment } from '#/types' 9 | import { type PrettyTaggedListRecord, hexlify, prettifyListRecord } from '#/types/list-record' 10 | import { isAddress } from '#/utilities' 11 | 12 | export type ENSFollowingResponse = PrettyTaggedListRecord & { 13 | ens?: ENSProfileResponse 14 | } 15 | 16 | export function commonFollowers(users: Hono<{ Bindings: Environment }>, services: Services) { 17 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 18 | users.get('/:addressOrENS/commonFollowers', async context => { 19 | const { addressOrENS } = context.req.param() 20 | 21 | const ensService = services.ens(env(context)) 22 | const address: Address = await ensService.getAddress(addressOrENS) 23 | if (!isAddress(address)) { 24 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) // return error if address is not valid 25 | } 26 | let { leader, limit, offset } = context.req.query() 27 | if (!isAddress(leader as Address)) { 28 | leader = await ensService.getAddress(addressOrENS) 29 | } 30 | if (!(isAddress(leader as Address) && leader)) { 31 | return context.json({ response: 'Invalid query address' }, 404) 32 | } 33 | const efp: IEFPIndexerService = services.efp(env(context)) 34 | let common: CommonFollowers[] 35 | 36 | if (limit || offset) { 37 | if (!limit) limit = '10' 38 | if (!offset) offset = '0' 39 | common = await efp.getCommonFollowersPage( 40 | address.toLowerCase() as Address, 41 | leader.toLowerCase() as Address, 42 | Number(limit), 43 | Number(offset) 44 | ) 45 | } else { 46 | common = await efp.getCommonFollowers(address.toLowerCase() as Address, leader.toLowerCase() as Address) 47 | } 48 | 49 | return context.json({ results: common, length: common.length }, 200) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /src/router/api/v1/users/details/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { ENSProfile } from '#/service/ens-metadata/types' 6 | import type { Address, Environment } from '#/types' 7 | import { isAddress } from '#/utilities' 8 | 9 | export function details(users: Hono<{ Bindings: Environment }>, services: Services) { 10 | users.get('/:addressOrENS/details', async context => { 11 | const { addressOrENS } = context.req.param() 12 | const { cache } = context.req.query() 13 | 14 | const cacheService = services.cache(env(context)) 15 | const cacheTarget = `users/${addressOrENS}/details` 16 | if (cache !== 'fresh') { 17 | const cacheHit = await cacheService.get(cacheTarget) 18 | if (cacheHit) { 19 | return context.json({ ...cacheHit }, 200) 20 | } 21 | } 22 | const refreshENS = !!cache 23 | const ensService = services.ens(env(context)) 24 | const { address, ...ens }: ENSProfile = await ensService.getENSProfile(addressOrENS.toLowerCase(), refreshENS) 25 | if (!isAddress(address)) { 26 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 27 | } 28 | 29 | const normalizedAddress: Address = address.toLowerCase() as `0x${string}` 30 | const efp: IEFPIndexerService = services.efp(env(context)) 31 | const primaryList = await efp.getUserPrimaryList(normalizedAddress) 32 | 33 | const ranksAndCounts = await efp.getUserRanksCounts(address) 34 | 35 | const ranks = { 36 | mutuals_rank: ranksAndCounts.mutuals_rank, 37 | followers_rank: ranksAndCounts.followers_rank, 38 | following_rank: ranksAndCounts.following_rank, 39 | top8_rank: ranksAndCounts.top8_rank, 40 | blocks_rank: ranksAndCounts.blocks_rank 41 | } 42 | const response = { address } as Record 43 | const packagedResponse = { ...response, ens, ranks, primary_list: primaryList?.toString() ?? null } 44 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 45 | return context.json(packagedResponse, 200) 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /src/router/api/v1/users/ens/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | 4 | import type { Services } from '#/service' 5 | import type { ENSProfile } from '#/service/ens-metadata/types' 6 | import type { Environment } from '#/types' 7 | 8 | export function ens(users: Hono<{ Bindings: Environment }>, services: Services) { 9 | users.get('/:addressOrENS/ens', async context => { 10 | const { addressOrENS } = context.req.param() 11 | 12 | const ensProfile: ENSProfile = await services.ens(env(context)).getENSProfile(addressOrENS) 13 | return context.json({ ens: ensProfile }, 200) 14 | }) 15 | 16 | users.get('/:addressOrENS/ens/avatar', async context => { 17 | const { addressOrENS } = context.req.param() 18 | 19 | const imageUrl = await services.ens(env(context)).getENSAvatar(addressOrENS) 20 | return context.redirect(imageUrl, 302) 21 | }) 22 | 23 | users.post('/ens/avatar/batch', async context => { 24 | const ids = await context.req.json() 25 | if (!Array.isArray(ids)) { 26 | return context.json({ message: 'Expected an array of ens names or addresses' }, 400) 27 | } 28 | 29 | const idsWithImages = await services.ens(env(context)).batchGetENSAvatars(ids) 30 | return context.json(idsWithImages, 200) 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/router/api/v1/users/followerState/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { FollowStateResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | import { isAddress } from '#/utilities' 7 | 8 | export function followerState(users: Hono<{ Bindings: Environment }>, services: Services) { 9 | users.get('/:addressOrENS/:addressOrENS2/followerState', async context => { 10 | const { addressOrENS, addressOrENS2 } = context.req.param() 11 | const { cache } = context.req.query() 12 | 13 | const ensService = services.ens(env(context)) 14 | const addressUser: Address = await ensService.getAddress(addressOrENS) 15 | if (!isAddress(addressUser)) { 16 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 17 | } 18 | const addressFollower: Address = await ensService.getAddress(addressOrENS2) 19 | if (!isAddress(addressFollower)) { 20 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 21 | } 22 | const cacheService = services.cache(env(context)) 23 | const cacheTarget = `users/${addressUser}/${addressFollower}/followerState`.toLowerCase() 24 | if (cache !== 'fresh') { 25 | const cacheHit = await cacheService.get(cacheTarget) 26 | if (cacheHit) { 27 | return context.json({ ...cacheHit }, 200) 28 | } 29 | } 30 | const efp: IEFPIndexerService = services.efp(env(context)) 31 | const state: FollowStateResponse = await efp.getUserFollowerState(addressUser, addressFollower) 32 | const packagedResponse = { addressUser, addressFollower, state } 33 | if (env(context).ALLOW_TTL_MOD === 'true') { 34 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse), 0) 35 | } else { 36 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 37 | } 38 | return context.json(packagedResponse, 200) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /src/router/api/v1/users/followers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { FollowerResponse } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { Address, Environment } from '#/types' 8 | import { isAddress, textOrEmojiPattern } from '#/utilities' 9 | 10 | export type ENSFollowerResponse = FollowerResponse & { ens?: ENSProfileResponse } 11 | 12 | export function followers(users: Hono<{ Bindings: Environment }>, services: Services) { 13 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 14 | users.get('/:addressOrENS/followers', includeValidator, async context => { 15 | const { addressOrENS } = context.req.param() 16 | const { cache } = context.req.query() 17 | let { include, offset, limit } = context.req.valid('query') 18 | if (!limit) limit = '10' 19 | if (!offset) offset = '0' 20 | 21 | let direction = 'latest' 22 | if (context.req.query('sort')?.toLowerCase() === 'followers') { 23 | direction = 'followers' 24 | } else if (context.req.query('sort')?.toLowerCase() === 'earliest') { 25 | direction = 'earliest' 26 | } 27 | 28 | const tagsQuery = context.req.query('tags') 29 | let tagsToSearch: string[] = [] 30 | if (tagsQuery) { 31 | const tagsArray = tagsQuery.split(',') 32 | tagsToSearch = tagsArray.filter((tag: any) => tag.match(textOrEmojiPattern)) 33 | } 34 | 35 | const cacheService = services.cache(env(context)) 36 | const cacheTarget = `users/${addressOrENS}/followers?limit=${limit}&offset=${offset}&sort=${direction}&tags=${tagsToSearch.join(',')}` 37 | if (cache !== 'fresh') { 38 | const cacheHit = await cacheService.get(cacheTarget) 39 | if (cacheHit) { 40 | return context.json({ ...cacheHit }, 200) 41 | } 42 | } 43 | const ensService = services.ens(env(context)) 44 | const address: Address = await ensService.getAddress(addressOrENS) 45 | if (!isAddress(address)) { 46 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 47 | } 48 | 49 | const followers: FollowerResponse[] = await services 50 | .efp(env(context)) 51 | .getUserFollowersByAddressTagSort(address, limit, offset, tagsToSearch, direction) 52 | let response: ENSFollowerResponse[] = followers 53 | 54 | if (include?.includes('ens')) { 55 | const ensProfilesForFollowers: ENSProfileResponse[] = await ensService.batchGetENSProfiles( 56 | followers.map(follower => follower.address) 57 | ) 58 | 59 | response = followers.map((follower, index) => { 60 | const ens: ENSProfileResponse = ensProfilesForFollowers[index] as ENSProfileResponse 61 | const ensFollowerResponse: ENSFollowerResponse = { 62 | ...follower, 63 | ens 64 | } 65 | return ensFollowerResponse 66 | }) 67 | } 68 | const packagedResponse = { followers: response } 69 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 70 | 71 | return context.json(packagedResponse, 200) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /src/router/api/v1/users/following/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { FollowingResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { ENSProfile } from '#/service/ens-metadata/types' 8 | import type { Address, Environment } from '#/types' 9 | import { type PrettyTaggedListRecord, hexlify, prettifyListRecord } from '#/types/list-record' 10 | import { isAddress, textOrEmojiPattern } from '#/utilities' 11 | 12 | export type ENSFollowingResponse = PrettyTaggedListRecord & { 13 | ens?: ENSProfileResponse 14 | } 15 | 16 | export function following(users: Hono<{ Bindings: Environment }>, services: Services) { 17 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 18 | users.get('/:addressOrENS/following', includeValidator, async context => { 19 | const { addressOrENS } = context.req.param() 20 | let { offset, limit, cache } = context.req.valid('query') 21 | if (!limit) limit = '10' 22 | if (!offset) offset = '0' 23 | 24 | let direction = 'latest' 25 | if (context.req.query('sort')?.toLowerCase() === 'followers') { 26 | direction = 'followers' 27 | } else if (context.req.query('sort')?.toLowerCase() === 'earliest') { 28 | direction = 'earliest' 29 | } 30 | 31 | const tagsQuery = context.req.query('tags') 32 | let tagsToSearch: string[] = [] 33 | if (tagsQuery) { 34 | const tagsArray = tagsQuery.split(',') 35 | tagsToSearch = tagsArray.filter((tag: any) => tag.match(textOrEmojiPattern)) 36 | } 37 | 38 | const cacheService = services.cache(env(context)) 39 | const cacheTarget = `users/${addressOrENS}/following?limit=${limit}&offset=${offset}&sort=${direction}&tags=${tagsToSearch.join(',')}` 40 | if (cache !== 'fresh') { 41 | const cacheHit = await cacheService.get(cacheTarget) 42 | if (cacheHit) { 43 | return context.json({ ...cacheHit }, 200) 44 | } 45 | } 46 | 47 | const ensService = services.ens(env(context)) 48 | const address: Address = await ensService.getAddress(addressOrENS) 49 | if (!isAddress(address)) { 50 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 51 | } 52 | 53 | const efp: IEFPIndexerService = services.efp(env(context)) 54 | const followingListRecords: FollowingResponse[] = await efp.getUserFollowingByAddressTagSort( 55 | address, 56 | limit, 57 | offset, 58 | tagsToSearch, 59 | direction 60 | ) 61 | 62 | let response: ENSFollowingResponse[] 63 | // Check if 'ens' information should be included 64 | if (context.req.query('include')?.includes('ens')) { 65 | // Filter for address records 66 | const addressRecords = followingListRecords.filter(record => record.recordType === 1) 67 | 68 | // Fetch ENS profiles in batch 69 | const addresses: Address[] = addressRecords.map(record => hexlify(record.data)) 70 | const ensProfiles: ENSProfile[] = [] 71 | for (const address of addresses) { 72 | const profile = await ensService.getENSProfile(address) 73 | ensProfiles.push(profile) 74 | } 75 | // Collect ENS profiles into a lookup map by address 76 | const ensMap: Map = new Map( 77 | addresses.map((address, index) => { 78 | if (!ensProfiles[index]?.name) { 79 | return [address, { name: '', address: address, avatar: null } as unknown as ENSProfileResponse] 80 | } 81 | return [address, ensProfiles[index] as ENSProfileResponse] 82 | }) 83 | ) 84 | 85 | // Aggregate ENS profiles back into the full list 86 | response = followingListRecords.map(record => { 87 | return record.recordType !== 1 88 | ? prettifyListRecord(record) 89 | : { ...prettifyListRecord(record), ens: ensMap.get(hexlify(record.data)) as ENSProfileResponse } 90 | }) 91 | } else { 92 | // If ENS is not included, just map the following list records to the pretty format 93 | response = followingListRecords.map(prettifyListRecord) 94 | } 95 | const packagedResponse = { following: response } 96 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 97 | 98 | return context.json(packagedResponse, 200) 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /src/router/api/v1/users/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | 3 | import type { Services } from '#/service' 4 | import type { Environment } from '#/types' 5 | import { account } from './account' 6 | import { allFollowers } from './allFollowers' 7 | import { allFollowing } from './allFollowing' 8 | import { commonFollowers } from './commonFollowers' 9 | import { details } from './details' 10 | import { ens } from './ens' 11 | import { followerState } from './followerState' 12 | import { followers } from './followers' 13 | import { following } from './following' 14 | import { latestFollowers } from './latestFollowers' 15 | import { listRecords } from './list-records' 16 | import { lists } from './lists' 17 | import { notifications } from './notifications' 18 | import { poap } from './poap' 19 | import { primaryList } from './primary-list' 20 | import { qr } from './qr' 21 | import { recommended } from './recommended' 22 | import { relationships } from './relationships' 23 | import { searchFollowers } from './searchFollowers' 24 | import { searchFollowing } from './searchFollowing' 25 | import { stats } from './stats' 26 | import { taggedAs } from './taggedAs' 27 | import { tags } from './tags' 28 | 29 | export function users(services: Services): Hono<{ Bindings: Environment }> { 30 | const users = new Hono<{ Bindings: Environment }>() 31 | 32 | // ENS profile metadata 33 | account(users, services) 34 | allFollowers(users, services) 35 | allFollowing(users, services) 36 | commonFollowers(users, services) 37 | details(users, services) 38 | ens(users, services) 39 | followers(users, services) 40 | followerState(users, services) 41 | following(users, services) 42 | latestFollowers(users, services) 43 | listRecords(users, services) 44 | lists(users, services) 45 | notifications(users, services) 46 | poap(users, services) 47 | primaryList(users, services) 48 | qr(users, services) 49 | recommended(users, services) 50 | relationships(users, services) 51 | searchFollowers(users, services) 52 | searchFollowing(users, services) 53 | stats(users, services) 54 | taggedAs(users, services) 55 | tags(users, services) 56 | 57 | users.get('/:addressOrENS', context => 58 | context.json( 59 | { 60 | message: `Not a valid endpoint. Available subpaths: ${[ 61 | '/account', 62 | '/allFollowers', 63 | '/commonFollowers', 64 | '/allFollowing', 65 | '/details', 66 | '/ens', 67 | '/followers', 68 | '/followerState', 69 | '/following', 70 | '/lists', 71 | '/poap', 72 | '/primary-list', 73 | '/profile', 74 | '/qr', 75 | '/recommended', 76 | '/relationships', 77 | '/searchFollowers', 78 | '/searchFollowing', 79 | '/stats', 80 | '/taggedAs', 81 | '/tags' 82 | ].join(', ')}` 83 | }, 84 | 501 85 | ) 86 | ) 87 | 88 | // Blocked by user 89 | // biome-ignore lint/suspicious/useAwait: 90 | users.get('/:addressOrENS/blocks', async context => { 91 | return context.text('Not implemented', 501) 92 | }) 93 | 94 | // Muted by user 95 | // biome-ignore lint/suspicious/useAwait: 96 | users.get('/:addressOrENS/mutes', async context => { 97 | return context.text('Not implemented', 501) 98 | }) 99 | 100 | // Mutuals with users 101 | // biome-ignore lint/suspicious/useAwait: 102 | users.get('/:addressOrENS/mutuals', async context => { 103 | return context.text('Not implemented', 501) 104 | }) 105 | 106 | return users 107 | } 108 | -------------------------------------------------------------------------------- /src/router/api/v1/users/latestFollowers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { LatestFollowerResponse } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { Address, Environment } from '#/types' 8 | import { isAddress, textOrEmojiPattern } from '#/utilities' 9 | 10 | export type ENSFollowerResponse = LatestFollowerResponse & { ens?: ENSProfileResponse } 11 | 12 | export function latestFollowers(users: Hono<{ Bindings: Environment }>, services: Services) { 13 | users.get('/:addressOrENS/latestFollowers', includeValidator, async context => { 14 | const { addressOrENS } = context.req.param() 15 | const { cache } = context.req.query() 16 | let { offset, limit } = context.req.valid('query') 17 | if (!limit) limit = '10' 18 | if (!offset) offset = '0' 19 | 20 | const cacheService = services.cache(env(context)) 21 | const cacheTarget = `users/${addressOrENS}/latestFollowers?limit=${limit}&offset=${offset}` 22 | if (cache !== 'fresh') { 23 | const cacheHit = await cacheService.get(cacheTarget) 24 | if (cacheHit) { 25 | return context.json({ ...cacheHit }, 200) 26 | } 27 | } 28 | const ensService = services.ens(env(context)) 29 | const address: Address = await ensService.getAddress(addressOrENS) 30 | if (!isAddress(address)) { 31 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 32 | } 33 | 34 | const followers: LatestFollowerResponse[] = await services 35 | .efp(env(context)) 36 | .getLatestFollowersByAddress(address, limit as string, offset as string) 37 | 38 | const packagedResponse = { followers: followers } 39 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 40 | 41 | return context.json(packagedResponse, 200) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /src/router/api/v1/users/list-records/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | import type { TaggedListRecord } from '#/types/list-record' 7 | import { isAddress } from '#/utilities' 8 | 9 | export function listRecords(users: Hono<{ Bindings: Environment }>, services: Services) { 10 | users.get('/:addressOrENS/list-records', async context => { 11 | const { addressOrENS } = context.req.param() 12 | 13 | const address: Address = await services.ens(env(context)).getAddress(addressOrENS) 14 | if (!isAddress(address)) { 15 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 16 | } 17 | const efp: IEFPIndexerService = services.efp(env(context)) 18 | const listRecords: TaggedListRecord[] = await efp.getUserListRecords(address) 19 | return context.json( 20 | { 21 | records: listRecords.map(({ version, recordType, data, tags }) => ({ 22 | version, 23 | record_type: recordType === 1 ? 'address' : `${recordType}`, 24 | data: `0x${Buffer.from(data).toString('hex')}` as `0x${string}`, 25 | tags 26 | })) 27 | }, 28 | 200 29 | ) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/router/api/v1/users/lists/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | import type { TaggedListRecord } from '#/types/list-record' 7 | import { isAddress } from '#/utilities' 8 | 9 | export function lists(users: Hono<{ Bindings: Environment }>, services: Services) { 10 | users.get('/:addressOrENS/lists', async context => { 11 | const { addressOrENS } = context.req.param() 12 | const { cache } = context.req.query() 13 | 14 | const cacheService = services.cache(env(context)) 15 | const cacheTarget = `users/${addressOrENS}/lists` 16 | if (cache !== 'fresh') { 17 | const cacheHit = await cacheService.get(cacheTarget) 18 | if (cacheHit) { 19 | return context.json({ ...cacheHit }, 200) 20 | } 21 | } 22 | 23 | let address: Address 24 | if (isAddress(addressOrENS)) { 25 | address = addressOrENS.toLowerCase() as Address 26 | } else { 27 | address = await services.ens(env(context)).getAddress(addressOrENS) 28 | if (!isAddress(address)) { 29 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 30 | } 31 | } 32 | const efp: IEFPIndexerService = services.efp(env(context)) 33 | const primaryList = await efp.getUserPrimaryList(address) 34 | const lists: number[] = await efp.getUserLists(address) 35 | 36 | const packagedResponse = { primary_list: primaryList?.toString() ?? null, lists } 37 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 38 | 39 | return context.json(packagedResponse, 200) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /src/router/api/v1/users/notifications/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { NotificationRow } from '#/service/efp-indexer/service' 6 | import type { Address, Environment } from '#/types' 7 | import { isAddress } from '#/utilities' 8 | 9 | export function notifications(users: Hono<{ Bindings: Environment }>, services: Services) { 10 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 11 | users.get('/:addressOrENS/notifications', includeValidator, async context => { 12 | const { addressOrENS } = context.req.param() 13 | const { cache } = context.req.query() 14 | let { offset, limit, opcode, start, interval, tag } = context.req.valid('query') 15 | if (!limit) limit = '10' 16 | if (!offset) offset = '0' 17 | if (!(opcode && [1, 2, 3, 4].includes(Number(opcode)))) opcode = '0' 18 | if (!start || start === '') start = Math.floor(Date.now() / 1000).toString() 19 | if (!tag || tag === '') tag = 'p_tag_empty' 20 | if (interval === 'hour') interval = '1:00:00' 21 | else if (interval === 'day') interval = '24:00:00' 22 | else if (interval === 'week') interval = '168:00:00' 23 | else if (interval === 'month') interval = '720:00:00' 24 | else if (interval === 'all') interval = '999:00:00' 25 | else interval = '168:00:00' 26 | 27 | const cacheService = services.cache(env(context)) 28 | const cacheTarget = `users/${addressOrENS}/notifications?opcode=${opcode}&start=${start}&interval=${interval}&tag=${tag}&limit=${limit}&offset=${offset}` 29 | 30 | if (cache !== 'fresh') { 31 | const cacheHit = await cacheService.get(cacheTarget) 32 | if (cacheHit) { 33 | return context.json({ ...cacheHit }, 200) 34 | } 35 | } 36 | 37 | const ensService = services.ens(env(context)) 38 | const address: Address = await ensService.getAddress(addressOrENS) 39 | if (!isAddress(address)) { 40 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 41 | } 42 | 43 | const notifications: NotificationRow[] = await services 44 | .efp(env(context)) 45 | .getNotificationsByAddress( 46 | address, 47 | opcode as string, 48 | BigInt(start as string), 49 | interval, 50 | tag as string, 51 | limit as string, 52 | offset as string 53 | ) 54 | 55 | const response = notifications.map(notification => { 56 | return { 57 | address: notification.address, 58 | name: notification.name, 59 | avatar: notification.avatar, 60 | token_id: notification.token_id, 61 | action: 62 | Number(notification.opcode) === 1 63 | ? 'follow' 64 | : Number(notification.opcode) === 2 65 | ? 'unfollow' 66 | : Number(notification.opcode) === 3 67 | ? 'tag' 68 | : Number(notification.opcode) === 4 69 | ? 'untag' 70 | : '', 71 | opcode: notification.opcode, 72 | op: notification.op, 73 | tag: notification.tag, 74 | updated_at: notification.updated_at 75 | } 76 | }) 77 | const summary = { 78 | interval: `${interval}(hrs)`, 79 | opcode: opcode === '0' ? 'all' : opcode, 80 | total: response.length, 81 | total_follows: response.filter(notification => Number(notification.opcode) === 1).length, 82 | total_unfollows: response.filter(notification => Number(notification.opcode) === 2).length, 83 | total_tags: response.filter(notification => Number(notification.opcode) === 3).length, 84 | total_untags: response.filter(notification => Number(notification.opcode) === 4).length 85 | } 86 | 87 | const packagedResponse = { summary: summary, notifications: response } 88 | 89 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 90 | 91 | return context.json(packagedResponse, 200) 92 | }) 93 | } 94 | -------------------------------------------------------------------------------- /src/router/api/v1/users/poap/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | import { isAddress } from '#/utilities' 7 | 8 | export function poap(users: Hono<{ Bindings: Environment }>, services: Services) { 9 | users.get('/:addressOrENS/poap', async context => { 10 | const { addressOrENS } = context.req.param() 11 | const address: Address = await services.ens(env(context)).getAddress(addressOrENS) 12 | if (!isAddress(address)) { 13 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 14 | } 15 | const efp: IEFPIndexerService = services.efp(env(context)) 16 | const link = await efp.claimPoapLink(address) 17 | 18 | return context.json({ link }, 200) 19 | }) 20 | 21 | users.get('/:addressOrENS/badges', async context => { 22 | const { addressOrENS } = context.req.param() 23 | const { cache } = context.req.query() 24 | 25 | const cacheService = services.cache(env(context)) 26 | const cacheTarget = `users/${addressOrENS}/badges` 27 | if (cache !== 'fresh') { 28 | const cacheHit = await cacheService.get(cacheTarget) 29 | if (cacheHit) { 30 | return context.json({ ...cacheHit }, 200) 31 | } 32 | } 33 | 34 | const address: Address = await services.ens(env(context)).getAddress(addressOrENS) 35 | if (!isAddress(address)) { 36 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 37 | } 38 | 39 | const headers = { 40 | method: 'GET', 41 | headers: { 42 | 'X-API-Key': `${env(context).POAP_API_TOKEN}`, 43 | 'Content-Type': 'application/json' 44 | } 45 | } 46 | 47 | const collections = ['177709', '178064', '178065', '178066', '183182'] 48 | const data = await Promise.all( 49 | collections.map(async collection => { 50 | const response = await fetch(`https://api.poap.tech/actions/scan/${address}/${collection}`, headers) 51 | return response.json() 52 | }) 53 | ) 54 | const poaps = data.map((_collection, index) => { 55 | return { 56 | eventId: collections[index], 57 | participated: !!(data[index] as any).tokenId, 58 | collection: (data[index] as any).tokenId ? data[index] : null 59 | } 60 | }) 61 | 62 | const packagedResponse = { poaps } 63 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 64 | return context.json(packagedResponse, 200) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /src/router/api/v1/users/primary-list/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { Environment } from '#/types' 5 | 6 | export function primaryList(users: Hono<{ Bindings: Environment }>, services: Services) { 7 | users.get('/:addressOrENS/primary-list', async context => { 8 | const { addressOrENS } = context.req.param() 9 | 10 | const address = await services.ens(env(context)).getAddress(addressOrENS) 11 | const primaryList: bigint | undefined = await services.efp(env(context)).getUserPrimaryList(address) 12 | return context.json( 13 | { 14 | primary_list: primaryList !== undefined ? primaryList.toString() : null 15 | }, 16 | 200 17 | ) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/router/api/v1/users/qr/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import qrcode from 'qr-image' 4 | 5 | import type { Services } from '#/service' 6 | import type { Address, Environment } from '#/types' 7 | import { isAddress } from '#/utilities' 8 | 9 | const efplogoSVG = ` 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ` 20 | 21 | const getGradientText = (nameOrAddress: string | Address) => 22 | `${ 23 | isAddress(nameOrAddress) 24 | ? `${nameOrAddress.slice(0, 6)}…${nameOrAddress.slice(38, 42)}` 25 | : nameOrAddress.length > 18 26 | ? `${nameOrAddress.slice(0, 18)}…` 27 | : nameOrAddress 28 | }` 29 | 30 | const getProfileImage = async (ensAvatar: string) => { 31 | // Fetch the image data 32 | const res = await fetch(ensAvatar) 33 | if (!res.ok) { 34 | return '' // Return an empty string if the image cannot be fetched 35 | } 36 | // const arrayBuffer = await res.arrayBuffer() 37 | // const base64String = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))) 38 | // // Determine the image MIME type (assuming JPEG or PNG) 39 | // const contentType = res.headers.get('Content-Type') || 'image/png' 40 | 41 | return `` 42 | } 43 | 44 | export function qr(users: Hono<{ Bindings: Environment }>, services: Services) { 45 | users.get('/:addressOrENS/qr', async context => { 46 | const { addressOrENS } = context.req.param() 47 | 48 | let address: Address 49 | let ensName: string | null = null 50 | let ensAvatar: string | undefined 51 | 52 | if (isAddress(addressOrENS)) { 53 | address = addressOrENS.toLowerCase() as Address 54 | const ensProfile = await services.ens(env(context)).getENSProfile(address) 55 | ensName = ensProfile.name 56 | ensAvatar = ensProfile.avatar 57 | } else { 58 | ensName = addressOrENS 59 | address = await services.ens(env(context)).getAddress(addressOrENS) 60 | ensAvatar = (await services.ens(env(context)).getENSProfile(address)).avatar 61 | if (!isAddress(address)) { 62 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 63 | } 64 | } 65 | 66 | const profileImageSVG = ensAvatar ? await getProfileImage(ensAvatar) : '' 67 | 68 | let image = qrcode.imageSync(`https://ethfollow.xyz/${address}`, { type: 'svg' }).toString('utf-8') 69 | image = image 70 | .replace( 71 | '', 72 | ` 73 | 74 | 75 | 76 | 77 | 78 | 82 | 83 | 92 | ` 93 | ) 94 | .replace(/', 98 | `${efplogoSVG}${profileImageSVG}${getGradientText(ensName || address)}` 99 | ) 100 | 101 | context.header('Content-Type', 'image/svg+xml;charset=utf-8') 102 | return context.body(svgWithLogo) 103 | }) 104 | } 105 | -------------------------------------------------------------------------------- /src/router/api/v1/users/recommended/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { NETWORKED_WALLET } from '#/constant' 4 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 5 | import type { Services } from '#/service' 6 | import type { IEFPIndexerService, RecommendedDetailsRow, RecommendedRow } from '#/service/efp-indexer/service' 7 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 8 | import type { ENSProfile } from '#/service/ens-metadata/types' 9 | import type { Address, Environment } from '#/types' 10 | import { type PrettyTaggedListRecord, hexlify, prettifyListRecord } from '#/types/list-record' 11 | import { isAddress } from '#/utilities' 12 | 13 | export type ENSFollowingResponse = PrettyTaggedListRecord & { 14 | ens?: ENSProfileResponse 15 | } 16 | 17 | export function recommended(users: Hono<{ Bindings: Environment }>, services: Services) { 18 | users.get('/:addressOrENS/recommended', includeValidator, async context => { 19 | const { addressOrENS } = context.req.param() 20 | let { offset, limit, cache } = context.req.valid('query') 21 | if (!limit) limit = '10' 22 | if (!offset) offset = '0' 23 | 24 | const cacheService = services.cache(env(context)) 25 | const cacheTarget = `users/${addressOrENS}/recommended?limit=${limit}&offset=${offset}` 26 | if (cache !== 'fresh') { 27 | const cacheHit = await cacheService.get(cacheTarget) 28 | if (cacheHit) { 29 | return context.json({ ...cacheHit }, 200) 30 | } 31 | } 32 | 33 | const ensService = services.ens(env(context)) 34 | const address: Address = await ensService.getAddress(addressOrENS) 35 | if (!isAddress(address)) { 36 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 37 | } 38 | 39 | const seed = context.req.query('seed') ? (context.req.query('seed') as Address) : (NETWORKED_WALLET as Address) 40 | const efp: IEFPIndexerService = services.efp(env(context)) 41 | const recommendedAddresses: RecommendedRow[] = await efp.getRecommendedByAddress( 42 | address, 43 | seed, 44 | limit as string, 45 | offset as string 46 | ) 47 | 48 | const packagedResponse = { recommended: recommendedAddresses } 49 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 50 | return context.json(packagedResponse, 200) 51 | }) 52 | 53 | users.get('/:addressOrENS/recommended/details', includeValidator, async context => { 54 | const { addressOrENS } = context.req.param() 55 | let { offset, limit, cache } = context.req.valid('query') 56 | if (!limit) limit = '10' 57 | if (!offset) offset = '0' 58 | 59 | const cacheService = services.cache(env(context)) 60 | const cacheTarget = `users/${addressOrENS}/recommended/details?limit=${limit}&offset=${offset}` 61 | if (cache !== 'fresh') { 62 | const cacheHit = await cacheService.get(cacheTarget) 63 | if (cacheHit) { 64 | return context.json({ ...cacheHit }, 200) 65 | } 66 | } 67 | 68 | const ensService = services.ens(env(context)) 69 | const address: Address = await ensService.getAddress(addressOrENS) 70 | if (!isAddress(address)) { 71 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 72 | } 73 | 74 | const efp: IEFPIndexerService = services.efp(env(context)) 75 | const recommendedAddresses: RecommendedDetailsRow[] = await efp.getRecommendedStackByAddress( 76 | address, 77 | Number.parseInt(limit as string), 78 | Number.parseInt(offset as string) 79 | ) 80 | const formattedRecommendations = recommendedAddresses.map(rec => { 81 | return { 82 | address: rec.address, 83 | ens: { 84 | name: rec.name, 85 | avatar: rec.avatar, 86 | records: JSON.parse(rec?.records) as string 87 | }, 88 | stats: { 89 | followers_count: rec.followers, 90 | following_count: rec.following 91 | }, 92 | ranks: { 93 | mutuals_rank: rec.mutuals_rank, 94 | followers_rank: rec.followers_rank, 95 | following_rank: rec.following_rank, 96 | top8_rank: rec.top8_rank, 97 | blocks_rank: rec.blocks_rank 98 | } 99 | } 100 | }) 101 | 102 | const packagedResponse = { recommended: formattedRecommendations } 103 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 104 | return context.json(packagedResponse, 200) 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /src/router/api/v1/users/relationships/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { validator } from 'hono/validator' 4 | import type { Services } from '#/service' 5 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { Address, Environment } from '#/types' 7 | import { isAddress } from '#/utilities' 8 | 9 | export function relationships(users: Hono<{ Bindings: Environment }>, services: Services) { 10 | users.get( 11 | '/:addressOrENS/relationships', 12 | validator('query', value => { 13 | const { tag, direction } = >value 14 | 15 | // Check if both tag and direction are present 16 | if (!(tag && direction)) { 17 | throw new Error('Both "tag" and "direction" query parameters are required') 18 | } 19 | 20 | // Validate and map shorthand values for 'direction' 21 | const validDirections = ['incoming', 'outgoing', 'in', 'out'] 22 | if (!validDirections.includes(direction)) { 23 | throw new Error('The "direction" parameter must be "incoming", "outgoing", "in", or "out"') 24 | } 25 | 26 | // Map shorthand values to full forms 27 | if (direction === 'in') value['direction'] = 'incoming' 28 | if (direction === 'out') value['direction'] = 'outgoing' 29 | 30 | return value 31 | }), 32 | async context => { 33 | const addressOrENS = context.req.param().addressOrENS 34 | let { tag, direction } = context.req.query() 35 | 36 | if (direction === 'in') direction = 'incoming' 37 | if (direction === 'out') direction = 'outgoing' 38 | 39 | const address: Address = await services.ens(env(context)).getAddress(addressOrENS) 40 | if (!isAddress(address)) { 41 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 42 | } 43 | const efp: IEFPIndexerService = services.efp(env(context)) 44 | let relationships: any[] = [] 45 | // an english description of the relationship 46 | let description = '' 47 | if (direction === 'incoming') { 48 | relationships = await efp.getIncomingRelationships(address, tag as string) 49 | description = `EFP Lists which include the address "${address}" with tag "${tag}"` 50 | } else if (direction === 'outgoing') { 51 | relationships = (await efp.getOutgoingRelationships(address, tag as string)).map(r => { 52 | return { 53 | version: r.version, 54 | recordType: r.recordType === 1 ? 'address' : `${r.recordType}`, 55 | data: `0x${r.data.toString('hex')}`, 56 | tags: r.tags 57 | } 58 | }) 59 | description = `EFP List Records tagged "${tag}" by "${address}" on primary list` 60 | } 61 | 62 | // Placeholder response until further implementation 63 | return context.json({ description, direction, tag, relationships }, 200) 64 | } 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/router/api/v1/users/searchFollowers/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { FollowerResponse } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { Address, Environment } from '#/types' 8 | import { isAddress, textOrEmojiPattern } from '#/utilities' 9 | 10 | export type ENSFollowerResponse = FollowerResponse & { ens?: ENSProfileResponse } 11 | 12 | export function searchFollowers(users: Hono<{ Bindings: Environment }>, services: Services) { 13 | users.get('/:addressOrENS/searchFollowers', includeValidator, async context => { 14 | const { addressOrENS } = context.req.param() 15 | let { offset, limit } = context.req.valid('query') 16 | if (!limit) limit = '10' 17 | if (!offset) offset = '0' 18 | const ensService = services.ens(env(context)) 19 | const address: Address = await ensService.getAddress(addressOrENS) 20 | if (!isAddress(address)) { 21 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 22 | } 23 | 24 | let term = context.req.query('term') 25 | 26 | if (!term?.match(textOrEmojiPattern)) { 27 | return context.json({ results: [] }, 200) 28 | } 29 | if (!isAddress(term as string)) { 30 | term = term?.toLowerCase() 31 | } 32 | const followers: ENSFollowerResponse[] = await services 33 | .efp(env(context)) 34 | .searchUserFollowersByAddress(address, limit as string, offset as string, term) 35 | const response: ENSFollowerResponse[] = followers 36 | 37 | return context.json({ followers: response }, 200) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/router/api/v1/users/searchFollowing/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import { includeValidator } from '#/router/api/v1/leaderboard/validators' 4 | import type { Services } from '#/service' 5 | import type { ENSTaggedListRecord, FollowingResponse, IEFPIndexerService } from '#/service/efp-indexer/service' 6 | import type { ENSProfileResponse } from '#/service/ens-metadata/service' 7 | import type { ENSProfile } from '#/service/ens-metadata/types' 8 | import type { Address, Environment } from '#/types' 9 | import { type PrettyTaggedListRecord, hexlify, prettifyListRecord } from '#/types/list-record' 10 | import { isAddress, textOrEmojiPattern } from '#/utilities' 11 | 12 | export type ENSFollowingResponse = PrettyTaggedListRecord & { 13 | ens?: ENSProfileResponse 14 | } 15 | 16 | export function searchFollowing(users: Hono<{ Bindings: Environment }>, services: Services) { 17 | users.get('/:addressOrENS/searchFollowing', includeValidator, async context => { 18 | const { addressOrENS } = context.req.param() 19 | let { offset, limit } = context.req.valid('query') 20 | if (!limit) limit = '10' 21 | if (!offset) offset = '0' 22 | const ensService = services.ens(env(context)) 23 | const address: Address = await ensService.getAddress(addressOrENS) 24 | if (!isAddress(address)) { 25 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 26 | } 27 | 28 | let term = context.req.query('term') 29 | 30 | if (!term?.match(textOrEmojiPattern)) { 31 | return context.json({ results: [] }, 200) 32 | } 33 | if (!isAddress(term as string)) { 34 | term = term?.toLowerCase() 35 | } 36 | const efp: IEFPIndexerService = services.efp(env(context)) 37 | const followingListRecords: ENSTaggedListRecord[] = await efp.searchUserFollowingByAddress( 38 | address, 39 | limit as string, 40 | offset as string, 41 | term 42 | ) 43 | 44 | const response = followingListRecords.map(record => { 45 | return { ...prettifyListRecord(record), ens: { name: record.ens?.name, avatar: record.ens?.avatar } } 46 | }) 47 | 48 | return context.json({ following: response }, 200) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /src/router/api/v1/users/stats/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 5 | import type { IENSMetadataService } from '#/service/ens-metadata/service' 6 | import type { Address, Environment } from '#/types' 7 | import { isAddress } from '#/utilities' 8 | 9 | export function stats(users: Hono<{ Bindings: Environment }>, services: Services) { 10 | users.get('/:addressOrENS/stats', async context => { 11 | const { addressOrENS } = context.req.param() 12 | const { live, cache } = context.req.query() 13 | 14 | let address: Address = addressOrENS.toLowerCase() as Address 15 | if (!isAddress(addressOrENS)) { 16 | const ens: IENSMetadataService = services.ens(env(context)) 17 | address = await ens.getAddress(addressOrENS) 18 | if (!isAddress(address)) { 19 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 20 | } 21 | } 22 | const cacheService = services.cache(env(context)) 23 | const cacheTarget = `users/${address}/stats`.toLowerCase() 24 | if (cache !== 'fresh' || live !== 'true') { 25 | const cacheHit = await cacheService.get(cacheTarget) 26 | if (cacheHit) { 27 | return context.json({ ...cacheHit }, 200) 28 | } 29 | } 30 | const efp: IEFPIndexerService = services.efp(env(context)) 31 | if (env(context).ALLOW_TTL_MOD === 'true') { 32 | const stats = { 33 | followers_count: await efp.getUserFollowersCount(address), 34 | following_count: await efp.getUserFollowingCount(address) 35 | } 36 | 37 | await cacheService.put(cacheTarget, JSON.stringify(stats), 0) 38 | return context.json(stats, 200) 39 | } 40 | const ranksAndCounts = await efp.getUserRanksCounts(address) 41 | const stats = { 42 | followers_count: ranksAndCounts.followers, 43 | following_count: ranksAndCounts.following 44 | } 45 | 46 | if (live === 'true') { 47 | stats.followers_count = await efp.getUserFollowersCount(address) 48 | stats.following_count = await efp.getUserFollowingCount(address) 49 | } 50 | 51 | await cacheService.put(cacheTarget, JSON.stringify(stats)) 52 | return context.json(stats, 200) 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /src/router/api/v1/users/taggedAs/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService, TagResponse } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | import { isAddress } from '#/utilities' 7 | 8 | export function taggedAs(users: Hono<{ Bindings: Environment }>, services: Services) { 9 | users.get('/:addressOrENS/taggedAs', async context => { 10 | const { addressOrENS } = context.req.param() 11 | const { cache } = context.req.query() 12 | 13 | const ensService = services.ens(env(context)) 14 | const address: Address = await ensService.getAddress(addressOrENS) 15 | if (!isAddress(address)) { 16 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 17 | } 18 | 19 | const cacheService = services.cache(env(context)) 20 | const cacheTarget = `users/${address}/taggedAs`.toLowerCase() 21 | if (cache !== 'fresh') { 22 | const cacheHit = await cacheService.get(cacheTarget) 23 | if (cacheHit) { 24 | return context.json({ ...cacheHit }, 200) 25 | } 26 | } 27 | 28 | const efp: IEFPIndexerService = services.efp(env(context)) 29 | const tagsResponse: TagResponse[] = await efp.getUserFollowerTags(address) 30 | 31 | const tags: string[] = [] 32 | const counts: any[] = [] 33 | for (const tagResponse of tagsResponse) { 34 | if (!tags.includes(tagResponse.tag)) { 35 | tags.push(tagResponse.tag) 36 | ;(counts as any)[tagResponse.tag] = 0 37 | } 38 | ;(counts as any)[tagResponse.tag]++ 39 | } 40 | const tagCounts = tags.map(tag => { 41 | return { tag: tag, count: (counts as any)[tag] } 42 | }) 43 | const packagedResponse = { address, tags, tagCounts, taggedAddresses: tagsResponse } 44 | if (env(context).ALLOW_TTL_MOD === 'true') { 45 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse), 0) 46 | } else { 47 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 48 | } 49 | return context.json(packagedResponse, 200) 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /src/router/api/v1/users/tags/index.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { env } from 'hono/adapter' 3 | import type { Services } from '#/service' 4 | import type { IEFPIndexerService, TagResponse } from '#/service/efp-indexer/service' 5 | import type { Address, Environment } from '#/types' 6 | import { isAddress } from '#/utilities' 7 | 8 | export function tags(users: Hono<{ Bindings: Environment }>, services: Services) { 9 | // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 10 | users.get('/:addressOrENS/tags', async context => { 11 | const { addressOrENS } = context.req.param() 12 | const { cache } = context.req.query() 13 | 14 | const ensService = services.ens(env(context)) 15 | const address: Address = await ensService.getAddress(addressOrENS) 16 | if (!isAddress(address)) { 17 | return context.json({ response: 'ENS name not valid or does not exist' }, 404) 18 | } 19 | 20 | const cacheService = services.cache(env(context)) 21 | const cacheTarget = `users/${address}/tags`.toLowerCase() 22 | if (cache !== 'fresh') { 23 | const cacheHit = await cacheService.get(cacheTarget) 24 | if (cacheHit) { 25 | return context.json({ ...cacheHit }, 200) 26 | } 27 | } 28 | 29 | const efp: IEFPIndexerService = services.efp(env(context)) 30 | const list_id = await efp.getUserPrimaryList(address) 31 | if (!list_id) { 32 | return context.json({ response: 'Primary List Not Found' }, 404) 33 | } 34 | const tagsResponse: TagResponse[] = await efp.getTaggedAddressesByList(list_id as unknown as string) 35 | 36 | const tags: string[] = [] 37 | const counts: any[] = [] 38 | for (const tagResponse of tagsResponse) { 39 | if (!tags.includes(tagResponse.tag)) { 40 | tags.push(tagResponse.tag) 41 | ;(counts as any)[tagResponse.tag] = 0 42 | } 43 | ;(counts as any)[tagResponse.tag]++ 44 | } 45 | const tagCounts = tags.map(tag => { 46 | return { tag: tag, count: (counts as any)[tag] } 47 | }) 48 | const packagedResponse = { address, tags, tagCounts, taggedAddresses: tagsResponse } 49 | if (env(context).ALLOW_TTL_MOD === 'true') { 50 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse), 0) 51 | } else { 52 | await cacheService.put(cacheTarget, JSON.stringify(packagedResponse)) 53 | } 54 | return context.json(packagedResponse, 200) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /src/router/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareHandler } from 'hono' 2 | 3 | import { apiLogger } from '#/logger' 4 | 5 | export const errorLogger: MiddlewareHandler = async (_c, next) => { 6 | try { 7 | await next() 8 | } catch (error) { 9 | apiLogger.error(`Error: ${JSON.stringify(error, undefined, 2)}`) 10 | throw error // Rethrow the error to be handled by subsequent middleware 11 | } 12 | } 13 | 14 | export const errorHandler: MiddlewareHandler = async (c, next) => { 15 | try { 16 | await next() 17 | } catch (_error) { 18 | return c.text('Internal server error', 500) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/service/cache/service.ts: -------------------------------------------------------------------------------- 1 | import type { KVNamespace } from '@cloudflare/workers-types' 2 | import { createClient } from 'redis' 3 | import type { RedisClientType } from 'redis' 4 | import type { Environment } from '#/types/index' 5 | 6 | export interface ICacheService { 7 | get(key: string): Promise<{} | null> 8 | put(key: string, value: string, ttl?: number): Promise 9 | } 10 | 11 | export class CacheService implements ICacheService { 12 | readonly #env: Environment 13 | readonly #cacheType: string 14 | #client: RedisClientType | KVNamespace | null = null 15 | #connecting = false 16 | 17 | // biome-ignore lint/correctness/noUndeclaredVariables: 18 | constructor(env: Env) { 19 | this.#env = env 20 | if (this.#env.EFP_DATA_CACHE === undefined) { 21 | this.#cacheType = 'redis' 22 | } else { 23 | this.#cacheType = 'kv' 24 | this.#client = this.#env.EFP_DATA_CACHE as KVNamespace 25 | } 26 | } 27 | 28 | async createRedisClient(): Promise { 29 | if (this.#connecting) { 30 | await new Promise(resolve => setTimeout(resolve, 100)) 31 | return this.#client as RedisClientType 32 | } 33 | 34 | this.#connecting = true 35 | 36 | const client: RedisClientType = createClient({ 37 | url: this.#env.REDIS_URL, 38 | socket: { 39 | connectTimeout: 10000 40 | } 41 | }) 42 | 43 | client.on('error', (err: Error) => { 44 | console.error(`Redis Error: ${err.message}`) 45 | client.quit() 46 | this.#client = null 47 | }) 48 | 49 | try { 50 | await client.connect() 51 | this.#client = client 52 | } catch (err) { 53 | console.error(`Failed to connect to Redis: ${err}`) 54 | this.#client = null 55 | } finally { 56 | this.#connecting = false 57 | } 58 | 59 | return client 60 | } 61 | 62 | async getRedisClient(): Promise { 63 | if (!this.#client) { 64 | return await this.createRedisClient() 65 | } 66 | return this.#client as RedisClientType 67 | } 68 | 69 | async closeClient(): Promise { 70 | if (this.#cacheType === 'redis' && this.#client) { 71 | await (this.#client as RedisClientType).quit() 72 | this.#client = null 73 | } 74 | } 75 | 76 | async get(key: string): Promise<{} | null> { 77 | if (this.#cacheType === 'redis') { 78 | const client = await this.getRedisClient() 79 | const result = await client.get(key) 80 | client.quit() 81 | this.#client = null 82 | return result ? (JSON.parse(result as string) as {}) : null 83 | } 84 | return this.#env.EFP_DATA_CACHE.get(key, 'json') 85 | } 86 | 87 | async put(key: string, value: string, ttl = this.#env.CACHE_TTL): Promise { 88 | if (this.#cacheType === 'redis') { 89 | const client = await this.getRedisClient() 90 | if (ttl === 0) { 91 | await client.set(key, value, {} as any) 92 | } else { 93 | await client.set(key, value, { EX: ttl } as any) 94 | } 95 | client.quit() 96 | this.#client = null 97 | } else if (ttl === 0) { 98 | await this.#env.EFP_DATA_CACHE.put(key, value, {}) 99 | } else { 100 | await this.#env.EFP_DATA_CACHE.put(key, value, { expirationTtl: ttl }) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/service/ens-metadata/types.ts: -------------------------------------------------------------------------------- 1 | import type { Address } from '#/types' 2 | 3 | export type ENSProfile = { 4 | name: string 5 | address: Address 6 | avatar?: string 7 | updated_at?: string 8 | // display: string 9 | records?: string 10 | contenthash?: string 11 | // chains: Record 12 | // fresh: number 13 | // resolver: Address 14 | // errors: Record 15 | } 16 | -------------------------------------------------------------------------------- /src/service/index.ts: -------------------------------------------------------------------------------- 1 | import type { ICacheService } from '#/service/cache/service' 2 | import type { IEFPIndexerService } from '#/service/efp-indexer/service' 3 | import type { IENSMetadataService } from '#/service/ens-metadata/service' 4 | import type { Environment } from '#/types' 5 | 6 | export interface Services { 7 | ens: (env: Environment) => IENSMetadataService 8 | efp: (env: Environment) => IEFPIndexerService 9 | cache: (env: Environment) => ICacheService 10 | } 11 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type { DB } from './generated' 2 | 3 | export type StringifiedBoolean = 'true' | 'false' 4 | 5 | export type MaybePromise = T | Promise | PromiseLike 6 | 7 | export type Address = `0x${string}` 8 | 9 | // biome-ignore lint/correctness/noUndeclaredVariables: 10 | export type Environment = Pretty 11 | 12 | export type NoRepetition = 13 | | ResultT 14 | | { 15 | [k in U]: NoRepetition, [k, ...ResultT]> 16 | }[U] 17 | 18 | /** 19 | * This type utility is used to unwrap complex types so you can hover over them in VSCode and see the actual type 20 | */ 21 | export type Pretty = { 22 | [K in keyof T]: T[K] 23 | } & {} 24 | -------------------------------------------------------------------------------- /src/types/list-location-type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * solidity shown below: 3 | * // @dev The version byte allows for: 4 | * // 1. Differentiating between record formats for upgradability. 5 | * // 2. Ensuring backward compatibility with older versions. 6 | * // 3. Identifying the record's schema or processing logic. 7 | * ```solidity 8 | * struct ListStorageLocation { 9 | * uint8 10 | * version 11 | * // @dev type of list location 12 | * uint8 13 | * locationType 14 | * // @dev data for the list location 15 | * bytes 16 | * data 17 | * } 18 | * ``` 19 | */ 20 | export type ListLocationType = { 21 | version: number 22 | locationType: number 23 | data: Buffer 24 | } 25 | 26 | export type EVMListLocationType = { 27 | version: number 28 | locationType: number 29 | chainId: number 30 | contractAddress: `0x${string}` 31 | slot: `0x${string}` 32 | } 33 | 34 | export function decode(listStorageLocation: `0x${string}`): ListLocationType { 35 | if (listStorageLocation.length !== 2 + (1 + 1 + 32 + 20 + 32) * 2) { 36 | throw new Error('invalid list location') 37 | } 38 | // read bytes 2-21 as address 39 | const asBytes: Buffer = Buffer.from(listStorageLocation.slice(2), 'hex') 40 | const version: number = Number(asBytes[0]) 41 | const locationType: number = Number(asBytes[1]) 42 | const data: Buffer = asBytes.subarray(2) 43 | return { version, locationType, data } 44 | } 45 | 46 | export function decodeListStorageLocation(listStorageLocation: `0x${string}`): EVMListLocationType { 47 | const { version, locationType, data } = decode(listStorageLocation) 48 | if (version !== 1) { 49 | throw new Error('invalid list location version') 50 | } 51 | if (locationType !== 1) { 52 | throw new Error('invalid list location type') 53 | } 54 | if (data.length !== 32 + 20 + 32) { 55 | throw new Error('invalid list location data') 56 | } 57 | const chainId: number = Number(data.subarray(0, 32).reduce((acc, cur) => acc * 256 + cur, 0)) 58 | const contractAddress: `0x${string}` = `0x${data.subarray(32, 32 + 20).toString('hex')}` 59 | const slot: `0x${string}` = `0x${data.subarray(32 + 20, 32 + 20 + 32).toString('hex')}` 60 | 61 | return { version, locationType, chainId, contractAddress, slot } 62 | } 63 | -------------------------------------------------------------------------------- /src/types/list-op.ts: -------------------------------------------------------------------------------- 1 | export type ListOp = { 2 | version: number 3 | opcode: number 4 | data: Buffer 5 | } 6 | -------------------------------------------------------------------------------- /src/types/list-record.ts: -------------------------------------------------------------------------------- 1 | export type ListRecord = { 2 | version: number 3 | recordType: number 4 | data: Buffer 5 | } 6 | 7 | export type TaggedListRecord = ListRecord & { 8 | tags: string[] 9 | address: Buffer 10 | } 11 | 12 | function toHexString(num: number): string { 13 | return num.toString(16).padStart(2, '0') 14 | } 15 | 16 | function uint8ArrayToHexString(arr: Uint8Array): string { 17 | return Array.from(arr, byte => toHexString(byte)).join('') 18 | } 19 | 20 | export function hashRecord(tokenId: bigint, listRecord: ListRecord): `0x${string}` { 21 | const versionHex = toHexString(listRecord.version) 22 | const typeHex = toHexString(listRecord.recordType) 23 | const dataHex = uint8ArrayToHexString(listRecord.data) 24 | 25 | return `0x${tokenId.toString()}-${versionHex}${typeHex}${dataHex}` 26 | } 27 | 28 | export type PrettyTaggedListRecord = { 29 | version: number 30 | record_type: string 31 | data: `0x${string}` 32 | address: `0x${string}` 33 | tags: string[] 34 | } 35 | 36 | export function hexlify(data: Buffer): `0x${string}` { 37 | return `0x${data.toString('hex')}` 38 | } 39 | 40 | export function prettifyListRecord(record: TaggedListRecord): PrettyTaggedListRecord { 41 | return { 42 | version: record.version, 43 | record_type: record.recordType === 1 ? 'address' : `${record.recordType}`, 44 | data: hexlify(record.data), 45 | address: hexlify(record.data), 46 | tags: record.tags 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/endpoints.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # 4 | # A dead simple script to test the endpoints of the API for 200 status code. 5 | # 6 | 7 | set -eou pipefail allexport 8 | 9 | # source .env and .dev.vars if they exist 10 | [ -f .env ] && source .env 11 | [ -f .dev.vars ] && source .dev.vars 12 | 13 | VERBOSE="${VERBOSE:-false}" 14 | IS_DEMO="${IS_DEMO:-false}" 15 | API_URL="${API_URL:-http://localhost:8787/api/v1}" 16 | 17 | echo "Running endpoints test against $API_URL..." 18 | echo "Demo mode: $IS_DEMO" 19 | echo "Verbose logging: $VERBOSE" 20 | echo 21 | 22 | TIMESTAMP=$(date +"%Y-%m-%d-%H-%M-%S") 23 | 24 | if ! curl --silent --fail http://localhost:8787 >/dev/null; then 25 | echo "http://localhost:8787 is not running. Exiting..." && exit 1 26 | fi 27 | 28 | paths=( 29 | '/users/dr3a.eth/ens' 30 | '/users/dr3a.eth/stats' 31 | '/users/dr3a.eth/primary-list' 32 | '/users/0xC480C3FD10d8965EB74B9B53ee65Bea24B2a6A73/primary-list' 33 | '/users/dr3a.eth/following' 34 | '/users/dr3a.eth/following/tagged/efp' 35 | '/users/dr3a.eth/following/tagged/ens' 36 | '/users/dr3a.eth/following/tagged/block' 37 | '/users/dr3a.eth/following/tagged/mute' 38 | '/users/dr3a.eth/following/tags' 39 | '/users/dr3a.eth/followers' 40 | '/users/0x86A41524CB61edd8B115A72Ad9735F8068996688/whoblocks' 41 | '/users/0x86A41524CB61edd8B115A72Ad9735F8068996688/whomutes' 42 | '/leaderboard/followers?limit=3' 43 | '/leaderboard/following?limit=3' 44 | '/lists/0/records?includeTags=false' 45 | '/lists/0/records?includeTags=true' 46 | ) 47 | 48 | function request() { 49 | curl --head \ 50 | --request 'GET' \ 51 | --silent \ 52 | --fail \ 53 | --write-out '%{http_code}%{errormsg}' \ 54 | --url "$API_URL$1" \ 55 | --output /dev/null \ 56 | --connect-timeout 3 \ 57 | --max-time 4 58 | } 59 | 60 | TEMP_FAILED_RESULTS_FILE=$(mktemp) 61 | echo "$TIMESTAMP $API_URL" >"$TEMP_FAILED_RESULTS_FILE" 62 | 63 | # for each path, make a request and save the result (save status code and error message if any) 64 | for path in "${paths[@]}"; do 65 | response=$(request "$path" || true) 66 | status_code=$(echo $response | cut -c1-3) 67 | error_message=$(echo $response | cut -c4-) 68 | if [[ "$VERBOSE" == "true" && "$status_code" == "200" ]]; then 69 | echo "✔︎ $status_code $path" 70 | fi 71 | if [ "$status_code" != "200" ]; then 72 | echo "$status_code $path $error_message" >>"$TEMP_FAILED_RESULTS_FILE" 73 | fi 74 | done 75 | 76 | echo "Results filepath: $(dirname "$TEMP_FAILED_RESULTS_FILE")/$(basename "$TEMP_FAILED_RESULTS_FILE")" 77 | # if there are any failed results, print them and exit with error 78 | if [ -s "$TEMP_FAILED_RESULTS_FILE" ]; then 79 | echo "Failed results:" 80 | cat "$TEMP_FAILED_RESULTS_FILE" 81 | exit 1 82 | fi 83 | 84 | rm "$TEMP_FAILED_RESULTS_FILE" 85 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | /* 3 | * This file contains tests for the EFP API endpoints. 4 | * It uses Vitest and Supertest to perform HTTP GET requests to each endpoint 5 | * and checks for a 200 OK response. Please ensure the EFP API is running and 6 | * accessible at the specified server URL before running these tests. 7 | */ 8 | import { afterAll, beforeAll, describe, expect, it } from 'vitest' 9 | 10 | let server: any 11 | 12 | beforeAll(() => { 13 | server = 'http://localhost:8787' 14 | }) 15 | 16 | afterAll(() => { 17 | server = null 18 | }) 19 | 20 | // biome-ignore lint/nursery/noSecrets: 21 | const endpoints = [ 22 | '/api/v1/discover', 23 | '/api/v1/leaderboard/ranked?sort=mutuals&direction=desc&cache=fresh', 24 | '/api/v1/leaderboard/search?term=eth', 25 | '/api/v1/lists/3/details?cache=fresh', 26 | '/api/v1/lists/4/allFollowers', 27 | '/api/v1/lists/4/allFollowing', 28 | '/api/v1/lists/4/allFollowingAddresses?cache=fresh', 29 | '/api/v1/lists/9/badges?cache=fresh', 30 | '/api/v1/lists/9/0x983110309620d911731ac0932219af06091b6744/buttonState?cache=fresh', 31 | '/api/v1/lists/3/details?cache=fresh', 32 | '/api/v1/lists/4/followers?cache=fresh', 33 | '/api/v1/lists/3/0xc983ebc9db969782d994627bdffec0ae6efee1b3/followerState?cache=fresh', 34 | '/api/v1/lists/9/following?cache=fresh', 35 | '/api/v1/lists/4/latestFollowers?cache=fresh', 36 | '/api/v1/lists/1/recommended', 37 | '/api/v1/lists/4/searchFollowers?term=crypt', 38 | '/api/v1/lists/9/searchFollowing?term=bran', 39 | '/api/v1/lists/3/stats?cache=fresh', 40 | '/api/v1/lists/41/taggedAs', 41 | '/api/v1/lists/3/tags', 42 | '/api/v1/stats?cache=fresh', 43 | '/api/v1/users/encrypteddegen.eth/account', 44 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/badges?cache=fresh', 45 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/allFollowers?cache=fresh', 46 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/allFollowing?cache=fresh', 47 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/commonFollowers?leader=0x0312567d78ff0c9ce0bd62a250df5c6474c71334', 48 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/details?cache=fresh', 49 | '/api/v1/users/0x983110309620d911731ac0932219af06091b6744/ens', 50 | '/api/v1/users/encrypteddegen.eth/followers?cache=fresh', 51 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/0x983110309620d911731ac0932219af06091b6744/followerState?cache=fresh', 52 | '/api/v1/users/encrypteddegen.eth/following?cache=fresh', 53 | '/api/v1/users/encrypteddegen.eth/latestFollowers?cache=fresh', 54 | '/api/v1/users/0xc983ebc9db969782d994627bdffec0ae6efee1b3/list-records', 55 | '/api/v1/users/0x983110309620d911731ac0932219af06091b6744/lists?cache=fresh', 56 | // biome-ignore lint/nursery/noSecrets: 57 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/notifications?cache=fresh', 58 | '/api/v1/users/0xc983ebc9db969782d994627bdffec0ae6efee1b3/primary-list', 59 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/recommended', 60 | // biome-ignore lint/nursery/noSecrets: 61 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/searchFollowers?term=brant', 62 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/searchFollowing?term=degen', 63 | '/api/v1/users/limes.eth/stats?cache=fresh&live=true', 64 | '/api/v1/users/0xc9c3a4337a1bba75d0860a1a81f7b990dc607334/taggedAs', 65 | '/api/v1/users/0x983110309620d911731ac0932219af06091b6744/tags' 66 | ] 67 | 68 | describe('EFP API Tests', () => { 69 | for (const endpoint of endpoints) { 70 | it(endpoint, async () => { 71 | const response = await request(server).get(endpoint) 72 | expect(response.status).toBe(200) 73 | }) 74 | } 75 | }) 76 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import * as vi from 'vitest' 2 | 3 | console.log(vi.getRunningMode()) 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "noEmit": true, 5 | "paths": { 6 | "#/*": ["./src/*"] 7 | }, 8 | "types": ["node", "bun", "@cloudflare/workers-types"], 9 | "lib": ["ESNext"], 10 | "module": "ESNext", 11 | "target": "ESNext", 12 | "moduleResolution": "Bundler", 13 | "useDefineForClassFields": true, 14 | "resolveJsonModule": true, 15 | "resolvePackageJsonExports": true, 16 | "resolvePackageJsonImports": true, 17 | "allowImportingTsExtensions": true, 18 | "noUncheckedIndexedAccess": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "sourceMap": false, 21 | "declarationMap": false, 22 | "noPropertyAccessFromIndexSignature": true, 23 | "exactOptionalPropertyTypes": true, 24 | "alwaysStrict": true, 25 | "strictNullChecks": true, 26 | "verbatimModuleSyntax": true, 27 | "strict": true, 28 | "skipLibCheck": true, 29 | "allowSyntheticDefaultImports": true, 30 | "forceConsistentCasingInFileNames": true, 31 | "allowJs": true, 32 | "checkJs": true 33 | }, 34 | "include": ["src", "scripts", "tests"], 35 | "files": ["environment.d.ts", "reset.d.ts", "vitest.config.ts"], 36 | "exclude": ["_"] 37 | } 38 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | resolve: { 5 | alias: { 6 | '#': './src' 7 | } 8 | }, 9 | test: { 10 | setupFiles: ['./tests/setup.ts'] 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | #: schema https://github.com/cloudflare/workers-sdk/files/12887590/wrangler.schema.json 2 | # https://developers.cloudflare.com/workers/wrangler/configuration 3 | 4 | # default stage is "development" 5 | 6 | # 7 | # start of globally inheritable configuration 8 | name = "indexer" 9 | main = "./src/index.ts" 10 | minify = true 11 | keep_vars = true 12 | compatibility_flags = ["nodejs_compat"] 13 | placement = { mode = "smart" } 14 | compatibility_date = "2023-10-30" 15 | # end of globally inheritable configuration 16 | # 17 | vars = { ENVIRONMENT = "development" } 18 | services = [{ binding = "ens", service = "ens" }] 19 | kv_namespaces = [ 20 | { binding = "EFP_DATA_CACHE", id = "5092581c2d524711a04560d335966a60", preview_id = "608971607bd2469e8972a0811f8de589" }, 21 | ] 22 | 23 | [env.development] 24 | name = "development" 25 | workers_dev = true 26 | vars = { ENVIRONMENT = "development", IS_DEMO = "false" } 27 | routes = [ 28 | { pattern = "development.api.ethfollow.xyz", custom_domain = true, zone_id = "0bb17a76c05f664a7c6cfd16721216cf" }, 29 | ] 30 | services = [{ binding = "ens", service = "ens" }] 31 | kv_namespaces = [ 32 | { binding = "EFP_DATA_CACHE", id = "c350ab2182ba476bb060fe9f5d0e026b" }, 33 | ] 34 | 35 | [env.production] 36 | name = "production" 37 | workers_dev = true 38 | vars = { ENVIRONMENT = "production", IS_DEMO = "false" } 39 | routes = [ 40 | { pattern = "api.ethfollow.xyz", custom_domain = true, zone_id = "0bb17a76c05f664a7c6cfd16721216cf" }, 41 | { pattern = "production.api.ethfollow.xyz", custom_domain = true, zone_id = "0bb17a76c05f664a7c6cfd16721216cf" }, 42 | ] 43 | services = [{ binding = "ens", service = "ens" }] 44 | kv_namespaces = [ 45 | { binding = "EFP_DEMO_KV", id = "c490de9b7d434fdb927933a86d1d2db4" }, 46 | { binding = "EFP_DATA_CACHE", id = "5092581c2d524711a04560d335966a60" }, 47 | ] 48 | --------------------------------------------------------------------------------