Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(farcaster): enable offchain resolver to farcaster names #194 #203

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
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';

developerfred marked this conversation as resolved.
Show resolved Hide resolved
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 });
developerfred marked this conversation as resolved.
Show resolved Hide resolved
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;
}

developerfred marked this conversation as resolved.
Show resolved Hide resolved
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']
});
developerfred marked this conversation as resolved.
Show resolved Hide resolved
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