From 890849f43e928b8c063d66a905c388f1e2ae3a3b Mon Sep 17 00:00:00 2001 From: horsefacts Date: Thu, 26 Oct 2023 22:20:29 -0400 Subject: [PATCH] feat: expose /connect API --- apps/hubble/package.json | 1 - apps/hubble/src/eth/fidVerifier.ts | 18 ++++ apps/hubble/src/hubble.ts | 7 +- apps/hubble/src/rpc/httpServer.ts | 29 +++++- apps/hubble/src/rpc/test/httpServer.test.ts | 109 +++++++++++++++++++- packages/core/package.json | 1 + packages/core/src/auth/connect.test.ts | 2 +- packages/core/src/auth/connect.ts | 17 +-- packages/core/src/eth/clients.ts | 20 +++- 9 files changed, 187 insertions(+), 17 deletions(-) create mode 100644 apps/hubble/src/eth/fidVerifier.ts diff --git a/apps/hubble/package.json b/apps/hubble/package.json index f1a86403c6..955a262e6f 100644 --- a/apps/hubble/package.json +++ b/apps/hubble/package.json @@ -95,7 +95,6 @@ "rate-limiter-flexible": "^2.4.1", "rwlock": "~5.0.0", "semver": "^7.5.4", - "siwe": "^2.1.4", "tar": "^6.1.15", "tiny-typed-emitter": "~2.1.0", "viem": "^1.12.2" diff --git a/apps/hubble/src/eth/fidVerifier.ts b/apps/hubble/src/eth/fidVerifier.ts new file mode 100644 index 0000000000..b56d49ac0b --- /dev/null +++ b/apps/hubble/src/eth/fidVerifier.ts @@ -0,0 +1,18 @@ +import { Hex, PublicClient } from "viem"; +import { clients } from "@farcaster/hub-nodejs"; +import { IdRegistry } from "./abis.js"; + +const ID_REGISTRY_ADDRESS = "0x00000000fcaf86937e41ba038b4fa40baa4b780a" as const; + +export function getVerifier(publicClient: PublicClient, address: Hex = ID_REGISTRY_ADDRESS) { + return { + fidVerifier: (custody: Hex) => + publicClient.readContract({ + address: address, + abi: IdRegistry.abi, + functionName: "idOf", + args: [custody], + }), + provider: clients.publicClientToProvider(publicClient), + }; +} diff --git a/apps/hubble/src/hubble.ts b/apps/hubble/src/hubble.ts index 0bec5bd168..d0ac6912e1 100644 --- a/apps/hubble/src/hubble.ts +++ b/apps/hubble/src/hubble.ts @@ -457,7 +457,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 diff --git a/apps/hubble/src/rpc/httpServer.ts b/apps/hubble/src/rpc/httpServer.ts index ec898cfc60..f0a142583b 100644 --- a/apps/hubble/src/rpc/httpServer.ts +++ b/apps/hubble/src/rpc/httpServer.ts @@ -19,6 +19,7 @@ import { sendUnaryData, userDataTypeFromJSON, utf8StringToBytes, + connect, } from "@farcaster/hub-nodejs"; import { Metadata, ServerUnaryCall } from "@grpc/grpc-js"; import fastify from "fastify"; @@ -29,7 +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 { SiweMessage } from "siwe"; +import { getVerifier } from "../eth/fidVerifier.js"; +import { PublicClient } from "viem"; const log = logger.child({ component: "HttpAPIServer" }); @@ -183,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) { @@ -214,6 +218,27 @@ export class HttpAPIServer { } initHandlers() { + //================connect================ + // @doc-tag: /connect + 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); + if (auth.isErr()) { + if (auth.error.errCode === "bad_request.validation_failure") { + reply.code(400).type("application/json").send({ error: auth.error.errCode, message: auth.error.message }); + } else if (auth.error.errCode === "unauthorized") { + reply.code(401).type("application/json").send({ error: auth.error.errCode, message: auth.error.message }); + } else if (auth.error.errCode === "unavailable.network_failure") { + reply.code(503).type("application/json").send({ error: auth.error.errCode, message: auth.error.message }); + } else { + reply.code(500).type("application/json").send({ error: auth.error.errCode, message: auth.error.message }); + } + } else { + reply.code(200).type("application/json").send(JSON.stringify(auth.value)); + } + }); + //================getInfo================ // @doc-tag: /info?dbstats=... this.app.get<{ Querystring: { dbstats: string } }>("/v1/info", (request, reply) => { diff --git a/apps/hubble/src/rpc/test/httpServer.test.ts b/apps/hubble/src/rpc/test/httpServer.test.ts index a99d360589..312800f33c 100644 --- a/apps/hubble/src/rpc/test/httpServer.test.ts +++ b/apps/hubble/src/rpc/test/httpServer.test.ts @@ -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"; @@ -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; @@ -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(); }); @@ -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`; @@ -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 = { @@ -701,6 +705,103 @@ describe("httpServer", () => { }); }); +describe("connect API", () => { + const signature = + "0xc030c553eebcc41d9300dc578febe8ee41d69f86f13ecbbbc2e621583af8fb2b37e92009d1390d827ff22fdf255db2707773e04724252e3ab5c56cf1b7d2063e1b"; + + 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); + }); + + 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); diff --git a/packages/core/package.json b/packages/core/package.json index 57f6062eef..f325f34211 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,6 +21,7 @@ "ffi-napi": "^4.0.3", "neverthrow": "^6.0.0", "ref-napi": "^3.0.3", + "siwe": "^2.1.4", "viem": "^1.12.2" }, "scripts": { diff --git a/packages/core/src/auth/connect.test.ts b/packages/core/src/auth/connect.test.ts index 2618a88fd2..4ceea4273a 100644 --- a/packages/core/src/auth/connect.test.ts +++ b/packages/core/src/auth/connect.test.ts @@ -226,7 +226,7 @@ describe("verify", () => { expect(result.isOk()).toBe(false); const err = result._unsafeUnwrapErr(); expect(err.errCode).toBe("unauthorized"); - expect(err.message).toBe(`Invalid resource: signer ${account.address} does not own fid 5678.`); + expect(err.message).toBe(`Invalid resource: signer ${account.address} does not own fid 1234.`); }); test("client error", async () => { diff --git a/packages/core/src/auth/connect.ts b/packages/core/src/auth/connect.ts index 084f4da3c3..07bdeb916a 100644 --- a/packages/core/src/auth/connect.ts +++ b/packages/core/src/auth/connect.ts @@ -41,25 +41,28 @@ export function validate(params: string | Partial): HubResult, signature: string, options: ConnectOpts = { fidVerifier: voidFidVerifier, }, ): HubAsyncResult { const { fidVerifier, provider } = options; - const siwe = (await verifySiweMessage(message, signature, provider)).andThen(mergeFid); + const valid = validate(message); + if (valid.isErr()) return err(valid.error); + + const siwe = (await verifySiweMessage(valid.value, signature, provider)).andThen(mergeFid); if (siwe.isErr()) return err(siwe.error); if (!siwe.value.success) { - const message = siwe.value.error?.type ?? "Unknown error"; - return err(new HubError("unauthorized", message)); + const errMessage = siwe.value.error?.type ?? "Unknown error"; + return err(new HubError("unauthorized", errMessage)); } const fid = await verifyFidOwner(siwe.value, fidVerifier); if (fid.isErr()) return err(fid.error); if (!fid.value.success) { - const message = siwe.value.error?.type ?? "Unknown error"; - return err(new HubError("unauthorized", message)); + const errMessage = siwe.value.error?.type ?? "Unknown error"; + return err(new HubError("unauthorized", errMessage)); } return ok(fid.value); } @@ -132,7 +135,7 @@ async function verifyFidOwner( if (fid !== BigInt(response.fid)) { response.success = false; response.error = new SiweError( - `Invalid resource: signer ${signer} does not own fid ${fid}.`, + `Invalid resource: signer ${signer} does not own fid ${response.fid}.`, response.fid.toString(), fid.toString(), ); diff --git a/packages/core/src/eth/clients.ts b/packages/core/src/eth/clients.ts index 0b995e25bc..303baecf6d 100644 --- a/packages/core/src/eth/clients.ts +++ b/packages/core/src/eth/clients.ts @@ -1,4 +1,5 @@ -import { VerifyTypedDataParameters, createPublicClient, http } from "viem"; +import { JsonRpcProvider, FallbackProvider, Networkish } from "ethers"; +import { HttpTransport, PublicClient, VerifyTypedDataParameters, createPublicClient, http } from "viem"; import { mainnet, goerli, optimism, optimismGoerli } from "viem/chains"; export interface ViemPublicClient { @@ -35,3 +36,20 @@ export const defaultPublicClients: PublicClients = { [goerli.id]: defaultL1PublicTestClient, [optimismGoerli.id]: defaultL2PublicTestClient, }; + +export function publicClientToProvider(publicClient: PublicClient): JsonRpcProvider | FallbackProvider { + const { chain, transport } = publicClient; + const network = { + chainId: chain?.id, + name: chain?.name, + ensAddress: chain?.contracts?.ensRegistry?.address, + } as Networkish; + if (transport.type === "fallback") { + const providers = (transport["transports"] as ReturnType[]).map( + ({ value }) => new JsonRpcProvider(value?.url, network), + ); + if (providers.length === 1) return providers[0] as JsonRpcProvider; + return new FallbackProvider(providers); + } + return new JsonRpcProvider(transport["url"], network); +}