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

feat: FIP-8 verifications for contract wallets #1449

Merged
merged 6 commits into from
Oct 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/orange-crabs-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@farcaster/hub-nodejs": patch
"@farcaster/hub-web": patch
"@farcaster/core": patch
"@farcaster/hubble": patch
---

FIP-8 contract verifications
11 changes: 9 additions & 2 deletions apps/hubble/src/hubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import { L2EventsProvider, OptimismConstants } from "./eth/l2EventsProvider.js";
import { prettyPrintTable } from "./profile/profile.js";
import packageJson from "./package.json" assert { type: "json" };
import { createPublicClient, fallback, http } from "viem";
import { mainnet } from "viem/chains";
import { mainnet, optimism } from "viem/chains";
import { AddrInfo } from "@chainsafe/libp2p-gossipsub/types";
import { CheckIncomingPortsJobScheduler } from "./storage/jobs/checkIncomingPortsJob.js";
import { NetworkConfig, applyNetworkConfig, fetchNetworkConfig } from "./network/utils/networkConfig.js";
Expand Down Expand Up @@ -344,13 +344,20 @@ export class Hub implements HubInterface {
lockTimeout: options.commitLockTimeout,
});

const opMainnetRpcUrls = options.l2RpcUrl.split(",");
const opTransports = opMainnetRpcUrls.map((url) => http(url, { retryCount: 2 }));
const opClient = createPublicClient({
chain: optimism,
transport: fallback(opTransports, { rank: options.rankRpcs ?? false }),
});

const ethMainnetRpcUrls = options.ethMainnetRpcUrl.split(",");
const transports = ethMainnetRpcUrls.map((url) => http(url, { retryCount: 2 }));
const mainnetClient = createPublicClient({
chain: mainnet,
transport: fallback(transports, { rank: options.rankRpcs ?? false }),
});
this.engine = new Engine(this.rocksDB, options.network, eventHandler, mainnetClient);
this.engine = new Engine(this.rocksDB, options.network, eventHandler, mainnetClient, opClient);

const profileSync = options.profileSync ?? false;
this.syncEngine = new SyncEngine(
Expand Down
67 changes: 64 additions & 3 deletions apps/hubble/src/storage/engine/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,70 @@ describe("mergeMessage", () => {
);
const result = await engine.mergeMessage(testnetVerificationAdd);
// Signature will not match because we're attempting to recover the address based on the wrong network
expect(result).toEqual(
err(new HubError("bad_request.validation_failure", "ethSignature does not match address")),
);
expect(result).toEqual(err(new HubError("bad_request.validation_failure", "invalid ethSignature")));
});

describe("validateOrRevokeMessage", () => {
let mergedMessage: Message;
let verifications: VerificationAddEthAddressMessage[] = [];

const getVerifications = async () => {
const verificationsResult = await engine.getVerificationsByFid(fid);
if (verificationsResult.isOk()) {
verifications = verificationsResult.value.messages;
}
};

const createVerification = async () => {
return await Factories.VerificationAddEthAddressMessage.create(
{
data: {
fid,
verificationAddEthAddressBody: Factories.VerificationAddEthAddressBody.build({
chainId: 1,
verificationType: 1,
}),
},
},
{ transient: { signer } },
);
};

beforeEach(async () => {
jest.replaceProperty(publicClient.chain, "id", 1);
jest.spyOn(publicClient, "verifyTypedData").mockResolvedValue(true);
mergedMessage = await createVerification();
const result = await engine.mergeMessage(mergedMessage);
expect(result.isOk()).toBeTruthy();
await getVerifications();
expect(verifications.length).toBe(1);
});

afterEach(async () => {
jest.restoreAllMocks();
});

test("revokes a contract verification when signature is no longer valid", async () => {
jest.spyOn(publicClient, "verifyTypedData").mockResolvedValue(false);
const result = await engine.validateOrRevokeMessage(mergedMessage);
expect(result.isOk()).toBeTruthy();

const verificationsResult = await engine.getVerificationsByFid(fid);
expect(verificationsResult.isOk()).toBeTruthy();

await getVerifications();
expect(verifications.length).toBe(0);
});

test("does not revoke contract verifications when RPC call fails", async () => {
jest.spyOn(publicClient, "verifyTypedData").mockRejectedValue(new Error("verify failed"));
const result = await engine.validateOrRevokeMessage(mergedMessage);
expect(result._unsafeUnwrapErr().errCode).toEqual("unavailable.network_failure");
expect(result._unsafeUnwrapErr().message).toMatch("verify failed");

await getVerifications();
expect(verifications.length).toBe(1);
});
});
});

Expand Down
56 changes: 48 additions & 8 deletions apps/hubble/src/storage/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import { nativeValidationMethods } from "../../rustfunctions.js";
import { RateLimiterAbstract } from "rate-limiter-flexible";
import { TypedEmitter } from "tiny-typed-emitter";
import { ValidationWorkerData } from "./validation.worker.js";

const log = logger.child({
component: "Engine",
Expand All @@ -79,6 +80,7 @@
private _db: RocksDB;
private _network: FarcasterNetwork;
private _publicClient: PublicClient | undefined;
private _l2PublicClient: PublicClient | undefined;

private _linkStore: LinkStore;
private _reactionStore: ReactionStore;
Expand All @@ -98,11 +100,18 @@

private _totalPruneSize: number;

constructor(db: RocksDB, network: FarcasterNetwork, eventHandler?: StoreEventHandler, publicClient?: PublicClient) {
constructor(
db: RocksDB,
network: FarcasterNetwork,
eventHandler?: StoreEventHandler,
publicClient?: PublicClient,
l2PublicClient?: PublicClient,
) {
super();
this._db = db;
this._network = network;
this._publicClient = publicClient;
this._l2PublicClient = l2PublicClient;

this.eventHandler = eventHandler ?? new StoreEventHandler(db);

Expand Down Expand Up @@ -142,7 +151,7 @@
const workerPath = "./build/storage/engine/validation.worker.js";
try {
if (fs.existsSync(workerPath)) {
this._validationWorker = new Worker(workerPath);
this._validationWorker = new Worker(workerPath, { workerData: this.getWorkerData() });
log.info({ workerPath }, "created validation worker thread");

this._validationWorker.on("message", (data) => {
Expand Down Expand Up @@ -398,6 +407,10 @@
const isValid = await this.validateMessage(message);

if (isValid.isErr() && message.data) {
if (isValid.error.errCode === "unavailable.network_failure") {
return err(isValid.error);
}

const setPostfix = typeToSetPostfix(message.data.type);

switch (setPostfix) {
Expand All @@ -417,11 +430,7 @@
return this._verificationStore.revoke(message);
}
case UserPostfix.UsernameProofMessage: {
if (isValid.error.errCode === "unavailable.network_failure") {
return err(isValid.error);
} else {
return this._usernameProofStore.revoke(message);
}
return this._usernameProofStore.revoke(message);
}
default: {
return err(new HubError("bad_request.invalid_param", "invalid message type"));
Expand Down Expand Up @@ -972,7 +981,7 @@
worker.postMessage({ id, message });
});
} else {
return validations.validateMessage(message, nativeValidationMethods);
return validations.validateMessage(message, nativeValidationMethods, this.getPublicClients());
}
}

Expand Down Expand Up @@ -1093,6 +1102,37 @@

return ok(undefined);
}

private getPublicClients(): { [chainId: number]: PublicClient } {
const clients: { [chainId: number]: PublicClient } = {};
if (this._publicClient?.chain) {
clients[this._publicClient.chain.id] = this._publicClient;
}
if (this._l2PublicClient?.chain) {
clients[this._l2PublicClient.chain.id] = this._l2PublicClient;

Check warning on line 1112 in apps/hubble/src/storage/engine/index.ts

View check run for this annotation

Codecov / codecov/patch

apps/hubble/src/storage/engine/index.ts#L1112

Added line #L1112 was not covered by tests
}
return clients;
}

private getWorkerData(): ValidationWorkerData {
const l1Transports: string[] = [];
this._publicClient?.transport["transports"].forEach((transport: { value?: { url: string } }) => {
if (transport?.value) {
l1Transports.push(transport.value["url"]);
}
});
const l2Transports: string[] = [];
this._l2PublicClient?.transport["transports"].forEach((transport: { value?: { url: string } }) => {
if (transport?.value) {
l2Transports.push(transport.value["url"]);
}
});

return {
ethMainnetRpcUrl: l1Transports.join(","),
l2RpcUrl: l2Transports.join(","),
};
}
}

export default Engine;
31 changes: 29 additions & 2 deletions apps/hubble/src/storage/engine/validation.worker.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
import { validations } from "@farcaster/hub-nodejs";
import { nativeValidationMethods } from "../../rustfunctions.js";
import { parentPort } from "worker_threads";
import { workerData, parentPort } from "worker_threads";
import { http, createPublicClient, fallback } from "viem";
import { optimism, mainnet } from "viem/chains";

export interface ValidationWorkerData {
l2RpcUrl: string;
ethMainnetRpcUrl: string;
}

const config = workerData as ValidationWorkerData;
const opMainnetRpcUrls = config.l2RpcUrl.split(",");
const opTransports = opMainnetRpcUrls.map((url) => http(url, { retryCount: 2 }));
const opClient = createPublicClient({
chain: optimism,
transport: fallback(opTransports, { rank: false }),
});

const ethMainnetRpcUrls = config.ethMainnetRpcUrl.split(",");
const transports = ethMainnetRpcUrls.map((url) => http(url, { retryCount: 2 }));
const mainnetClient = createPublicClient({
chain: mainnet,
transport: fallback(transports, { rank: false }),
});

const publicClients = {
[optimism.id]: opClient,
[mainnet.id]: mainnetClient,
};

// Wait for messages from the main thread and validate them, posting the result back
parentPort?.on("message", (data) => {
(async () => {
const { id, message } = data;
const result = await validations.validateMessage(message, nativeValidationMethods);
const result = await validations.validateMessage(message, nativeValidationMethods, publicClients);

if (result.isErr()) {
parentPort?.postMessage({ id, errCode: result.error.errCode, errMessage: result.error.message });
Expand Down
2 changes: 1 addition & 1 deletion apps/hubble/src/test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const anvilChain = {
http: [localHttpUrl],
},
},
} as const satisfies Chain;
} satisfies Chain;

const provider = {
// biome-ignore lint/suspicious/noExplicitAny: legacy code, avoid using ignore for new code
Expand Down
67 changes: 66 additions & 1 deletion packages/core/src/crypto/eip712.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { HubAsyncResult, HubError } from "../errors";
import { VerificationEthAddressClaim } from "../verifications";
import { UserNameProofClaim } from "../userNameProof";
import { PublicClients, defaultPublicClients } from "../eth/clients";
import { CHAIN_IDS } from "../eth/chains";

export const EIP_712_FARCASTER_DOMAIN = {
name: "Farcaster Verify Ethereum Address",
Expand Down Expand Up @@ -30,6 +32,8 @@
},
] as const;

export const EIP_712_FARCASTER_VERIFICATION_CLAIM_CHAIN_IDS = [...CHAIN_IDS, 0];

export const EIP_712_FARCASTER_MESSAGE_DATA = [
{
name: "hash",
Expand All @@ -50,11 +54,18 @@
{ name: "owner", type: "address" },
] as const;

export const verifyVerificationEthAddressClaimSignature = async (
const verifyVerificationClaimEOASignature = async (
claim: VerificationEthAddressClaim,
signature: Uint8Array,
address: Uint8Array,
chainId: number,
): HubAsyncResult<boolean> => {
if (chainId !== 0) {
return ResultAsync.fromPromise(

Check warning on line 64 in packages/core/src/crypto/eip712.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/crypto/eip712.ts#L64

Added line #L64 was not covered by tests
Promise.reject(),
() => new HubError("bad_request.invalid_param", "Invalid chain ID"),

Check warning on line 66 in packages/core/src/crypto/eip712.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/crypto/eip712.ts#L66

Added line #L66 was not covered by tests
);
}
const valid = await ResultAsync.fromPromise(
verifyTypedData({
address: bytesToHex(address),
Expand All @@ -66,10 +77,64 @@
}),
(e) => new HubError("unknown", e as Error),
);
return valid;
};

const verifyVerificationClaimContractSignature = async (
claim: VerificationEthAddressClaim,
signature: Uint8Array,
address: Uint8Array,
chainId: number,
publicClients: PublicClients = defaultPublicClients,
): HubAsyncResult<boolean> => {
const client = publicClients[chainId];
if (!client) {
return ResultAsync.fromPromise(
Promise.reject(),
() => new HubError("bad_request.invalid_param", `RPC client not provided for chainId ${chainId}`),
);
}
const valid = await ResultAsync.fromPromise(
client.verifyTypedData({
address: bytesToHex(address),
domain: { ...EIP_712_FARCASTER_DOMAIN, chainId },
types: { VerificationClaim: EIP_712_FARCASTER_VERIFICATION_CLAIM },
primaryType: "VerificationClaim",
message: claim,
signature,
}),
(e) => new HubError("unavailable.network_failure", e as Error),

Check warning on line 106 in packages/core/src/crypto/eip712.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/crypto/eip712.ts#L106

Added line #L106 was not covered by tests
);
return valid;
};

export const verifyVerificationEthAddressClaimSignature = async (
claim: VerificationEthAddressClaim,
signature: Uint8Array,
address: Uint8Array,
verificationType: number,
chainId: number,
publicClients: PublicClients = defaultPublicClients,
): HubAsyncResult<boolean> => {
if (!EIP_712_FARCASTER_VERIFICATION_CLAIM_CHAIN_IDS.includes(chainId)) {
return ResultAsync.fromPromise(
Promise.reject(),
() => new HubError("bad_request.invalid_param", "Invalid chain ID"),
);
}

if (verificationType === 0) {
return verifyVerificationClaimEOASignature(claim, signature, address, chainId);
} else if (verificationType === 1) {
return verifyVerificationClaimContractSignature(claim, signature, address, chainId, publicClients);
} else {
return ResultAsync.fromPromise(
Promise.reject(),
() => new HubError("bad_request.invalid_param", "Invalid verification type"),
);
}
};

export const verifyUserNameProofClaim = async (
nameProof: UserNameProofClaim,
signature: Uint8Array,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/eth/chains.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { mainnet, goerli, optimism, optimismGoerli } from "viem/chains";

export const CHAIN_IDS = [mainnet.id, goerli.id, optimism.id, optimismGoerli.id] as const;
Loading