diff --git a/src/addressResolvers/ens.ts b/src/addressResolvers/ens.ts index a12b0aaa..a5d35924 100644 --- a/src/addressResolvers/ens.ts +++ b/src/addressResolvers/ens.ts @@ -4,13 +4,13 @@ import { capture } from '@snapshot-labs/snapshot-sentry'; import { ens_normalize } from '@adraffy/ens-normalize'; import { provider as getProvider, - graphQlCall, Address, Handle, isSilencedError, FetchError, isEvmAddress } from './utils'; +import { graphQlCall } from '../helpers/utils'; export const NAME = 'Ens'; const NETWORK = '1'; diff --git a/src/addressResolvers/index.ts b/src/addressResolvers/index.ts index e4f7c80c..fadefb7b 100644 --- a/src/addressResolvers/index.ts +++ b/src/addressResolvers/index.ts @@ -62,7 +62,7 @@ export async function lookupAddresses(addresses: Address[]): Promise; } export async function resolveNames(handles: Handle[]): Promise> { @@ -72,5 +72,5 @@ export async function resolveNames(handles: Handle[]): Promise; } diff --git a/src/addressResolvers/lens.ts b/src/addressResolvers/lens.ts index ff8cfd17..1576a235 100644 --- a/src/addressResolvers/lens.ts +++ b/src/addressResolvers/lens.ts @@ -1,5 +1,6 @@ import { capture } from '@snapshot-labs/snapshot-sentry'; -import { graphQlCall, Address, Handle, FetchError, isSilencedError, isEvmAddress } from './utils'; +import { Address, Handle, FetchError, isSilencedError, isEvmAddress } from './utils'; +import { graphQlCall } from '../helpers/utils'; export const NAME = 'Lens'; const API_URL = 'https://api-v2.lens.dev/graphql'; diff --git a/src/addressResolvers/utils.ts b/src/addressResolvers/utils.ts index a612509a..5b30fac1 100644 --- a/src/addressResolvers/utils.ts +++ b/src/addressResolvers/utils.ts @@ -1,4 +1,3 @@ -import axios from 'axios'; import snapshot from '@snapshot-labs/snapshot.js'; import { getAddress } from '@ethersproject/address'; @@ -25,20 +24,6 @@ export function withoutEmptyValues(obj: Record) { return Object.fromEntries(Object.entries(obj).filter(([, value]) => value)); } -export function graphQlCall(url, query: string) { - return axios({ - url: url, - method: 'post', - headers: { - 'Content-Type': 'application/json' - }, - timeout: 5e3, - data: { - query - } - }); -} - export function normalizeAddresses(addresses: Address[]): Address[] { return addresses .map(a => { @@ -69,8 +54,8 @@ export function isSilencedError(error: any): boolean { export function mapOriginalInput( input: string[], - results: Record -): Record { + results: Record +): Record { const inputLc = input.map(i => i?.toLowerCase()); const resultLc = Object.fromEntries( Object.entries(results).map(([key, value]) => [key.toLowerCase(), value]) diff --git a/src/api.ts b/src/api.ts index 69857dcd..bc4d34df 100644 --- a/src/api.ts +++ b/src/api.ts @@ -6,6 +6,7 @@ import resolvers from './resolvers'; import constants from './constants.json'; import { rpcError, rpcSuccess } from './helpers/utils'; import { lookupAddresses, resolveNames } from './addressResolvers'; +import following from './following'; const router = express.Router(); const TYPE_CONSTRAINTS = Object.keys(constants.resolvers).join('|'); @@ -15,11 +16,18 @@ router.post('/', async (req, res) => { if (!method) return rpcError(res, 400, 'missing method', id); try { let result: any = {}; - if (!Array.isArray(params)) return rpcError(res, 400, 'params must be an array of string', id); - if (method === 'lookup_addresses') result = await lookupAddresses(params); - else if (method === 'resolve_names') result = await resolveNames(params); - else return rpcError(res, 400, 'invalid method', id); + if (['lookup_addresses', 'resolve_names'].includes(method)) { + if (!Array.isArray(params)) + return rpcError(res, 400, 'params must be an array of string', id); + + if (method === 'lookup_addresses') result = await lookupAddresses(params); + else if (method === 'resolve_names') result = await resolveNames(params); + } else if (method === 'following') { + if (Array.isArray(params)) return rpcError(res, 400, 'params must be a string', id); + + result = await following(params); + } else return rpcError(res, 400, 'invalid method', id); if (result?.error) return rpcError(res, result.code || 500, result.error, id); return rpcSuccess(res, result, id); diff --git a/src/following/index.ts b/src/following/index.ts new file mode 100644 index 00000000..9a41f98b --- /dev/null +++ b/src/following/index.ts @@ -0,0 +1,23 @@ +import { Address, normalizeAddresses } from '../addressResolvers/utils'; +import * as lensFollowing from './lens'; + +const PROVIDERS = [lensFollowing]; + +export default async function following(address: Address): Promise { + const normalizedAddress = normalizeAddresses([address])[0]; + + if (!normalizedAddress) return []; + + // TODO: Add cache + const result = await Promise.all( + PROVIDERS.flatMap(provider => { + try { + return provider.following(normalizedAddress); + } catch (e) { + return []; + } + }) + ); + + return result.flat(); +} diff --git a/src/following/lens.ts b/src/following/lens.ts new file mode 100644 index 00000000..9c072d30 --- /dev/null +++ b/src/following/lens.ts @@ -0,0 +1,69 @@ +import { capture } from '@snapshot-labs/snapshot-sentry'; +import { Address, FetchError, isSilencedError } from '../addressResolvers/utils'; +import { graphQlCall } from '../helpers/utils'; + +export const NAME = 'Lens'; +const API_URL = 'https://api-v2.lens.dev/graphql'; + +async function getProfileIds(address: Address): Promise { + const { + data: { + data: { + profiles: { items } + } + } + } = await graphQlCall( + API_URL, + `query Profile { + profiles(request: { where: { ownedBy: "${address}" } }) { + items { + id + } + } + }` + ); + + return items.map(({ id }) => id); +} + +export async function following(targetAddress: string): Promise { + try { + const profileIds = await getProfileIds(targetAddress); + + if (profileIds.length === 0) return []; + + // TODO: Handle pagination, to fetch more than 50 followings per profile + const result = await Promise.all( + profileIds.flatMap(async profileId => { + const { + data: { + data: { + following: { items } + } + } + } = await graphQlCall( + API_URL, + `query Following { + following(request: { for: "${profileId}", limit: Fifty }) { + items { + handle { + ownedBy + } + } + } + }` + ); + + return items.map(({ handle: { ownedBy } }) => ownedBy) as string[]; + }) + ); + + return result.flat(); + } catch (e) { + if (!isSilencedError(e)) { + capture(e, { input: { addresses: targetAddress } }); + } + + throw new FetchError(); + } +} diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts index ae683bbf..86ffb4f7 100644 --- a/src/helpers/utils.ts +++ b/src/helpers/utils.ts @@ -1,3 +1,5 @@ +import axios from 'axios'; + export function rpcSuccess(res, result, id) { res.json({ jsonrpc: '2.0', @@ -17,3 +19,17 @@ export function rpcError(res, code, e, id) { id }); } + +export function graphQlCall(url: string, query: string) { + return axios({ + url: url, + method: 'post', + headers: { + 'Content-Type': 'application/json' + }, + timeout: 5e3, + data: { + query + } + }); +}