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

Farcaster Connect API #1548

Closed
wants to merge 14 commits into from
18 changes: 18 additions & 0 deletions apps/hubble/src/eth/fidVerifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Hex, PublicClient } from "viem";
import { clients } from "@farcaster/hub-nodejs";
import { IdRegistry } from "./abis.js";
import { OptimismConstants } from "./l2EventsProvider.js";

export function getVerifier(publicClient: PublicClient, address: Hex = OptimismConstants.IdRegistryV2Address) {
const provider = clients.publicClientToProvider(publicClient);
return {
fidVerifier: (custody: Hex) =>
publicClient.readContract({
address: address,
abi: IdRegistry.abi,
functionName: "idOf",
args: [custody],
}),
provider,
};
}
7 changes: 6 additions & 1 deletion apps/hubble/src/hubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,12 @@ export class Hub implements HubInterface {
options.rpcRateLimit,
options.rpcSubscribePerIpLimit,
);
this.httpApiServer = new HttpAPIServer(this.rpcServer.getImpl(), this.engine, this.options.httpCorsOrigin);
this.httpApiServer = new HttpAPIServer(
this.rpcServer.getImpl(),
this.engine,
opClient,
this.options.httpCorsOrigin,
);
this.adminServer = new AdminServer(this, this.rocksDB, this.engine, this.syncEngine, options.rpcAuth);

// Setup job schedulers/workers
Expand Down
45 changes: 44 additions & 1 deletion apps/hubble/src/rpc/httpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
sendUnaryData,
userDataTypeFromJSON,
utf8StringToBytes,
connect,
} from "@farcaster/hub-nodejs";
import { Metadata, ServerUnaryCall } from "@grpc/grpc-js";
import fastify from "fastify";
Expand All @@ -29,6 +30,8 @@ import { PageOptions } from "../storage/stores/types.js";
import { DeepPartial } from "../storage/stores/store.js";
import Engine from "../storage/engine/index.js";
import { statsd } from "../utils/statsd.js";
import { getVerifier } from "../eth/fidVerifier.js";
import { PublicClient } from "viem";

const log = logger.child({ component: "HttpAPIServer" });

Expand Down Expand Up @@ -182,12 +185,14 @@ function getPageOptions(query: QueryPageParams): PageOptions {
export class HttpAPIServer {
grpcImpl: HubServiceServer;
engine: Engine;
l2PublicClient: PublicClient;

app = fastify();

constructor(grpcImpl: HubServiceServer, engine: Engine, corsOrigin = "*") {
constructor(grpcImpl: HubServiceServer, engine: Engine, l2PublicClient: PublicClient, corsOrigin = "*") {
this.grpcImpl = grpcImpl;
this.engine = engine;
this.l2PublicClient = l2PublicClient;

// Handle binary data
this.app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, function (req, body, done) {
Expand All @@ -213,6 +218,44 @@ export class HttpAPIServer {
}

initHandlers() {
//================connect================
// @doc-tag: /connect?message=...&signature=...
this.app.post<{ Body: { message: string; signature: string } }>("/v1/connect", async (request, reply) => {
const { message, signature } = request.body;
const verifierOpts = getVerifier(this.l2PublicClient);
const auth = await connect.verify(message, signature, verifierOpts);

const statusCodes: Record<string, number> = {
"bad_request.validation_failure": 400,
unauthorized: 401,
"unavailable.network_failure": 503,
};

if (auth.isErr()) {
const errorCode = auth.error.errCode;
const statusCode = statusCodes[errorCode] || 500;
reply.code(statusCode).type("application/json").send({ error: errorCode, message: auth.error.message });
return;
}

const { fid, userDataParams } = auth.value;

if (userDataParams) {
const call = getCallObject("getUserDataByFid", { fid }, request);
this.grpcImpl.getUserDataByFid(call, (err, response) => {
if (err) {
reply.code(400).type("application/json").send(JSON.stringify(err));
return;
}

const protobufJSON = protoToJSON(response, MessagesResponse);
reply.send({ ...auth.value, userData: protobufJSON });
});
} else {
reply.send(auth.value);
}
});

//================getInfo================
// @doc-tag: /info?dbstats=...
this.app.get<{ Querystring: { dbstats: string } }>("/v1/info", (request, reply) => {
Expand Down
139 changes: 135 additions & 4 deletions apps/hubble/src/rpc/test/httpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
UserNameType,
utf8StringToBytes,
VerificationAddEthAddressMessage,
clients,
} from "@farcaster/hub-nodejs";
import Engine from "../../storage/engine/index.js";
import { MockHub } from "../../test/mocks.js";
Expand All @@ -30,9 +31,12 @@ import axios from "axios";
import { faker } from "@faker-js/faker";
import { DeepPartial } from "fishery";
import { mergeDeepPartial } from "../../test/utils.js";
import { publicClient } from "../../test/utils.js";
import { IdRegisterOnChainEvent } from "@farcaster/core";
import { APP_VERSION } from "../../hubble.js";
import { connect } from "@farcaster/hub-nodejs";
import { PublicClient, zeroAddress } from "viem";

const publicClient = clients.defaultL2PublicClient as PublicClient;

const db = jestRocksDB("httpserver.rpc.server.test");
const network = FarcasterNetwork.TESTNET;
Expand All @@ -51,7 +55,7 @@ function getFullUrl(path: string) {
beforeAll(async () => {
syncEngine = new SyncEngine(hub, db);
server = new Server(hub, engine, syncEngine);
httpServer = new HttpAPIServer(server.getImpl(), engine);
httpServer = new HttpAPIServer(server.getImpl(), engine, publicClient);
httpServerAddress = (await httpServer.start())._unsafeUnwrap();
});

Expand Down Expand Up @@ -91,7 +95,7 @@ describe("httpServer", () => {
test("cors", async () => {
const syncEngine = new SyncEngine(hub, db);
const server = new Server(hub, engine, syncEngine);
const httpServer = new HttpAPIServer(server.getImpl(), engine, "http://example.com");
const httpServer = new HttpAPIServer(server.getImpl(), engine, publicClient, "http://example.com");
const addr = (await httpServer.start())._unsafeUnwrap();

const url = `${addr}/v1/info`;
Expand Down Expand Up @@ -149,7 +153,7 @@ describe("httpServer", () => {
test("submit with auth", async () => {
const rpcAuth = "username:password";
const authGrpcServer = new Server(hub, engine, syncEngine, undefined, rpcAuth);
const authServer = new HttpAPIServer(authGrpcServer.getImpl(), engine);
const authServer = new HttpAPIServer(authGrpcServer.getImpl(), engine, publicClient);
const addr = (await authServer.start())._unsafeUnwrap();

const postConfig = {
Expand Down Expand Up @@ -701,6 +705,133 @@ describe("httpServer", () => {
});
});

describe("connect API", () => {
const signature =
"0xc030c553eebcc41d9300dc578febe8ee41d69f86f13ecbbbc2e621583af8fb2b37e92009d1390d827ff22fdf255db2707773e04724252e3ab5c56cf1b7d2063e1b";
const userDataSignature =
"0xa5a5fbcf6d80862928f19a0b6ab2bdcb5f5d9febdafc4d759a99d417dfe58d9029739b29937fb0d78792b049db9602444133931153ea3b1e1fbcd69b50a5a0371c";

beforeAll(async () => {
httpServer = new HttpAPIServer(server.getImpl(), engine, publicClient);
httpServerAddress = (await httpServer.start())._unsafeUnwrap();
});

test("valid message - 200", async () => {
const res = connect.build({
domain: "example.com",
uri: "https://example.com/login",
version: "1",
nonce: "12345678",
issuedAt: "2023-10-01T00:00:00.000Z",
address: "0x2311B397957B19FCAe315Ad6726b7305BeBC24a1",
fid: 20943,
});
const message = res._unsafeUnwrap();
const url = getFullUrl("/v1/connect");
const response = await axios.post(url, { message, signature }, { headers: { "Content-Type": "application/json" } });

expect(response.status).toBe(200);
expect(response.data.success).toBe(true);
expect(response.data.fid).toBe(20943);
expect(response.data.userDataParams).toBe(undefined);
expect(response.data.userData).toBe(undefined);
});

test("valid message with userData - 200", async () => {
const res = connect.build({
domain: "example.com",
uri: "https://example.com/login",
version: "1",
nonce: "12345678",
issuedAt: "2023-10-01T00:00:00.000Z",
address: "0x2311B397957B19FCAe315Ad6726b7305BeBC24a1",
fid: 20943,
userDataParams: ["pfp", "display", "username"],
});
const message = res._unsafeUnwrap();
const url = getFullUrl("/v1/connect");
const response = await axios.post(
url,
{ message, signature: userDataSignature },
{ headers: { "Content-Type": "application/json" } },
);

expect(response.status).toBe(200);
expect(response.data.success).toBe(true);
expect(response.data.fid).toBe(20943);
expect(response.data.userDataParams).toStrictEqual(["pfp", "display", "username"]);
expect(response.data.userData.messages).toStrictEqual([]);
});

test("invalid signature - 401", async () => {
const res = connect.build({
domain: "example.com",
uri: "https://example.com/login",
version: "1",
nonce: "12345678",
issuedAt: "2023-10-01T00:00:00.000Z",
address: zeroAddress,
fid: 20943,
});
const message = res._unsafeUnwrap();
const url = getFullUrl("/v1/connect");
const response = await axios.post(
url,
{ message, signature },
{ validateStatus: () => true, headers: { "Content-Type": "application/json" } },
);

expect(response.status).toBe(401);
expect(response.data.error).toBe("unauthorized");
});

test("invalid message - 400", async () => {
const message = {
domain: "example.com",
statement: "Farcaster Connect",
chainId: 10,
uri: "https://example.com/login",
version: "1",
nonce: "12345678",
issuedAt: "2023-10-01T00:00:00.000Z",
address: "0x123",
resources: ["farcaster://fid/20943"],
};
const url = getFullUrl("/v1/connect");
const response = await axios.post(
url,
{ message, signature },
{ validateStatus: () => true, headers: { "Content-Type": "application/json" } },
);

expect(response.status).toBe(400);
expect(response.data.error).toBe("bad_request.validation_failure");
});

test("provider error - 503", async () => {
jest.spyOn(publicClient, "readContract").mockRejectedValue(new Error("client error"));
const res = connect.build({
domain: "example.com",
uri: "https://example.com/login",
version: "1",
nonce: "12345678",
issuedAt: "2023-10-01T00:00:00.000Z",
address: "0x2311B397957B19FCAe315Ad6726b7305BeBC24a1",
fid: 20943,
});
const message = res._unsafeUnwrap();
const url = getFullUrl("/v1/connect");
const response = await axios.post(
url,
{ message, signature },
{ validateStatus: () => true, headers: { "Content-Type": "application/json" } },
);

expect(response.status).toBe(503);
expect(response.data.error).toBe("unavailable.network_failure");
});
});

async function axiosGet(url: string) {
try {
return await axios.get(url);
Expand Down
29 changes: 29 additions & 0 deletions apps/hubble/www/docs/docs/httpapi/connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Farcaster Connect API

## connect
Verify a Farcaster Connect message and signature.


**Body Parameters**
| Parameter | Description |
| --------- | ----------- |
| message | Farcaster Connect sign in message |
| signature | ERC-191 signature |


**Example**
```bash
curl http://127.0.0.1:2281/v1/connect \
-H 'Content-Type: application/json' \
-d'{"message": "msg", "signature": "sig"}
```
**Response**
```json
{
"fid": 20943,
"success": true
}
```
7 changes: 3 additions & 4 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"files": ["dist"],
"license": "MIT",
"dependencies": {
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.3.0",
"ffi-napi": "^4.0.3",
"neverthrow": "^6.0.0",
"ref-napi": "^3.0.3",
"siwe": "^2.1.4",
"ethers": "^6.6.1",
"viem": "^1.12.2"
},
"scripts": {
Expand All @@ -40,7 +40,6 @@
"@types/ffi-napi": "^4.0.7",
"@types/ref-napi": "^3.0.7",
"biome-config-custom": "*",
"ethers": "^6.6.1",
"ethers5": "npm:ethers@^5.7.0",
"node-gyp": "^9.4.0",
"prettier-config-custom": "*",
Expand Down
Loading
Loading