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: Get the EVM address for a given Farcaster User #114

Merged
merged 14 commits into from
Feb 13, 2024
6 changes: 6 additions & 0 deletions .changeset/thin-windows-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@coinbase/onchainkit': patch
---

- Added `getFarcasterUserAddress` utility to extract user's custody and/or verified addresses.
- Updates the version of `@types/jest` package
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,50 @@ type FrameMetadataType = {
type FrameMetadataResponse = Record<string, string>;
```

### getFarcasterUserAddress

The `getFarcasterUserAddress` function retrieves the user address associated with a given Farcaster ID (fid). It provides options to specify whether the client wants `custody address` and/or `verified addresses`, and also allows the user to provide their own neynar api key. By default, both `custody` and `verified` addresses are provided.

```tsx
import { getFarcasterUserAddress } from '@coinbase/onchainkit/farcaster';

async function fetchUserAddress(fid: number) {
// Returns custody and verified addresses. If there is an error, returns null
const userAddress = await getFarcasterUserAddress(fid);
console.log(userAddress);
}

fetchUserAddress(3);
```

**@Param**

```ts
// Fid - Farcaster Id
fid: number;

// Optional options to specify whether the client wants custody and/or verified addresses
// along with their neynar api key
type GetFarcasterUserAddressOptions =
| {
neynarApiKey?: string; // default to onchain-kit's default key
hasCustodyAddresses?: boolean; // default to true
hasVerifiedAddresses?: boolean; // default to true
}
| undefined;
```

**@Returns**

```ts
type GetFarcasterUserAddressResponse = {
// Custody Address of a given fid
custodyAddress?: string;
// List of all verified addresses for a given fid
verifiedAddresses?: string[];
};
```

<br />
<br />

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"@changesets/cli": "^2.26.2",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.2.0",
"@types/jest": "^29.5.11",
"@types/jest": "^29.5.12",
Sneh1999 marked this conversation as resolved.
Show resolved Hide resolved
"@types/react": "^18",
"@types/react-dom": "^18",
"jest": "^29.7.0",
Expand Down
79 changes: 79 additions & 0 deletions src/farcaster/getFarcasterUserAddress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { getFarcasterUserAddress } from './getFarcasterUserAddress';
import { getCustodyAddressForFidNeynar } from '../utils/neynar/user/getCustodyAddressForFidNeynar';
import { getVerifiedAddressesForFidNeynar } from '../utils/neynar/user/getVerifiedAddressesForFidNeynar';

jest.mock('../utils/neynar/user/getCustodyAddressForFidNeynar');
jest.mock('../utils/neynar/user/getVerifiedAddressesForFidNeynar');

describe('getFarcasterUserAddress function', () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
});

it('should return null if any API call fails', async () => {
const error = new Error('Something went wrong');
(getVerifiedAddressesForFidNeynar as jest.Mock).mockRejectedValue(error);
const result = await getFarcasterUserAddress(123);
expect(result).toBeNull();
});

it('should return both custody and verified addresses by default', async () => {
const expectedCustodyAddress = 'mock-custody-address';
(getCustodyAddressForFidNeynar as jest.Mock).mockResolvedValue(expectedCustodyAddress);
(getVerifiedAddressesForFidNeynar as jest.Mock).mockResolvedValue([expectedCustodyAddress]);

const result = await getFarcasterUserAddress(123);
expect(result).toEqual({
custodyAddress: expectedCustodyAddress,
verifiedAddresses: [expectedCustodyAddress],
});
});

it('should return null if both hasCustodyAddresses and hasVerifiedAddresses are false', async () => {
const result = await getFarcasterUserAddress(123, {
hasCustodyAddresses: false,
hasVerifiedAddresses: false,
});
expect(result).toEqual({});
});

it('should return both custodyAddress and verifiedAddresses when both options are true', async () => {
const expectedCustodyAddress = 'mock-custody-address';
const expectedVerifiedAddresses = ['mock-verified-address-1', 'mock-verified-address-2'];
(getCustodyAddressForFidNeynar as jest.Mock).mockResolvedValue(expectedCustodyAddress);
(getVerifiedAddressesForFidNeynar as jest.Mock).mockResolvedValue(expectedVerifiedAddresses);
const result = await getFarcasterUserAddress(123, {
hasCustodyAddresses: true,
hasVerifiedAddresses: true,
});
expect(result).toEqual({
custodyAddress: expectedCustodyAddress,
verifiedAddresses: expectedVerifiedAddresses,
});
});

it('should only return custodyAddress when hasVerifiedAddresses is false', async () => {
const expectedCustodyAddress = 'mock-custody-address';
(getCustodyAddressForFidNeynar as jest.Mock).mockResolvedValue(expectedCustodyAddress);
const result = await getFarcasterUserAddress(123, {
hasVerifiedAddresses: false,
});
expect(result).toEqual({ custodyAddress: expectedCustodyAddress });
});

it('should only return verifiedAddresses when hasCustodyAddresses is false', async () => {
const expectedVerifiedAddresses = ['mock-verified-address-1', 'mock-verified-address-2'];
(getVerifiedAddressesForFidNeynar as jest.Mock).mockResolvedValue(expectedVerifiedAddresses);
const result = await getFarcasterUserAddress(123, {
hasCustodyAddresses: false,
});
expect(result).toEqual({ verifiedAddresses: expectedVerifiedAddresses });
});

it('should call getCustodyAddressForFidNeynar and getVerifiedAddressesForFidNeynar with the default neynarApiKey if not provided', async () => {
await getFarcasterUserAddress(123);
expect(getCustodyAddressForFidNeynar).toHaveBeenCalledWith(123, undefined);
expect(getVerifiedAddressesForFidNeynar).toHaveBeenCalledWith(123, undefined);
});
});
48 changes: 48 additions & 0 deletions src/farcaster/getFarcasterUserAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { getCustodyAddressForFidNeynar } from '../utils/neynar/user/getCustodyAddressForFidNeynar';
import { getVerifiedAddressesForFidNeynar } from '../utils/neynar/user/getVerifiedAddressesForFidNeynar';
import { GetFarcasterUserAddressResponse } from './types';

type GetFarcasterUserAddressOptions =
| {
neynarApiKey?: string; // default to onchain-kit's default key
hasCustodyAddresses?: boolean; // default to true
hasVerifiedAddresses?: boolean; // default to true
}
| undefined;

/**
* Get the user address for a given fid
* @param fid The farcaster id
* @param GetFarcasterUserAddressOptions The options to specify the type of addresses to get and the neynar api key
* @returns the custory address and/or verified addresses. If there is an error, it returns null
*/
async function getFarcasterUserAddress(
fid: number,
options?: GetFarcasterUserAddressOptions,
): Promise<GetFarcasterUserAddressResponse | null> {
try {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Sneh1999 side not for the next PR, large try/catch are a bad practice in Javascript, as each Try/Catch will hold on memory almost everything inside a try till the end of the execution. It's advice to have smaller try/catch.

const hasCustodyAddresses = options?.hasCustodyAddresses ?? true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasCustodyAddresses @Sneh1999 did you mentioned there can be only one address for custody?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So why not hasCustodyAddress as singular?

const hasVerifiedAddresses = options?.hasVerifiedAddresses ?? true;
const response: GetFarcasterUserAddressResponse = {};

if (hasCustodyAddresses) {
const custodyAddress = await getCustodyAddressForFidNeynar(fid, options?.neynarApiKey);
if (custodyAddress) {
response.custodyAddress = custodyAddress;
}
}

if (hasVerifiedAddresses) {
const verifiedAddresses = await getVerifiedAddressesForFidNeynar(fid, options?.neynarApiKey);
if (verifiedAddresses) {
response.verifiedAddresses = verifiedAddresses;
}
}

return response;
} catch (e) {
return null;
}
}

export { getFarcasterUserAddress };
2 changes: 2 additions & 0 deletions src/farcaster/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { getFarcasterUserAddress } from './getFarcasterUserAddress';
export type { GetFarcasterUserAddressResponse } from './types';
9 changes: 9 additions & 0 deletions src/farcaster/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* GetFarcasterUserAddressResponse
*
* Note: exported as public Type
*/
export type GetFarcasterUserAddressResponse = {
custodyAddress?: string;
verifiedAddresses?: string[];
};
28 changes: 8 additions & 20 deletions src/utils/neynar/frame/neynarFrameFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { version } from '../../../version';
import { FrameValidationData } from '../../../core/types';
import { FetchError } from '../exceptions/FetchError';
import { convertToNeynarResponseModel } from './neynarFrameModels';
import { postDataToNeynar } from '../postDataToNeynar';

export const NEYNAR_DEFAULT_API_KEY = 'NEYNAR_ONCHAIN_KIT';

Expand All @@ -11,25 +12,12 @@ export async function neynarFrameValidation(
castReactionContext = true,
followContext = true,
): Promise<FrameValidationData | undefined> {
const options = {
method: 'POST',
url: `https://api.neynar.com/v2/farcaster/frame/validate`,
headers: {
accept: 'application/json',
api_key: apiKey,
'content-type': 'application/json',
onchainkit_version: version,
},
body: JSON.stringify({
message_bytes_in_hex: messageBytes,
cast_reaction_context: castReactionContext, // Returns if the user has liked/recasted
follow_context: followContext, // Returns if the user is Following
}),
};
const resp = await fetch(options.url, options);
if (resp.status !== 200) {
throw new FetchError(`non-200 status returned from neynar : ${resp.status}`);
}
const responseBody = await resp.json();
const url = `https://api.neynar.com/v2/farcaster/frame/validate`;

const responseBody = await postDataToNeynar(url, apiKey, {
message_bytes_in_hex: messageBytes,
cast_reaction_context: castReactionContext, // Returns if the user has liked/recasted
follow_context: followContext, // Returns if the user is Following
});
return convertToNeynarResponseModel(responseBody);
}
21 changes: 21 additions & 0 deletions src/utils/neynar/getDataFormNeynar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { version } from '../../version';
import { FetchError } from './exceptions/FetchError';
import { NEYNAR_DEFAULT_API_KEY } from './frame/neynarFrameFunctions';

export async function getDataFromNeynar(url: string, apiKey: string = NEYNAR_DEFAULT_API_KEY) {
const options = {
method: 'GET',
url: url,
headers: {
accept: 'application/json',
api_key: apiKey,
'content-type': 'application/json',
onchainkit_version: version,
},
};
const resp = await fetch(options.url, options);
if (resp.status !== 200) {
throw new FetchError(`non-200 status returned from neynar : ${resp.status}`);
}
return await resp.json();
}
17 changes: 17 additions & 0 deletions src/utils/neynar/neynar.integ.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { neynarBulkUserLookup } from './user/neynarUserFunctions';
import { neynarFrameValidation } from './frame/neynarFrameFunctions';
import { getCustodyAddressForFidNeynar } from './user/getCustodyAddressForFidNeynar';
import { getVerifiedAddressesForFidNeynar } from './user/getVerifiedAddressesForFidNeynar';

describe('neynar integration tests', () => {
it('bulk data lookup should find all users', async () => {
Expand Down Expand Up @@ -27,4 +29,19 @@ describe('neynar integration tests', () => {
);
expect(response?.interactor.verified_accounts.length).toBeGreaterThan(0);
});

it('get custody address for FID returns correct address', async () => {
const fid = 3;
const response = await getCustodyAddressForFidNeynar(fid);
expect(response).toEqual('0x6b0bda3f2ffed5efc83fa8c024acff1dd45793f1');
});

it('get verified addresses for FID returns correct addresses', async () => {
const fid = 3;
const response = await getVerifiedAddressesForFidNeynar(fid);
expect(response).toEqual([
'0x8fc5d6afe572fefc4ec153587b63ce543f6fa2ea',
'0xd7029bdea1c17493893aafe29aad69ef892b8ff2',
]);
});
});
26 changes: 26 additions & 0 deletions src/utils/neynar/postDataToNeynar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { version } from '../../version';
import { FetchError } from './exceptions/FetchError';
import { NEYNAR_DEFAULT_API_KEY } from './frame/neynarFrameFunctions';

export async function postDataToNeynar(
url: string,
apiKey: string = NEYNAR_DEFAULT_API_KEY,
data: any,
) {
const options = {
method: 'POST',
url: url,
headers: {
accept: 'application/json',
api_key: apiKey,
'content-type': 'application/json',
onchainkit_version: version,
},
body: JSON.stringify(data),
};
const resp = await fetch(options.url, options);
if (resp.status !== 200) {
throw new FetchError(`non-200 status returned from neynar : ${resp.status}`);
}
return await resp.json();
}
19 changes: 19 additions & 0 deletions src/utils/neynar/user/getCustodyAddressForFidNeynar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { version } from '../../../version';
import { FetchError } from '../exceptions/FetchError';
import { getDataFromNeynar } from '../getDataFormNeynar';
import { NEYNAR_DEFAULT_API_KEY } from '../frame/neynarFrameFunctions';

export async function getCustodyAddressForFidNeynar(
fid: number,
apiKey: string = NEYNAR_DEFAULT_API_KEY,
): Promise<string> {
const url = `https://api.neynar.com/v1/farcaster/custody-address?fid=${fid}`;

const responseBody = await getDataFromNeynar(url, apiKey);

if (!responseBody || !responseBody.result || !responseBody.result.custodyAddress) {
throw new Error('No custody address found for FID ' + fid);
}

return responseBody.result.custodyAddress;
}
22 changes: 22 additions & 0 deletions src/utils/neynar/user/getVerifiedAddressesForFidNeynar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NEYNAR_DEFAULT_API_KEY } from '../frame/neynarFrameFunctions';
import { getDataFromNeynar } from '../getDataFormNeynar';

export async function getVerifiedAddressesForFidNeynar(
fid: number,
apiKey: string = NEYNAR_DEFAULT_API_KEY,
): Promise<string[]> {
const url = `https://api.neynar.com/v1/farcaster/verifications?fid=${fid}`;

const responseBody = await getDataFromNeynar(url, apiKey);

if (
!responseBody ||
!responseBody.result ||
!responseBody.result.verifications ||
responseBody.result.verifications.length === 0
) {
throw new Error('No verified addresses found for FID ' + fid);
}

return responseBody.result.verifications;
}
13 changes: 3 additions & 10 deletions src/utils/neynar/user/neynarUserFunctions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { FetchError } from '../exceptions/FetchError';
import { getDataFromNeynar } from '../getDataFormNeynar';

export const NEYNAR_DEFAULT_API_KEY = 'NEYNAR_ONCHAIN_KIT';
export interface NeynarUserModel {
Expand All @@ -23,16 +24,8 @@ export async function neynarBulkUserLookup(
farcasterIDs: number[],
apiKey: string = NEYNAR_DEFAULT_API_KEY,
): Promise<NeynarBulkUserLookupModel | undefined> {
const options = {
method: 'GET',
url: `https://api.neynar.com/v2/farcaster/user/bulk?fids=${farcasterIDs.join(',')}`,
headers: { accept: 'application/json', api_key: apiKey },
};
const resp = await fetch(options.url, { headers: options.headers });
if (resp.status !== 200) {
throw new FetchError(`non-200 status returned from neynar : ${resp.status}`);
}
const responseBody = await resp.json();
const url = `https://api.neynar.com/v2/farcaster/user/bulk?fids=${farcasterIDs.join(',')}`;
const responseBody = await getDataFromNeynar(url, apiKey);
return convertToNeynarResponseModel(responseBody);
}

Expand Down
Loading
Loading