Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
144 changes: 144 additions & 0 deletions src/addressResolvers/farcaster.ts
Original file line number Diff line number Diff line change
@@ -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<T>(url: string): Promise<T> {
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<T>;
}

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<UserResult | null> {
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<Record<Handle, Address>> {
const results: Record<Handle, Address> = {};
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<ApiResponse>(url);
processUserDetails(userDetails, results);
} catch (e) {
handleLookupError(e, addresses);
}
return results;
}
9 changes: 8 additions & 1 deletion src/addressResolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand Down
12 changes: 12 additions & 0 deletions test/integration/addressResolvers/farcaster.test.ts
Original file line number Diff line number Diff line change
@@ -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']
});
35 changes: 35 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"

[email protected]:
version "0.2.0"
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
Expand Down Expand Up @@ -5522,13 +5542,23 @@ 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, [email protected]:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
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"
Expand Down Expand Up @@ -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"
Expand Down