Skip to content

Commit 6027ac8

Browse files
feat: add json farcaster signature utils
1 parent f158920 commit 6027ac8

File tree

1 file changed

+312
-0
lines changed

1 file changed

+312
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { createPublicClient, http, parseAbi } from "viem";
2+
import { optimism } from "viem/chains";
3+
import type { JsonObject } from "../core/types";
4+
5+
export class InvalidJFSHeaderError extends Error {}
6+
7+
export class InvalidJFSPayloadError extends Error {}
8+
9+
export class InvalidJFSCompactSignatureError extends Error {}
10+
11+
export class InvalidJFSSignatureError extends Error {}
12+
13+
export type JSONFarcasterSignatureHeader = {
14+
fid: number;
15+
type: "custody" | "app_key";
16+
key: `0x${string}`;
17+
};
18+
19+
export type JSONFarcasterSignatureEncoded = {
20+
header: string;
21+
payload: string;
22+
signature: string;
23+
};
24+
25+
/**
26+
* Generates account association payload that must be signed using encodeJSONFarcasterSignature
27+
*
28+
* @example
29+
* ```ts
30+
* const signature = await sign({
31+
* fid: 1,
32+
* signer: {
33+
* type: "custody",
34+
* custodyAddress: "0x1234567890abcdef1234567890abcdef12345678"
35+
* },
36+
* payload: constructJSONFarcasterSignatureAccountAssociationPaylod("example.com"),
37+
* signMessage: async (message) => {
38+
* return account.signMessage({ message });
39+
* },
40+
* });
41+
* ```
42+
*/
43+
export function constructJSONFarcasterSignatureAccountAssociationPaylod(
44+
/**
45+
* The domain must:
46+
* - match the domain the manifest is being served from
47+
* - be without protocol (http/https)
48+
*/
49+
domain: string
50+
): JsonObject {
51+
return {
52+
domain,
53+
};
54+
}
55+
56+
type GenerateJSONFarcasterSignatureInput = {
57+
fid: number;
58+
signer: JSONFarcasterSignatureSigner;
59+
payload: JsonObject;
60+
signMessage: (message: string) => Promise<`0x${string}`>;
61+
};
62+
63+
/**
64+
* Encodes JSON Farcaster signature
65+
*
66+
* @example
67+
* ```ts
68+
* const signature = await sign({
69+
* fid: 1,
70+
* signer: {
71+
* type: "custody",
72+
* custodyAddress: "0x1234567890abcdef1234567890abcdef12345678"
73+
* },
74+
* payload: {
75+
* domain: "example.com"
76+
* },
77+
* signMessage: async (message) => {
78+
* return account.signMessage({ message });
79+
* },
80+
* });
81+
* ```
82+
*/
83+
export async function sign(
84+
input: GenerateJSONFarcasterSignatureInput
85+
): Promise<{
86+
compact: string;
87+
json: JSONFarcasterSignatureEncoded;
88+
}> {
89+
const encodedHeader = encodeHeader(input.fid, input.signer);
90+
const encodedPayload = encodePayload(input.payload);
91+
const signature = await input.signMessage(
92+
`${encodedHeader}.${encodedPayload}`
93+
);
94+
const encodedSignature = encodeSignature(signature);
95+
96+
return {
97+
compact: `${encodedHeader}.${encodedPayload}.${encodedSignature}`,
98+
json: {
99+
header: encodedHeader,
100+
payload: encodedPayload,
101+
signature: encodedSignature,
102+
},
103+
};
104+
}
105+
106+
/**
107+
* Verifies compact JSON Farcaster Signature
108+
*
109+
* @example
110+
* ```ts
111+
* const isValid = await verifyCompact("compact json farcaster signature");
112+
* ```
113+
*/
114+
export async function verifyCompact(
115+
compactSignature: string
116+
): Promise<boolean> {
117+
const [encodedHeader, encodedPayload, encodedSignature] =
118+
compactSignature.split(".");
119+
120+
if (!encodedHeader || !encodedPayload || !encodedSignature) {
121+
throw new InvalidJFSCompactSignatureError();
122+
}
123+
124+
return verify({
125+
header: encodedHeader,
126+
payload: encodedPayload,
127+
signature: encodedSignature,
128+
});
129+
}
130+
131+
/**
132+
* Verifies JSON Farcaster Signature
133+
*
134+
* @example
135+
* ```ts
136+
* const isValid = await verify({
137+
* header: "encoded header",
138+
* payload: "encoded payload",
139+
* signature: "encoded signature",
140+
* });
141+
* ```
142+
*/
143+
export async function verify(
144+
signatureObject: JSONFarcasterSignatureEncoded
145+
): Promise<boolean> {
146+
const decodedHeader = decodeHeader(signatureObject.header);
147+
148+
if (decodedHeader.type !== "custody") {
149+
throw new InvalidJFSHeaderError("Only custody signatures are supported");
150+
}
151+
152+
const signature = decodeSignature(signatureObject.signature);
153+
const publicClient = createPublicClient({
154+
chain: optimism,
155+
transport: http(),
156+
});
157+
const messageIsValid = await publicClient.verifyMessage({
158+
address: decodedHeader.key,
159+
signature,
160+
message: `${signatureObject.header}.${signatureObject.payload}`,
161+
});
162+
163+
if (!messageIsValid) {
164+
return false;
165+
}
166+
167+
const custodyAddressOfFid = await publicClient.readContract({
168+
abi: parseAbi([
169+
"function custodyOf(uint256) public view returns (address)",
170+
"function idOf(address) public view returns (uint256)",
171+
]),
172+
address: "0x00000000Fc6c5F01Fc30151999387Bb99A9f489b",
173+
functionName: "custodyOf",
174+
args: [BigInt(decodedHeader.fid)],
175+
});
176+
177+
return custodyAddressOfFid === decodedHeader.key;
178+
}
179+
180+
type JSONFarcasterSignatureSigner =
181+
| {
182+
type: "custody";
183+
custodyAddress: `0x${string}`;
184+
}
185+
| {
186+
type: "app_key";
187+
appKey: string;
188+
};
189+
190+
export function encodeHeader(
191+
fid: number,
192+
signer: JSONFarcasterSignatureSigner
193+
): string {
194+
return Buffer.from(
195+
JSON.stringify({
196+
fid,
197+
type: signer.type,
198+
key: signer.type === "custody" ? signer.custodyAddress : signer.appKey,
199+
}),
200+
"utf-8"
201+
).toString("base64url");
202+
}
203+
204+
export function decodeHeader(
205+
encodedHeader: string
206+
): JSONFarcasterSignatureHeader {
207+
try {
208+
const decodedHeader = Buffer.from(encodedHeader, "base64url").toString(
209+
"utf-8"
210+
);
211+
const value: unknown = JSON.parse(decodedHeader);
212+
const header: JSONFarcasterSignatureHeader = {
213+
fid: 0,
214+
type: "custody",
215+
key: "0x",
216+
};
217+
218+
if (typeof value !== "object") {
219+
throw new InvalidJFSHeaderError();
220+
}
221+
222+
if (value === null) {
223+
throw new InvalidJFSHeaderError();
224+
}
225+
226+
if ("fid" in value && typeof value.fid === "number" && value.fid > 0) {
227+
header.fid = value.fid;
228+
} else {
229+
throw new InvalidJFSHeaderError();
230+
}
231+
232+
if (
233+
"type" in value &&
234+
typeof value.type === "string" &&
235+
["custody", "app_key"].includes(value.type)
236+
) {
237+
header.type = value.type as JSONFarcasterSignatureHeader["type"];
238+
} else {
239+
throw new InvalidJFSHeaderError();
240+
}
241+
242+
if (
243+
"key" in value &&
244+
typeof value.key === "string" &&
245+
value.key.startsWith("0x") &&
246+
value.key.length > 2
247+
) {
248+
header.key = value.key as JSONFarcasterSignatureHeader["key"];
249+
} else {
250+
throw new InvalidJFSHeaderError();
251+
}
252+
253+
return header;
254+
} catch (e) {
255+
if (e instanceof InvalidJFSHeaderError) {
256+
throw e;
257+
}
258+
259+
throw new InvalidJFSHeaderError();
260+
}
261+
}
262+
263+
export function encodePayload(data: JsonObject): string {
264+
return Buffer.from(JSON.stringify(data), "utf-8").toString("base64url");
265+
}
266+
267+
export function decodePayload(encodedPayload: string): JsonObject {
268+
try {
269+
const decodedPayload = Buffer.from(encodedPayload, "base64url").toString(
270+
"utf-8"
271+
);
272+
const value: unknown = JSON.parse(decodedPayload);
273+
274+
if (typeof value !== "object") {
275+
throw new InvalidJFSPayloadError();
276+
}
277+
278+
if (value === null) {
279+
throw new InvalidJFSPayloadError();
280+
}
281+
282+
return value as JsonObject;
283+
} catch (e) {
284+
if (e instanceof InvalidJFSPayloadError) {
285+
throw e;
286+
}
287+
288+
throw new InvalidJFSPayloadError();
289+
}
290+
}
291+
292+
export function encodeSignature(signature: `0x${string}`): string {
293+
return Buffer.from(signature, "utf-8").toString("base64url");
294+
}
295+
296+
export function decodeSignature(signature: string): `0x${string}` {
297+
try {
298+
const signatureHash = Buffer.from(signature, "base64url").toString("utf-8");
299+
300+
if (!signatureHash.startsWith("0x")) {
301+
throw new InvalidJFSSignatureError();
302+
}
303+
304+
return signatureHash as `0x${string}`;
305+
} catch (e) {
306+
if (e instanceof InvalidJFSSignatureError) {
307+
throw e;
308+
}
309+
310+
throw new InvalidJFSSignatureError();
311+
}
312+
}

0 commit comments

Comments
 (0)