diff --git a/package.json b/package.json index eef7c6c6..5b0a4f0b 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "eslint": "^6.7.2", "express": "^4.17.1", "jsdom": "^19.0.0", - "node-fetch": "v2.7.0", + "node-fetch": "^2.7.0", "nodemon": "^2.0.7", "redis": "^4.6.10", "sharp": "^0.30.1", @@ -48,6 +48,7 @@ "@types/express": "^4.17.11", "@types/jest": "^28.1.0", "@types/node": "^14.14.21", + "@types/node-fetch": "^2.6.11", "@typescript-eslint/eslint-plugin": "^2.33.0", "@typescript-eslint/parser": "^2.33.0", "eslint-plugin-prettier": "^3.1.3", diff --git a/src/addressResolvers/farcaster.ts b/src/addressResolvers/farcaster.ts new file mode 100644 index 00000000..be9e2223 --- /dev/null +++ b/src/addressResolvers/farcaster.ts @@ -0,0 +1,144 @@ +import { capture } from '@snapshot-labs/snapshot-sentry'; +import { Address, Handle, FetchError, isSilencedError, isEvmAddress } from './utils'; +import { getAddress } from '@ethersproject/address'; +import fetch from 'node-fetch'; + +export const NAME = 'Farcaster'; +const FNAMES_API_URL = 'https://fnames.farcaster.xyz/transfers?name='; +const NEYNAR_API_URL = 'https://api.neynar.com/v2/farcaster/user/'; +const API_KEY = process.env.NEYNAR_API_KEY ?? ''; + +interface UserDetails { + username: Handle; + verified_addresses: { + eth_addresses: Address[]; + sol_addresses: string[]; + }; + pfp_url: string; +} + +interface ApiResponse { + [address: string]: UserDetails[]; +} + +interface UserResult { + username?: Handle; + eth_addresses?: Address[]; + sol_addresses?: string[]; + pfp_url?: string; +} + +async function fetchData(url: string): Promise { + const headers = { + Accept: 'application/json', + api_key: API_KEY + }; + const response = await fetch(url, { headers }); + if (!response.ok) { + const e = new FetchError(`Failed to fetch data from the API. Status: ${response.status}`); + if (!isSilencedError(e)) { + capture(e, { tags: { issue: 'api_fetch_failure' } }); + } + throw e; + } + return response.json() as Promise; +} + +function isValidUserData(data: any): boolean { + return Array.isArray(data) && data.length > 0 && data[0].username; +} + +function formatUserDetails(userDetails: { users: UserDetails[] }): UserResult { + const user = userDetails.users[0]; + return { + username: user.username, + eth_addresses: user.verified_addresses.eth_addresses.filter(isEvmAddress), + sol_addresses: user.verified_addresses.sol_addresses, + pfp_url: user.pfp_url + }; +} + +async function getUserDetails(username: Handle): Promise<{ users: UserDetails[] } | null> { + const transferData = await fetchData<{ transfers: any[] }>(`${FNAMES_API_URL}${username}`); + if (transferData.transfers.length > 0) { + const userDetails = await fetchData<{ result: { users: UserDetails[] } }>( + `${NEYNAR_API_URL}search?q=${username}&viewer_fid=197049` + ); + if (userDetails.result && userDetails.result.users.length > 0) { + return userDetails.result; + } + } + return null; +} + +function handleUserDetailsError(e: any, username: Handle): void { + if (!isSilencedError(e)) { + capture(e, { input: { username }, tags: { issue: 'fetch_user_details_failure' } }); + } + throw new FetchError(`Error fetching user details for ${username}.`); +} + +async function fetchUserDetailsByUsername(username: Handle): Promise { + try { + const userDetails = await getUserDetails(username); + if (userDetails) { + return formatUserDetails(userDetails); + } + } catch (e) { + handleUserDetailsError(e, username); + } + return null; +} + +function buildLookupUrl(addresses: Address[]): string { + const filteredAddresses = addresses.filter(isEvmAddress); + if (filteredAddresses.length === 0) { + throw new FetchError('No valid Ethereum addresses provided.'); + } + const addressesQuery = filteredAddresses.join(','); + return `${NEYNAR_API_URL}bulk-by-address?addresses=${addressesQuery}`; +} + +function processUserDetails(userDetails: ApiResponse, results: { [key: Handle]: Address }): void { + for (const [address, data] of Object.entries(userDetails)) { + if (isValidUserData(data)) { + const checksumAddress = getAddress(address); + results[checksumAddress] = `${data[0].username}.fcast.id`; + } + } +} + +function handleLookupError(e: any, addresses: Address[]): void { + if (!isSilencedError(e)) { + capture(e, { input: { addresses }, tags: { issue: 'lookup_addresses_failure' } }); + } + throw new FetchError('No user found for this address.'); +} + +export async function resolveNames(handles: Handle[]): Promise> { + const results: Record = {}; + for (const handle of handles) { + const normalizedHandle = handle.replace('.fcast.id', ''); + try { + const userDetails = await fetchUserDetailsByUsername(normalizedHandle); + if (userDetails && userDetails.eth_addresses) { + results[handle] = getAddress(userDetails.eth_addresses[0]); + } + } catch (e) { + console.error(`Error resolving name for handle ${handle}:`, e); + } + } + return results; +} + +export async function lookupAddresses(addresses: Address[]): Promise<{ [key: Handle]: Address }> { + const results: { [key: Handle]: Address } = {}; + try { + const url = buildLookupUrl(addresses); + const userDetails = await fetchData(url); + processUserDetails(userDetails, results); + } catch (e) { + handleLookupError(e, addresses); + } + return results; +} diff --git a/src/addressResolvers/index.ts b/src/addressResolvers/index.ts index e4f7c80c..f713f7c2 100644 --- a/src/addressResolvers/index.ts +++ b/src/addressResolvers/index.ts @@ -2,6 +2,7 @@ import * as ensResolver from './ens'; import * as lensResolver from './lens'; import * as unstoppableDomainResolver from './unstoppableDomains'; import * as starknetResolver from './starknet'; +import * as farcasterResolver from './farcaster'; import cache from './cache'; import { Address, @@ -13,7 +14,13 @@ import { } from './utils'; import { timeAddressResolverResponse as timeResponse } from '../helpers/metrics'; -const RESOLVERS = [ensResolver, unstoppableDomainResolver, lensResolver, starknetResolver]; +const RESOLVERS = [ + ensResolver, + unstoppableDomainResolver, + lensResolver, + starknetResolver, + farcasterResolver +]; const MAX_LOOKUP_ADDRESSES = 50; const MAX_RESOLVE_NAMES = 5; diff --git a/test/integration/addressResolvers/farcaster.test.ts b/test/integration/addressResolvers/farcaster.test.ts new file mode 100644 index 00000000..075c804c --- /dev/null +++ b/test/integration/addressResolvers/farcaster.test.ts @@ -0,0 +1,12 @@ +import { resolveNames, lookupAddresses } from '../../../src/addressResolvers/farcaster'; +import testAddressResolver from './helper'; + +testAddressResolver({ + name: 'Farcaster', + lookupAddresses, + resolveNames, + validAddress: '0xd1a8Dd23e356B9fAE27dF5DeF9ea025A602EC81e', + validDomain: 'codingsh.fcast.id', + blankAddress: '0x0000000000000000000000000000000000000000', + invalidDomains: ['domain.crypto', 'domain.eth', 'domain.com'] +}); diff --git a/yarn.lock b/yarn.lock index e67bc20e..1b382644 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3503,6 +3503,11 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + data-urls@^3.0.1: version "3.0.2" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" @@ -4082,6 +4087,14 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -4162,6 +4175,13 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -5522,6 +5542,7 @@ node-addon-api@^5.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.1.0.tgz#49da1ca055e109a23d537e9de43c09cca21eb762" integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== + node-fetch@^2.6.12, node-fetch@^2.6.7, node-fetch@^2.7.0, node-fetch@v2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" @@ -5529,6 +5550,15 @@ node-fetch@^2.6.12, node-fetch@^2.6.7, node-fetch@^2.7.0, node-fetch@v2.7.0: dependencies: whatwg-url "^5.0.0" +node-fetch@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" + integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -6848,6 +6878,11 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +web-streams-polyfill@^3.0.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"