Skip to content
Open
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
51 changes: 40 additions & 11 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import ccipread from '@chainlink/ccip-read-server';
import { ZeroAddress } from 'ethers';
import { encodeFunctionData, keccak256, parseAbi } from 'viem';

import { getReadClient, getWriteClient, migrateToLatest } from './db.js';
import './env.js';
import { log } from './log.js';
import { generateCCIPSignature, signer, signerAddress } from './signature.js';
import { signer, signerAddress } from './signature.js';
import { generateCCIPSignature } from './ccip-signature.js';
import {
createTransfer,
getCurrentUsername,
Expand All @@ -15,11 +16,12 @@ import {
ValidationError,
} from './transfers.js';

import { currentTimestamp, decodeDnsName } from './util.js';
import { currentTimestamp, decodeDnsName, decodeEnsRequest } from './util.js';
import { getIdRegistryContract } from './ethereum.js';
import { getRecordFromHub } from './hub.js';

export const RESOLVE_ABI = [
'function resolve(bytes calldata name, bytes calldata data) external view returns(string name, uint256 timestamp, address owner, bytes memory sig)',
'function resolve(bytes calldata name, bytes calldata data) external view returns(bytes32 request, bytes memory result, uint256 validUntil, bytes memory sig)',
];

const write = getWriteClient();
Expand All @@ -32,15 +34,42 @@ const server = new ccipread.Server();
server.add(RESOLVE_ABI, [
{
type: 'resolve',
func: async ([name, _data], _req) => {
const fname = decodeDnsName(name)[0];
// `name` is the DNS encoded ENS name, eg alice.farcaster.eth
// `data` is the calldata for the encoded ENS resolver call, eg addr() or text()
func: async ([name, data]) => {
const nameParts = decodeDnsName(name);
const [fname, subdomain, tld] = nameParts;

// Only support farcaster.eth subdomains
if (subdomain !== 'farcaster' && tld !== 'eth') {
throw new Error('Invalid name');
}

const ensRequest = decodeEnsRequest(data);
const transfer = await getLatestTransfer(fname, read);
if (!transfer || transfer.to === 0) {
// If no transfer or the name was unregistered, return empty values
return ['', 0, ZeroAddress, '0x'];
const now = Math.floor(Date.now() / 1000);
const validUntil = now + 60;

const extraData = encodeFunctionData({
abi: parseAbi(['function resolve(bytes calldata name, bytes calldata data) view returns (bytes memory)']),
functionName: 'resolve',
args: [name, data],
});

// Throw if no transfer or the name was unregistered or the ENS request is unsupported
if (!transfer || transfer.to === 0 || !ensRequest) {
log.info(`No transfer or invalid request: ${fname}`);
return [keccak256(extraData), '0x', validUntil, '0x'];
}
const signature = await generateCCIPSignature(transfer.username, transfer.timestamp, transfer.owner, signer);
return [transfer.username, transfer.timestamp, transfer.owner, signature];

const { plain, response } = await getRecordFromHub(transfer.user_fid, ensRequest);
log.info({ fname, ...ensRequest, res: plain }, 'getRecordFromHub');

// The L1 contract must be able to confirm that this response is for a recent request it initiated
// To do that, the initial request must be included in the signed response from the gateway (hashed for efficiency)
// The response is hashed beacuse EIP-712 doesn't support dynamic types
const signature = await generateCCIPSignature(keccak256(extraData), keccak256(response), validUntil, signer);
return [keccak256(extraData), response, validUntil, signature];
},
},
]);
Expand Down
49 changes: 49 additions & 0 deletions src/ccip-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ethers } from 'ethers';
import { CCIP_ADDRESS } from './env.js';

const ccip_domain = {
name: 'Farcaster name verification',
version: '1',
chainId: 1,
verifyingContract: CCIP_ADDRESS,
};

const types = {
DataProof: [
{ name: 'request', type: 'bytes32' },
{ name: 'result', type: 'bytes32' },
{ name: 'validUntil', type: 'uint256' },
],
};

export async function generateCCIPSignature(
request: `0x${string}`,
result: `0x${string}`,
validUntil: number,
signer: ethers.Signer
) {
const dataProof = {
request,
result,
validUntil,
};

const signature = await signer.signTypedData(ccip_domain, types, dataProof);
return signature;
}

export function verifyCCIPSignature(
request: `0x${string}`,
result: `0x${string}`,
validUntil: number,
signature: string,
signerAddress: string
) {
const dataProof = {
request,
result,
validUntil,
};
const signer = ethers.verifyTypedData(ccip_domain, types, dataProof, signature);
return signer.toLowerCase() === signerAddress.toLowerCase();
}
2 changes: 2 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ if (ENVIRONMENT === 'prod' && CCIP_ADDRESS === '') {
}

export const ID_REGISTRY_ADDRESS = process.env['ID_REGISTRY_ADDRESS'] || '0x00000000fc6c5f01fc30151999387bb99a9f489b';

export const HUB_GRPC_URL = new URL(process.env['HUB_GRPC_URL'] || 'http://localhost:2281');
142 changes: 142 additions & 0 deletions src/hub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { AbiItem, encodeFunctionResult, zeroAddress } from 'viem';

import { HUB_GRPC_URL } from './env.js';
import { decodeEnsRequest, BASE_RESOLVER_ABI } from './util.js';

// ENS text record keys to support
const supportedKeys = ['avatar', 'description', 'url'] as const;
type SupportedKey = (typeof supportedKeys)[number];

/**
*
* @param fid
* @param param1
* @returns An object with the raw and encoded ENS record
*/
export async function getRecordFromHub(
fid: number,
{ functionName, args }: NonNullable<ReturnType<typeof decodeEnsRequest>>
) {
let result: string = '';

if (functionName === 'addr') {
const coinType = args[1] ?? 60n; // 60 is ETH (SLIP-0044)

// Farcaster doesn't store different addresses per chain, so we'll assume all addresses are valid on all EVM chains
// This checks if a request is for ETH or an L2 (ENSIP-11)
if (coinType === 60n || coinType > 0x80000000) {
result = await getEthAddressByFid(fid);
} else {
result = zeroAddress;
}
}

if (functionName === 'text') {
const key = args[1] as SupportedKey;

// Only handle keys that hubs might have
if (supportedKeys.includes(key)) {
result = await getUserDataByFid(fid, key);
}
}

// Find the correct ABI item for each function, otherwise `addr(node, coinType)` gets incorrectly encoded as `addr(node)`
const abi: AbiItem | undefined = BASE_RESOLVER_ABI.find(
(abi) => abi.name === functionName && abi.inputs.length === args.length
);

return {
plain: result,
// This is what the response of ENS resolve() looks like, and will ultimately be returned to the client
response: encodeFunctionResult({
abi: [abi],
functionName,
result,
}),
};
}

// TODO: figure out if all the types below can be imported from other Farcaster packages
// Or get GRPC working and connect to the client from @farcaster/hub-nodejs for strong types
type Protocol = 'PROTOCOL_ETHEREUM' | 'PROTOCOL_SOLANA';

type HttpVerificationsResponse = {
messages: Array<{
data: {
type: string;
fid: number;
timestamp: number;
network: string;
verificationAddAddressBody: {
address: string;
claimSignature: string;
blockHash: string;
verificationType: number;
chainId: number;
protocol: Protocol;
ethSignature: string;
};
verificationAddEthAddressBody: {
address: string;
claimSignature: string;
blockHash: string;
verificationType: number;
chainId: number;
protocol: Protocol;
ethSignature: string;
};
};
hash: string;
hashScheme: string;
signature: string;
signatureScheme: string;
signer: string;
}>;
nextPageToken: string;
};

async function getEthAddressByFid(fid: number) {
const res = await fetch(HUB_GRPC_URL.origin + '/v1/verificationsByFid?fid=' + fid);
const verifications = (await res.json()) as HttpVerificationsResponse;

const ethVerifications = verifications.messages.filter(
(m) => m.data?.verificationAddAddressBody?.protocol === 'PROTOCOL_ETHEREUM'
);
const ethAddress = ethVerifications[0]?.data?.verificationAddAddressBody?.address;
return ethAddress ?? zeroAddress;
}

type HttpUserDataResponse = {
messages: Array<{
data: {
type: string;
fid: number;
timestamp: number;
network: string;
userDataBody: {
type: string;
value: string;
};
};
hash: string;
hashScheme: string;
signature: string;
signatureScheme: string;
signer: string;
}>;
nextPageToken: string;
};

const ensTextKeyToHubType: Record<SupportedKey, string> = {
avatar: 'USER_DATA_TYPE_PFP',
description: 'USER_DATA_TYPE_BIO',
url: 'USER_DATA_TYPE_URL',
};

async function getUserDataByFid(fid: number, key: SupportedKey) {
const hubType = ensTextKeyToHubType[key];
const res = await fetch(HUB_GRPC_URL.origin + '/v1/userDataByFid?fid=' + fid);
const json = (await res.json()) as HttpUserDataResponse;
const message = json.messages.find((m) => m.data.userDataBody.type === hubType);
return message?.data.userDataBody.value ?? '';
}
34 changes: 2 additions & 32 deletions src/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ethers } from 'ethers';
import * as process from 'process';
import { createPublicClient, fallback, http } from 'viem';
import { optimism } from 'viem/chains';
import { CCIP_ADDRESS, OP_ALCHEMY_SECRET, WARPCAST_ADDRESS } from './env.js';
import { OP_ALCHEMY_SECRET, WARPCAST_ADDRESS } from './env.js';

export const signer = ethers.Wallet.fromPhrase(
process.env.MNEMONIC || 'test test test test test test test test test test test junk'
Expand All @@ -26,12 +26,7 @@ const hub_domain = {
// TODO: When changing, remember to also update on the backend!
verifyingContract: '0xe3be01d99baa8db9905b33a3ca391238234b79d1', // name registry contract, will be the farcaster ENS CCIP contract later
} as const;
const ccip_domain = {
name: 'Farcaster name verification',
version: '1',
chainId: 1,
verifyingContract: CCIP_ADDRESS,
};

const types = {
UserNameProof: [
{ name: 'name', type: 'string' },
Expand All @@ -49,15 +44,6 @@ export async function generateSignature(userName: string, timestamp: number, own
return Buffer.from((await signer.signTypedData(hub_domain, types, userNameProof)).replace(/^0x/, ''), 'hex');
}

export async function generateCCIPSignature(userName: string, timestamp: number, owner: string, signer: ethers.Signer) {
const userNameProof = {
name: userName,
timestamp,
owner: owner,
};
return Buffer.from((await signer.signTypedData(ccip_domain, types, userNameProof)).replace(/^0x/, ''), 'hex');
}

export async function verifySignature(
userName: string,
timestamp: number,
Expand Down Expand Up @@ -88,19 +74,3 @@ export async function verifySignature(

return verifyResult;
}

export function verifyCCIPSignature(
userName: string,
timestamp: number,
owner: string,
signature: string,
signerAddress: string
) {
const userNameProof = {
name: userName,
timestamp,
owner: owner,
};
const signer = ethers.verifyTypedData(ccip_domain, types, userNameProof, signature);
return signer.toLowerCase() === signerAddress.toLowerCase();
}
1 change: 1 addition & 0 deletions src/transfers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ function toTransferResponse(row: Selectable<TransfersTable> | undefined) {
to: row.to,
user_signature: bytesToHex(row.userSignature),
server_signature: bytesToHex(row.serverSignature),
user_fid: row.userFid,
};
}

Expand Down
19 changes: 19 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
BigNumberish,
ZeroHash,
} from 'ethers';
import { decodeFunctionData, parseAbi } from 'viem';

export type EventArgBasicValue = string | number | boolean;
type EventArgValue = EventArgBasicValue | EventArgBasicValue[] | EventArgs;
Expand Down Expand Up @@ -108,3 +109,21 @@ export function decodeDnsName(name: string) {
}
return labels;
}

export const BASE_RESOLVER_ABI = parseAbi([
'function addr(bytes32 node) view returns (address)',
'function addr(bytes32 node, uint256 coinType) view returns (bytes memory)',
'function text(bytes32 node, string key) view returns (string memory)',
]);

export function decodeEnsRequest(data: `0x${string}`) {
try {
return decodeFunctionData({
abi: BASE_RESOLVER_ABI,
data,
});
} catch {
// Handle lookups of records that we don't support like contenthash
return null;
}
}
Loading
Loading