Skip to content

Commit d961642

Browse files
feat: support app key signatures
1 parent 33ad044 commit d961642

11 files changed

Lines changed: 556 additions & 125 deletions

File tree

.changeset/rich-bags-begin.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"frames.js": patch
3+
"@frames.js/debugger": patch
4+
---
5+
6+
feat: support for app key signatures

packages/frames.js/package.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,16 @@
187187
"default": "./dist/farcaster-v2/types.cjs"
188188
}
189189
},
190+
"./farcaster-v2/verify-app-key-with-neynar": {
191+
"import": {
192+
"types": "./dist/farcaster-v2/verify-app-key-with-neynar.d.ts",
193+
"default": "./dist/farcaster-v2/verify-app-key-with-neynar.js"
194+
},
195+
"require": {
196+
"types": "./dist/farcaster-v2/verify-app-key-with-neynar.d.cts",
197+
"default": "./dist/farcaster-v2/verify-app-key-with-neynar.cjs"
198+
}
199+
},
190200
"./core": {
191201
"import": {
192202
"types": "./dist/core/index.d.ts",
@@ -420,12 +430,14 @@
420430
},
421431
"dependencies": {
422432
"@farcaster/frame-core": "^0.0.24",
423-
"@farcaster/frame-node": "^0.0.13",
433+
"@noble/ed25519": "^2.2.3",
434+
"@noble/hashes": "^1.7.1",
424435
"@vercel/og": "^0.6.3",
425436
"cheerio": "^1.0.0-rc.12",
437+
"ox": "^0.4.4",
426438
"protobufjs": "^7.2.6",
427-
"viem": "^2.7.8",
428439
"type-fest": "^4.28.1",
440+
"viem": "^2.7.8",
429441
"zod": "^3.24.1"
430442
}
431443
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { sha512 } from "@noble/hashes/sha512";
2+
import { etc, getPublicKey, sign, verify } from "@noble/ed25519";
3+
4+
if (!etc.sha512Sync) {
5+
etc.sha512Sync = (...m: Uint8Array[]) => sha512(etc.concatBytes(...m));
6+
}
7+
8+
export { getPublicKey, sign, verify };

packages/frames.js/src/farcaster-v2/events.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@ import type {
33
EncodedJsonFarcasterSignatureSchema,
44
} from "@farcaster/frame-core";
55
import { serverEventSchema } from "@farcaster/frame-core";
6-
import {
7-
createJsonFarcasterSignature,
8-
hexToBytes,
9-
} from "@farcaster/frame-node";
10-
import type { Hex } from "viem";
6+
import { bytesToHex, type Hex } from "viem";
7+
import { sign, signMessageWithAppKey } from "./json-signature";
8+
import { getPublicKey } from "./es25519";
119

1210
export class InvalidWebhookResponseError extends Error {
1311
constructor(
@@ -22,7 +20,7 @@ export type { FrameServerEvent };
2220

2321
type SendEventOptions = {
2422
/**
25-
* App private key
23+
* Private app key (signer private key)
2624
*/
2725
privateKey: Hex | Uint8Array;
2826
fid: number;
@@ -36,13 +34,16 @@ export async function sendEvent(
3634
event: FrameServerEvent,
3735
{ privateKey, fid, webhookUrl }: SendEventOptions
3836
): Promise<void> {
37+
const appKey = bytesToHex(getPublicKey(privateKey));
3938
const payload = serverEventSchema.parse(event);
40-
const signature = createJsonFarcasterSignature({
39+
const signature = await sign({
4140
fid,
42-
payload: Buffer.from(JSON.stringify(payload)),
43-
privateKey:
44-
typeof privateKey === "string" ? hexToBytes(privateKey) : privateKey,
45-
type: "app_key",
41+
payload,
42+
signer: {
43+
type: "app_key",
44+
appKey,
45+
},
46+
signMessage: signMessageWithAppKey(privateKey),
4647
});
4748

4849
const response = await fetch(webhookUrl, {
@@ -52,11 +53,11 @@ export async function sendEvent(
5253
"Content-Type": "application/json",
5354
},
5455
body: JSON.stringify(
55-
signature satisfies EncodedJsonFarcasterSignatureSchema
56+
signature.json satisfies EncodedJsonFarcasterSignatureSchema
5657
),
5758
});
5859

59-
if (response.status >= 200 && response.status < 300) {
60+
if (response.ok) {
6061
return;
6162
}
6263

packages/frames.js/src/farcaster-v2/json-signature.test.ts

Lines changed: 196 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
/* eslint-disable @typescript-eslint/no-unsafe-assignment -- for expect.any() */
2+
import * as ed25519 from "@noble/ed25519";
3+
import { sha512 } from "@noble/hashes/sha512";
4+
import type { Hex } from "viem";
5+
import { bytesToHex, hexToBytes } from "viem";
26
import {
37
sign,
48
verify,
@@ -8,10 +12,16 @@ import {
812
encodeSignature,
913
decodeHeader,
1014
decodePayload,
11-
decodeSignature,
15+
decodeCustodyTypeSignature,
16+
decodeAppKeyTypeSignature,
1217
constructJSONFarcasterSignatureAccountAssociationPaylod,
18+
signMessageWithAppKey,
1319
} from "./json-signature";
1420

21+
process.env.NEYNAR_API_KEY = "NEYNAR_FRAMES_JS";
22+
23+
ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m));
24+
1525
const fcDemoSignature = {
1626
header:
1727
"eyJmaWQiOjM2MjEsInR5cGUiOiJjdXN0b2R5Iiwia2V5IjoiMHgyY2Q4NWEwOTMyNjFmNTkyNzA4MDRBNkVBNjk3Q2VBNENlQkVjYWZFIn0",
@@ -33,58 +43,144 @@ const framesJsDemoSignature = {
3343
const framesJsDemoCompactSignature =
3444
"eyJmaWQiOjM0MTc5NCwidHlwZSI6ImN1c3RvZHkiLCJrZXkiOiIweDc4Mzk3RDlEMTg1RDNhNTdEMDEyMTNDQmUzRWMxRWJBQzNFRWM3N2QifQ.eyJkb21haW4iOiJmcmFtZXNqcy5vcmcifQ.MHgwOWExNWMyZDQ3ZDk0NTM5NWJjYTJlNGQzNDg3MzYxMGUyNGZiMDFjMzc0NTUzYTJmOTM2NjM3YjU4YTA5NzdjNzAxOWZiYzljNGUxY2U5ZmJjOGMzNWVjYTllNzViMTM5Zjg3ZGQyNTBlMzhkMjBmM2YyZmEyNDk2MDQ1NGExMjFi";
3545

36-
const signatures = [framesJsDemoSignature, fcDemoSignature];
46+
const custodySignatures = [framesJsDemoSignature, fcDemoSignature];
3747

38-
const compactSignatures = [
48+
const dummyAppKeySignature = {
49+
header:
50+
"eyJmaWQiOjM0MTc5NCwidHlwZSI6ImFwcF9rZXkiLCJrZXkiOiIweGJkZGVhNDQ2ODUxZDYwZjQ4OTAxNjU1NDc4YTIwNTQ3MmNjOTJmNGUwMzdiNTIzNmE1YzVhYmZjMWI4ZTA5MWIifQ",
51+
payload: "eyJ0ZXN0Ijp0cnVlfQ",
52+
signature:
53+
"Y1C9-m6EIAPDqd8-2NrSXBKrpvWKUfA3Qjy865De5yUu7MV_b1TjsQKtwqbaVv_UzFz5ghmvygVbGjhx-kbRDw",
54+
};
55+
56+
const dummyAppKeyCompactSignature = `${dummyAppKeySignature.header}.${dummyAppKeySignature.payload}.${dummyAppKeySignature.signature}`;
57+
const appKeySignatures = [dummyAppKeySignature];
58+
59+
const custodyCompactSignatures = [
3960
framesJsDemoCompactSignature,
4061
fcDemoCompactSignature,
4162
];
63+
const appKeyCompactSignatures = [dummyAppKeyCompactSignature];
4264

4365
describe("verifyCompact", () => {
44-
it.each(compactSignatures)("verifies valid message", async (signature) => {
45-
await expect(verifyCompact(signature)).resolves.toBe(true);
66+
describe("custody", () => {
67+
it.each(custodyCompactSignatures)(
68+
"verifies valid message",
69+
async (signature) => {
70+
await expect(verifyCompact(signature)).resolves.toBe(true);
71+
}
72+
);
73+
});
74+
75+
describe("app_key", () => {
76+
it.each(appKeyCompactSignatures)(
77+
"verifies valid message",
78+
async (signature) => {
79+
await expect(verifyCompact(signature)).resolves.toBe(true);
80+
}
81+
);
4682
});
4783
});
4884

4985
describe("verify", () => {
50-
it.each(signatures)("verifies valid message", async (signature) => {
51-
await expect(verify(signature)).resolves.toBe(true);
86+
describe("custody", () => {
87+
it.each(custodySignatures)("verifies valid message", async (signature) => {
88+
await expect(verify(signature)).resolves.toBe(true);
89+
});
90+
});
91+
92+
describe("app_key", () => {
93+
it.each(appKeySignatures)("verifies valid message", async (signature) => {
94+
await expect(verify(signature)).resolves.toBe(true);
95+
});
5296
});
5397
});
5498

5599
describe("sign", () => {
56-
it("signs any payload", async () => {
57-
const signature = await sign({
58-
fid: 1,
59-
payload: { test: true },
60-
signer: {
61-
type: "custody",
62-
custodyAddress: "0x1234567890abcdef1234567890abcdef12345678",
63-
},
64-
signMessage: (message) => {
65-
expect(typeof message === "string").toBe(true);
66-
expect(message.length).toBeGreaterThan(0);
100+
describe("custody", () => {
101+
it("signs any payload", async () => {
102+
const signature = await sign({
103+
fid: 1,
104+
payload: { test: true },
105+
signer: {
106+
type: "custody",
107+
custodyAddress: "0x1234567890abcdef1234567890abcdef12345678",
108+
},
109+
signMessage: (message) => {
110+
expect(typeof message === "string").toBe(true);
111+
expect(message.length).toBeGreaterThan(0);
67112

68-
return Promise.resolve("0x0000000");
69-
},
70-
});
113+
return Promise.resolve("0x0000000");
114+
},
115+
});
71116

72-
expect(signature).toMatchObject({
73-
compact: expect.any(String),
74-
json: {
75-
header: expect.any(String),
76-
payload: expect.any(String),
77-
signature: expect.any(String),
78-
},
117+
expect(signature).toMatchObject({
118+
compact: expect.any(String),
119+
json: {
120+
header: expect.any(String),
121+
payload: expect.any(String),
122+
signature: expect.any(String),
123+
},
124+
});
125+
126+
expect(decodePayload(signature.json.payload)).toEqual({ test: true });
127+
expect(decodeHeader(signature.json.header)).toEqual({
128+
fid: 1,
129+
type: "custody",
130+
key: "0x1234567890abcdef1234567890abcdef12345678",
131+
});
132+
expect(decodeCustodyTypeSignature(signature.json.signature)).toEqual(
133+
"0x0000000"
134+
);
79135
});
136+
});
80137

81-
expect(decodePayload(signature.json.payload)).toEqual({ test: true });
82-
expect(decodeHeader(signature.json.header)).toEqual({
83-
fid: 1,
84-
type: "custody",
85-
key: "0x1234567890abcdef1234567890abcdef12345678",
138+
describe("app_key", () => {
139+
it("signs any payload", async () => {
140+
const privateKey = ed25519.utils.randomPrivateKey();
141+
let messageSignature: Hex = "0x";
142+
const signature = await sign({
143+
fid: 1,
144+
payload: { test: true },
145+
signer: {
146+
type: "app_key",
147+
appKey: bytesToHex(ed25519.getPublicKey(privateKey)),
148+
},
149+
signMessage: (message) => {
150+
expect(typeof message === "string").toBe(true);
151+
expect(message.length).toBeGreaterThan(0);
152+
153+
messageSignature = bytesToHex(
154+
ed25519.sign(Buffer.from(message, "utf-8"), privateKey)
155+
);
156+
157+
return Promise.resolve(messageSignature);
158+
},
159+
});
160+
161+
expect(signature).toMatchObject({
162+
compact: expect.any(String),
163+
json: {
164+
header: expect.any(String),
165+
payload: expect.any(String),
166+
signature: expect.any(String),
167+
},
168+
});
169+
170+
expect(Buffer.from(signature.json.signature, "base64url")).toHaveProperty(
171+
"byteLength",
172+
64
173+
);
174+
expect(decodePayload(signature.json.payload)).toEqual({ test: true });
175+
expect(decodeHeader(signature.json.header)).toEqual({
176+
fid: 1,
177+
type: "app_key",
178+
key: bytesToHex(ed25519.getPublicKey(privateKey)),
179+
});
180+
expect(decodeAppKeyTypeSignature(signature.json.signature)).toEqual(
181+
messageSignature
182+
);
86183
});
87-
expect(decodeSignature(signature.json.signature)).toEqual("0x0000000");
88184
});
89185
});
90186

@@ -140,17 +236,77 @@ describe("decodePayload", () => {
140236

141237
describe("encodeSignature", () => {
142238
it("encodes signature", () => {
143-
const value = encodeSignature("0x0000000");
239+
const input = "0x0000000";
240+
const value = encodeSignature(Buffer.from("0x0000000", "utf-8"));
144241

145242
expect(typeof value).toBe("string");
243+
expect(Buffer.from(input, "utf-8").toString("base64url")).toEqual(value);
244+
});
245+
246+
it("encodes signature as Buffer", () => {
247+
const input = hexToBytes("0x0000001");
248+
const value = encodeSignature(Buffer.from(input));
249+
250+
expect(Buffer.from(input).toString("base64url")).toEqual(value);
251+
});
252+
});
253+
254+
describe("decodeAppKeyTypeSignature", () => {
255+
it("decodes signature (string)", () => {
256+
const buf = Buffer.from("0x0000000", "utf-8");
257+
const encodedSignature = encodeSignature(buf);
258+
const value = decodeAppKeyTypeSignature(encodedSignature);
259+
260+
expect(value).toBe(bytesToHex(buf));
261+
});
262+
263+
it("decodes signature (from buffer)", () => {
264+
const input = hexToBytes("0x0000001");
265+
const encodedSignature = encodeSignature(Buffer.from(input));
266+
const value = decodeAppKeyTypeSignature(encodedSignature);
267+
268+
expect(value).toBe(bytesToHex(input));
269+
});
270+
});
271+
272+
describe("decodeCustodyTypeSignature", () => {
273+
it("decodes signature (string)", () => {
274+
const buf = Buffer.from("0x0000000", "utf-8");
275+
const encodedSignature = encodeSignature(buf);
276+
const value = decodeCustodyTypeSignature(encodedSignature);
277+
278+
expect(value).toBe(buf.toString("utf-8"));
279+
});
280+
281+
it("decodes signature (from buffer)", () => {
282+
const input = "0x0000001";
283+
const encodedSignature = encodeSignature(Buffer.from(input, "utf-8"));
284+
const value = decodeCustodyTypeSignature(encodedSignature);
285+
286+
expect(value).toBe(input);
146287
});
147288
});
148289

149-
describe("decodeSignature", () => {
150-
it("decodes signature", () => {
151-
const encodedSignature = encodeSignature("0x0000000");
152-
const value = decodeSignature(encodedSignature);
290+
describe("signMessageWithAppKey", () => {
291+
it("signs any payload", async () => {
292+
const privateKey = ed25519.utils.randomPrivateKey();
293+
const signature = await sign({
294+
fid: 1,
295+
payload: { test: true },
296+
signer: {
297+
type: "app_key",
298+
appKey: bytesToHex(ed25519.getPublicKey(privateKey)),
299+
},
300+
signMessage: signMessageWithAppKey(privateKey),
301+
});
153302

154-
expect(value).toBe("0x0000000");
303+
expect(signature).toMatchObject({
304+
compact: expect.any(String),
305+
json: {
306+
header: expect.any(String),
307+
payload: expect.any(String),
308+
signature: expect.any(String),
309+
},
310+
});
155311
});
156312
});

0 commit comments

Comments
 (0)