Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ IPFS_GATEWAY=cloudflare-ipfs.com
REDIS_URL=
SENTRY_DSN=
SENTRY_TRACE_SAMPLE_RATE=
NEYNAR_API_KEY=
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@snapshot-labs/snapshot-metrics": "^1.4.1",
"@snapshot-labs/snapshot-sentry": "^1.5.5",
"@snapshot-labs/snapshot.js": "^0.11.17",
"@types/node-fetch": "^2.6.11",
Comment thread
developerfred marked this conversation as resolved.
Outdated
"@unstoppabledomains/resolution": "^9.2.2",
"axios": "^0.25.0",
"canvas": "^2.9.0",
Expand All @@ -37,6 +38,7 @@
"eslint": "^6.7.2",
"express": "^4.17.1",
"jsdom": "^19.0.0",
"node-fetch": "^3.3.2",
Comment thread
developerfred marked this conversation as resolved.
Outdated
"nodemon": "^2.0.7",
"redis": "^4.6.10",
"sharp": "^0.30.1",
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';

Comment thread
developerfred marked this conversation as 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 });
Comment thread
developerfred marked this conversation as 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;
}

Comment thread
developerfred marked this conversation as 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']
});
Comment thread
developerfred marked this conversation as resolved.
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
"sourceRoot": "/"
"sourceRoot": "/",
Comment thread
developerfred marked this conversation as resolved.
Outdated
"baseUrl": "."
Comment thread
developerfred marked this conversation as resolved.
Outdated
}
}
47 changes: 47 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2409,6 +2409,14 @@
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==

"@types/node-fetch@^2.6.11":
version "2.6.11"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24"
integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==
dependencies:
"@types/node" "*"
form-data "^4.0.0"

"@types/node@*", "@types/node@^14.14.21":
version "14.17.17"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.17.tgz#4ec7b71bbcb01a4e55455b60b18b1b6a783fe31d"
Expand Down Expand Up @@ -3502,6 +3510,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 @@ -4098,6 +4111,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 @@ -4183,6 +4204,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"
Expand Down Expand Up @@ -5729,13 +5757,27 @@ node-addon-api@^4.3.0:
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f"
integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==

node-domexception@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==

node-fetch@^2.6.12, node-fetch@^2.6.5, node-fetch@^2.7.0:
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 @@ -7220,6 +7262,11 @@ walker@^1.0.7:
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