Skip to content

Commit

Permalink
refactor: remove farcaster references replace w/ neynar api (#59)
Browse files Browse the repository at this point in the history
Co-authored-by: Rob Polak <[email protected]>
  • Loading branch information
robpolak and robpolak-cb authored Feb 1, 2024
1 parent cd67802 commit caa2788
Show file tree
Hide file tree
Showing 12 changed files with 314 additions and 854 deletions.
8 changes: 0 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,6 @@
"version": "0.3.1",
"repository": "https://github.com/coinbase/onchainkit.git",
"license": "MIT",
"dependencies": {
"@ethersproject/abstract-signer": "^5.7.0",
"@farcaster/fishery": "^2.2.3",
"@farcaster/hub-nodejs": "^0.10.21",
"ethers": "^6.10.0",
"react": "^18",
"viem": "^2.5.0"
},
"scripts": {
"build": "tsc && tsc --module commonjs --outDir dist/lib",
"check": "yarn format",
Expand Down
10 changes: 5 additions & 5 deletions src/core/farcaster.integ.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getFrameMessage } from './getFrameMessage';

describe('getFrameValidatedMessage integration tests', () => {
it('bulk data lookup should find all users', async () => {
it('frame message should decode properly', async () => {
const body = {
untrustedData: {
fid: 194519,
Expand All @@ -22,9 +22,9 @@ describe('getFrameValidatedMessage integration tests', () => {
};
const response = await getFrameMessage(body);
expect(response?.isValid).toEqual(true);
expect(response?.message?.url).toEqual(body.untrustedData.url);
expect(response?.message?.fid).toEqual(body.untrustedData.fid);
expect(response?.message?.network).toEqual(body.untrustedData.network);
expect(response?.message?.castId.fid).toEqual(body.untrustedData.castId.fid);
expect(response?.message?.button).toEqual(body.untrustedData.buttonIndex);
expect(response?.message?.interactor.fid).toEqual(body.untrustedData.fid);
expect(response.message?.liked).toEqual(false);
expect(response.message?.recasted).toEqual(false);
});
});
4 changes: 3 additions & 1 deletion src/core/farcasterTypes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { NeynarFrameValidationResponse } from '../utils/neynar/frame/neynarFrameModels';

export interface FrameRequest {
untrustedData: FrameData;
trustedData: {
Expand All @@ -6,7 +8,7 @@ export interface FrameRequest {
}

export type FrameValidationResponse =
| { isValid: true; message: FrameData }
| { isValid: true; message: NeynarFrameValidationResponse }
| { isValid: false; message: undefined };

export interface FrameData {
Expand Down
11 changes: 0 additions & 11 deletions src/core/getFrameAccountAddress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,6 @@ jest.mock('../utils/neynar/user/neynarUserFunctions', () => {
};
});

jest.mock('@farcaster/hub-nodejs', () => {
return {
getSSLHubRpcClient: jest.fn().mockReturnValue({
validateMessage: jest.fn(),
}),
Message: {
decode: jest.fn(),
},
};
});

describe('getFrameAccountAddress', () => {
const fakeMessage = {
fid: 1234,
Expand Down
28 changes: 10 additions & 18 deletions src/core/getFrameMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,22 @@ import { mockNeynarResponse } from './mock';
import { getFrameMessage } from './getFrameMessage';
import { neynarBulkUserLookup } from '../utils/neynar/user/neynarUserFunctions';
import { FrameRequest } from './farcasterTypes';
import { neynarFrameValidation } from '../utils/neynar/frame/neynarFrameFunctions';

jest.mock('../utils/neynar/user/neynarUserFunctions', () => {
return {
neynarBulkUserLookup: jest.fn(),
};
});

jest.mock('@farcaster/hub-nodejs', () => {
jest.mock('../utils/neynar/frame/neynarFrameFunctions', () => {
return {
getSSLHubRpcClient: jest.fn().mockReturnValue({
validateMessage: jest.fn(),
}),
Message: {
decode: jest.fn(),
},
neynarFrameValidation: jest.fn(),
};
});

describe('getFrameValidatedMessage', () => {
it('should return undefined if the message is invalid', async () => {
const fid = 1234;
const addresses = ['0xaddr1'];
const { validateMock } = mockNeynarResponse(fid, addresses, neynarBulkUserLookup as jest.Mock);
validateMock.mockClear();
validateMock.mockResolvedValue({
isOk: () => {
return false;
},
});
const result = await getFrameMessage({
trustedData: { messageBytes: 'invalid' },
} as FrameRequest);
Expand All @@ -40,11 +27,16 @@ describe('getFrameValidatedMessage', () => {
it('should return the message if the message is valid', async () => {
const fid = 1234;
const addresses = ['0xaddr1'];
mockNeynarResponse(fid, addresses, neynarBulkUserLookup as jest.Mock);
mockNeynarResponse(
fid,
addresses,
neynarBulkUserLookup as jest.Mock,
neynarFrameValidation as jest.Mock,
);
const fakeFrameData = {
trustedData: {},
};
const result = await getFrameMessage(fakeFrameData as FrameRequest);
expect(result?.message?.fid).toEqual(fid);
expect(result?.message?.interactor.fid).toEqual(fid);
});
});
40 changes: 13 additions & 27 deletions src/core/getFrameMessage.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
import { HubRpcClient, Message, getSSLHubRpcClient } from '@farcaster/hub-nodejs';
import { convertToFrame, FrameRequest, FrameValidationResponse } from './farcasterTypes';

/**
* Farcaster Hub for signature verification,
* consider using a private hub if needed:
* https://docs.farcaster.xyz/hubble/hubble
*/
const HUB_URL = 'nemes.farcaster.xyz:2283';

export function getHubClient(): HubRpcClient {
return getSSLHubRpcClient(HUB_URL);
}
import { FrameValidationResponse } from './farcasterTypes';
import { neynarFrameValidation } from '../utils/neynar/frame/neynarFrameFunctions';

/**
* Given a frame message, decode and validate it.
Expand All @@ -19,24 +8,21 @@ export function getHubClient(): HubRpcClient {
*
* @param body The JSON received by server on frame callback
*/
async function getFrameMessage(body: FrameRequest): Promise<FrameValidationResponse> {
// Get the message from the request body
const frameMessage: Message = Message.decode(
Buffer.from(body?.trustedData?.messageBytes ?? '', 'hex'),
);
async function getFrameMessage(body: any): Promise<FrameValidationResponse> {
// Validate the message
const client = getHubClient();
const result = await client.validateMessage(frameMessage);
if (result.isOk() && result.value.valid && result.value.message) {
const response = await neynarFrameValidation(body?.trustedData?.messageBytes);
if (response?.valid) {
return {
isValid: true,
message: response,
};
} else {
// Security best practice, don't return anything if we can't validate the frame.
return {
isValid: result.value?.valid,
message: convertToFrame(result?.value?.message?.data),
isValid: false,
message: undefined,
};
}
return {
isValid: false,
message: undefined,
};
}

export { getFrameMessage };
20 changes: 7 additions & 13 deletions src/core/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function mockNeynarResponse(
fid: number,
addresses: string[] | undefined,
lookupMock: jest.Mock,
frameValidationMock: jest.Mock = jest.fn(),
) {
const neynarResponse = {
users: [
Expand All @@ -28,18 +29,11 @@ export function mockNeynarResponse(
};
lookupMock.mockResolvedValue(neynarResponse);

const getSSLHubRpcClientMock = require('@farcaster/hub-nodejs').getSSLHubRpcClient;
const validateMock = getSSLHubRpcClientMock().validateMessage as jest.Mock;

// Mock the response from the Farcaster hub
validateMock.mockResolvedValue({
isOk: () => true,
value: { valid: true, message: { fid } },
frameValidationMock.mockResolvedValue({
valid: true,
interactor: {
fid,
verified_accounts: addresses,
},
});

validateMock.mockResolvedValue(buildFarcasterResponse(fid));

return {
validateMock,
};
}
59 changes: 59 additions & 0 deletions src/utils/neynar/frame/neynarFrameFunctions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { FetchError } from '../exceptions/FetchError';
import { neynarFrameValidation } from './neynarFrameFunctions';

describe('neynar frame functions', () => {
let fetchMock = jest.fn();
let status = 200;

beforeEach(() => {
status = 200;
global.fetch = jest.fn(() =>
Promise.resolve({
status,
json: fetchMock,
}),
) as jest.Mock;
});

it('should return fetch response correctly', async () => {
const mockedResponse = {
valid: true,
action: {
tapped_button: {
index: 1,
},
cast: {
viewer_context: {
recasted: true,
},
},
interactor: {
fid: 1234,
verifications: ['0x00123'],
viewer_context: {
following: true,
followed_by: true,
},
},
},
};
fetchMock.mockResolvedValue(mockedResponse);
const message = '0x00';
const resp = await neynarFrameValidation(message);

expect(resp?.valid).toEqual(true);
expect(resp?.recasted).toEqual(mockedResponse.action.cast.viewer_context.recasted);
expect(resp?.button).toEqual(mockedResponse.action.tapped_button.index);
expect(resp?.interactor?.fid).toEqual(mockedResponse.action.interactor.fid);
expect(resp?.interactor?.verified_accounts).toEqual(
mockedResponse.action.interactor.verifications,
);
expect(resp?.following).toEqual(mockedResponse.action.interactor.viewer_context.following);
});

it('fails on a non-200', async () => {
status = 401;
const resp = neynarFrameValidation('0x00');
await expect(resp).rejects.toThrow(FetchError);
});
});
28 changes: 28 additions & 0 deletions src/utils/neynar/frame/neynarFrameFunctions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { FetchError } from '../exceptions/FetchError';
import { convertToNeynarResponseModel, NeynarFrameValidationResponse } from './neynarFrameModels';

export const NEYNAR_DEFAULT_API_KEY = 'NEYNAR_ONCHAIN_KIT';

export async function neynarFrameValidation(
messageBytes: string,
apiKey: string = NEYNAR_DEFAULT_API_KEY,
castReactionContext = true,
followContext = true,
): Promise<NeynarFrameValidationResponse | 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' },
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();
return convertToNeynarResponseModel(responseBody);
}
Loading

0 comments on commit caa2788

Please sign in to comment.