Skip to content

Commit

Permalink
feat: Get the EVM address for a given Farcaster User (#114)
Browse files Browse the repository at this point in the history
Co-authored-by: Sneh Koul <[email protected]>
  • Loading branch information
Sneh1999 and snehkoul-cb authored Feb 13, 2024
1 parent 2c101e8 commit 926bc30
Show file tree
Hide file tree
Showing 15 changed files with 310 additions and 36 deletions.
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 @@ -29,7 +29,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",
"@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 {
const hasCustodyAddresses = options?.hasCustodyAddresses ?? true;
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

0 comments on commit 926bc30

Please sign in to comment.