diff --git a/.changeset/thin-windows-attend.md b/.changeset/thin-windows-attend.md new file mode 100644 index 0000000000..44fc9fd14a --- /dev/null +++ b/.changeset/thin-windows-attend.md @@ -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 diff --git a/README.md b/README.md index 4faf267fd3..3e7d150c06 100644 --- a/README.md +++ b/README.md @@ -422,6 +422,50 @@ type FrameMetadataType = { type FrameMetadataResponse = Record; ``` +### 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[]; +}; +``` +

diff --git a/package.json b/package.json index c8a56bdcf3..ed97775f37 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/farcaster/getFarcasterUserAddress.test.ts b/src/farcaster/getFarcasterUserAddress.test.ts new file mode 100644 index 0000000000..0a2d1fa389 --- /dev/null +++ b/src/farcaster/getFarcasterUserAddress.test.ts @@ -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); + }); +}); diff --git a/src/farcaster/getFarcasterUserAddress.ts b/src/farcaster/getFarcasterUserAddress.ts new file mode 100644 index 0000000000..089f9ca4e2 --- /dev/null +++ b/src/farcaster/getFarcasterUserAddress.ts @@ -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 { + 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 }; diff --git a/src/farcaster/index.ts b/src/farcaster/index.ts new file mode 100644 index 0000000000..a64757496e --- /dev/null +++ b/src/farcaster/index.ts @@ -0,0 +1,2 @@ +export { getFarcasterUserAddress } from './getFarcasterUserAddress'; +export type { GetFarcasterUserAddressResponse } from './types'; diff --git a/src/farcaster/types.ts b/src/farcaster/types.ts new file mode 100644 index 0000000000..580ac856d4 --- /dev/null +++ b/src/farcaster/types.ts @@ -0,0 +1,9 @@ +/** + * GetFarcasterUserAddressResponse + * + * Note: exported as public Type + */ +export type GetFarcasterUserAddressResponse = { + custodyAddress?: string; + verifiedAddresses?: string[]; +}; diff --git a/src/utils/neynar/frame/neynarFrameFunctions.ts b/src/utils/neynar/frame/neynarFrameFunctions.ts index 73e8778d8b..26445c6bbd 100644 --- a/src/utils/neynar/frame/neynarFrameFunctions.ts +++ b/src/utils/neynar/frame/neynarFrameFunctions.ts @@ -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'; @@ -11,25 +12,12 @@ export async function neynarFrameValidation( castReactionContext = true, followContext = true, ): Promise { - 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); } diff --git a/src/utils/neynar/getDataFormNeynar.ts b/src/utils/neynar/getDataFormNeynar.ts new file mode 100644 index 0000000000..5569d05e0d --- /dev/null +++ b/src/utils/neynar/getDataFormNeynar.ts @@ -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(); +} diff --git a/src/utils/neynar/neynar.integ.ts b/src/utils/neynar/neynar.integ.ts index 58333622cb..163c581e00 100644 --- a/src/utils/neynar/neynar.integ.ts +++ b/src/utils/neynar/neynar.integ.ts @@ -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 () => { @@ -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', + ]); + }); }); diff --git a/src/utils/neynar/postDataToNeynar.ts b/src/utils/neynar/postDataToNeynar.ts new file mode 100644 index 0000000000..5f3bee6048 --- /dev/null +++ b/src/utils/neynar/postDataToNeynar.ts @@ -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(); +} diff --git a/src/utils/neynar/user/getCustodyAddressForFidNeynar.ts b/src/utils/neynar/user/getCustodyAddressForFidNeynar.ts new file mode 100644 index 0000000000..4f4497a164 --- /dev/null +++ b/src/utils/neynar/user/getCustodyAddressForFidNeynar.ts @@ -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 { + 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; +} diff --git a/src/utils/neynar/user/getVerifiedAddressesForFidNeynar.ts b/src/utils/neynar/user/getVerifiedAddressesForFidNeynar.ts new file mode 100644 index 0000000000..48395a9a9b --- /dev/null +++ b/src/utils/neynar/user/getVerifiedAddressesForFidNeynar.ts @@ -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 { + 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; +} diff --git a/src/utils/neynar/user/neynarUserFunctions.ts b/src/utils/neynar/user/neynarUserFunctions.ts index fa8e4c62a6..49fe6c1a30 100644 --- a/src/utils/neynar/user/neynarUserFunctions.ts +++ b/src/utils/neynar/user/neynarUserFunctions.ts @@ -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 { @@ -23,16 +24,8 @@ export async function neynarBulkUserLookup( farcasterIDs: number[], apiKey: string = NEYNAR_DEFAULT_API_KEY, ): Promise { - 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); } diff --git a/yarn.lock b/yarn.lock index ed2086658f..04b68a81a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2185,7 +2185,7 @@ __metadata: "@changesets/cli": "npm:^2.26.2" "@testing-library/jest-dom": "npm:^6.4.0" "@testing-library/react": "npm:^14.2.0" - "@types/jest": "npm:^29.5.11" + "@types/jest": "npm:^29.5.12" "@types/react": "npm:^18" "@types/react-dom": "npm:^18" jest: "npm:^29.7.0" @@ -3394,13 +3394,13 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^29.5.11": - version: 29.5.11 - resolution: "@types/jest@npm:29.5.11" +"@types/jest@npm:^29.5.12": + version: 29.5.12 + resolution: "@types/jest@npm:29.5.12" dependencies: expect: "npm:^29.0.0" pretty-format: "npm:^29.0.0" - checksum: 524a3394845214581278bf4d75055927261fbeac7e1a89cd621bd0636da37d265fe0a85eac58b5778758faad1cbd7c7c361dfc190c78ebde03a91cce33463261 + checksum: 25fc8e4c611fa6c4421e631432e9f0a6865a8cb07c9815ec9ac90d630271cad773b2ee5fe08066f7b95bebd18bb967f8ce05d018ee9ab0430f9dfd1d84665b6f languageName: node linkType: hard