Skip to content

Commit

Permalink
feat: expose /connect API
Browse files Browse the repository at this point in the history
  • Loading branch information
horsefacts committed Oct 27, 2023
1 parent 515f1e8 commit c1f24b2
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 17 deletions.
1 change: 0 additions & 1 deletion apps/hubble/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
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 { IdRegistryV2 } from "./abis.js";
import { clients } from "@farcaster/hub-nodejs";

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: IdRegistryV2.abi,
functionName: "idOf",
args: [custody],
}),
provider: clients.publicClientToProvider(publicClient),
};
}
7 changes: 6 additions & 1 deletion apps/hubble/src/hubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,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
29 changes: 27 additions & 2 deletions 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,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" });

Expand Down Expand Up @@ -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) {
Expand All @@ -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) => {
Expand Down
109 changes: 105 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,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);
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/auth/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
17 changes: 10 additions & 7 deletions packages/core/src/auth/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,28 @@ export function validate(params: string | Partial<SiweMessage>): HubResult<SiweM
}

export async function verify(
message: SiweMessage,
message: string | Partial<SiweMessage>,
signature: string,
options: ConnectOpts = {
fidVerifier: voidFidVerifier,
},
): HubAsyncResult<SiweResponse> {
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);
}
Expand Down Expand Up @@ -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(),
);
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/eth/clients.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<HttpTransport>[]).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);
}

0 comments on commit c1f24b2

Please sign in to comment.