diff --git a/.changeset/blue-wasps-hunt.md b/.changeset/blue-wasps-hunt.md new file mode 100644 index 000000000..5b25c2e42 --- /dev/null +++ b/.changeset/blue-wasps-hunt.md @@ -0,0 +1,6 @@ +--- +"@xmtp/frames-client": major +"@xmtp/frames-validator": major +--- + +Add V3 support to Frames client and validator diff --git a/packages/frames-client/package.json b/packages/frames-client/package.json index 71b95bb45..a1096a3e5 100644 --- a/packages/frames-client/package.json +++ b/packages/frames-client/package.json @@ -63,15 +63,17 @@ "dependencies": { "@noble/hashes": "^1.4.0", "@open-frames/proxy-client": "^0.3.3", - "@xmtp/proto": "^3.72.0", + "@xmtp/proto": "^3.72.3", "long": "^5.2.3" }, "devDependencies": { "@open-frames/types": "^0.1.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.1", + "@xmtp/node-sdk": "^0.0.27", "@xmtp/xmtp-js": "^12.0.0", "ethers": "^6.13.1", + "fast-glob": "^3.3.2", "rollup": "^4.27.3", "rollup-plugin-dts": "^6.1.1", "rollup-plugin-filesize": "^10.0.0", @@ -79,6 +81,7 @@ "tsconfig": "workspace:*", "typedoc": "^0.26.11", "typescript": "^5.6.3", + "uint8array-extras": "^1.4.0", "vite": "^5.4.11", "vite-tsconfig-paths": "^5.1.2", "vitest": "^2.1.3" diff --git a/packages/frames-client/src/client.test.ts b/packages/frames-client/src/client.test.ts new file mode 100644 index 000000000..6c921375f --- /dev/null +++ b/packages/frames-client/src/client.test.ts @@ -0,0 +1,235 @@ +import { getRandomValues } from "node:crypto"; +import { sha256 } from "@noble/hashes/sha256"; +import { Client as V3Client } from "@xmtp/node-sdk"; +import { fetcher, frames } from "@xmtp/proto"; +import { Client, Signature, SignedPublicKey } from "@xmtp/xmtp-js"; +import { getBytes, Wallet } from "ethers"; +import { uint8ArrayToHex } from "uint8array-extras"; +import { describe, expect, it } from "vitest"; +import { FramesClient } from "./client"; +import { + isV3FramesSigner, + type FramesSigner, + type V2FramesSigner, + type V3FramesSigner, +} from "./types"; + +const { b64Decode } = fetcher; + +const getV2Setup = async () => { + const client = await Client.create(Wallet.createRandom(), { env: "local" }); + const signer: V2FramesSigner = { + address: () => client.address, + getPublicKeyBundle: () => client.keystore.getPublicKeyBundle(), + sign: (digest: Uint8Array) => + client.keystore.signDigest({ + digest, + identityKey: true, + prekeyIndex: undefined, + }), + }; + const framesClient = new FramesClient(signer); + return { signer, framesClient }; +}; + +const getV3Setup = async () => { + const encryptionKey = getRandomValues(new Uint8Array(32)); + const wallet = Wallet.createRandom(); + const client = await V3Client.create( + { + getAddress: () => wallet.address, + signMessage: async (message: string) => + getBytes(await wallet.signMessage(message)), + }, + encryptionKey, + { env: "local" }, + ); + const signer: V3FramesSigner = { + address: () => client.accountAddress, + installationId: () => client.installationIdBytes, + inboxId: () => client.inboxId, + sign: (digest: Uint8Array) => + client.signWithInstallationKey(uint8ArrayToHex(digest)), + }; + const framesClient = new FramesClient(signer); + return { signer, framesClient }; +}; + +const shouldSignFrameActionWithValidSignature = + (signer: FramesSigner, framesClient: FramesClient) => async () => { + const frameUrl = "https://example.com"; + const buttonIndex = 1; + + const signedPayload = await framesClient.signFrameAction({ + frameUrl, + buttonIndex, + conversationTopic: "foo", + participantAccountAddresses: ["amal", "bola"], + state: "state", + address: "0x...", + transactionId: "123", + }); + + // Below addresses are typically the same but can technically be different + // walletAddress references address of XMTP client + expect(signedPayload.untrustedData.walletAddress).toEqual( + await signer.address(), + ); + + // address references the address associated with initiating a transaction + expect(signedPayload.untrustedData.address).toEqual("0x..."); + expect(signedPayload.untrustedData.transactionId).toEqual("123"); + + expect(signedPayload.untrustedData.url).toEqual(frameUrl); + expect(signedPayload.untrustedData.buttonIndex).toEqual(buttonIndex); + expect( + signedPayload.untrustedData.opaqueConversationIdentifier, + ).toBeDefined(); + expect(signedPayload.untrustedData.timestamp).toBeGreaterThan(0); + + const signedPayloadProto = frames.FrameAction.decode( + b64Decode(signedPayload.trustedData.messageBytes), + ); + expect(signedPayloadProto.actionBody).toBeDefined(); + + if (isV3FramesSigner(signer)) { + expect(signedPayloadProto.signature).toBeUndefined(); + expect(signedPayloadProto.signedPublicKeyBundle).toBeUndefined(); + } else { + expect(signedPayloadProto.signature).toBeDefined(); + expect(signedPayloadProto.signedPublicKeyBundle).toBeDefined(); + + if ( + !signedPayloadProto.signature || + !signedPayloadProto.signedPublicKeyBundle?.identityKey + ) { + throw new Error("Missing signature"); + } + + const signatureInstance = new Signature(signedPayloadProto.signature); + const digest = sha256(signedPayloadProto.actionBody); + // Ensure the signature is valid + expect( + signatureInstance + .getPublicKey(digest) + ?.equals( + new SignedPublicKey( + signedPayloadProto.signedPublicKeyBundle.identityKey, + ).toLegacyKey(), + ), + ).toBe(true); + } + + const signedPayloadBody = frames.FrameActionBody.decode( + signedPayloadProto.actionBody, + ); + + expect(signedPayloadBody.buttonIndex).toEqual(buttonIndex); + expect(signedPayloadBody.frameUrl).toEqual(frameUrl); + expect(signedPayloadBody.opaqueConversationIdentifier).toBeDefined(); + expect(signedPayloadBody.state).toEqual("state"); + }; + +// Will add E2E tests back once we have Frames deployed with the new schema +const worksE2E = (framesClient: FramesClient) => async () => { + const frameUrl = + "https://fc-polls-five.vercel.app/polls/01032f47-e976-42ee-9e3d-3aac1324f4b8"; + const metadata = await framesClient.proxy.readMetadata(frameUrl); + expect(metadata).toBeDefined(); + expect(metadata.frameInfo).toMatchObject({ + acceptedClients: { + farcaster: "vNext", + }, + buttons: { + "1": { + label: "Yes", + }, + "2": { + label: "No", + }, + }, + image: { + content: + "https://fc-polls-five.vercel.app/api/image?id=01032f47-e976-42ee-9e3d-3aac1324f4b8", + }, + postUrl: + "https://fc-polls-five.vercel.app/api/vote?id=01032f47-e976-42ee-9e3d-3aac1324f4b8", + }); + const signedPayload = await framesClient.signFrameAction({ + frameUrl, + buttonIndex: 1, + conversationTopic: "foo", + participantAccountAddresses: ["amal", "bola"], + }); + const postUrl = metadata.extractedTags["fc:frame:post_url"]; + const response = await framesClient.proxy.post(postUrl, signedPayload); + expect(response).toBeDefined(); + expect(response.extractedTags["fc:frame"]).toEqual("vNext"); + + const imageUrl = response.extractedTags["fc:frame:image"]; + const mediaUrl = framesClient.proxy.mediaUrl(imageUrl); + + const downloadedMedia = await fetch(mediaUrl); + expect(downloadedMedia.ok).toBeTruthy(); + expect(downloadedMedia.headers.get("content-type")).toEqual("image/png"); +}; + +const sendsBackButtonPostUrl = (framesClient: FramesClient) => async () => { + const frameUrl = + "https://tx-boilerplate-frame-git-main-xmtp-labs.vercel.app/"; + const metadata = await framesClient.proxy.readMetadata(frameUrl); + expect(metadata).toBeDefined(); + expect(metadata.frameInfo).toMatchObject({ + acceptedClients: { + xmtp: "2024-02-09", + farcaster: "vNext", + }, + buttons: { + "1": { + label: "Make transaction", + action: "tx", + target: + "https://tx-boilerplate-frame-git-main-xmtp-labs.vercel.app/api/transaction", + postUrl: + "https://tx-boilerplate-frame-git-main-xmtp-labs.vercel.app/api/transaction-success", + }, + }, + image: { + content: + "https://tx-boilerplate-frame-git-main-xmtp-labs.vercel.app/api/og?transaction=null", + }, + }); +}; + +describe("FramesClient", () => { + describe.concurrent("signFrameAction", () => { + describe("V2", () => { + it("should sign a frame action with a valid signature", async () => { + const { signer, framesClient } = await getV2Setup(); + await shouldSignFrameActionWithValidSignature(signer, framesClient)(); + }); + + it("works e2e", async () => { + const { framesClient } = await getV2Setup(); + await worksE2E(framesClient)(); + }); + + it("sends back the button postUrl for a tx frame in frame info", async () => { + const { framesClient } = await getV2Setup(); + await sendsBackButtonPostUrl(framesClient)(); + }); + }); + + describe("V3", () => { + it("should sign a frame action with a valid signature", async () => { + const { signer, framesClient } = await getV3Setup(); + await shouldSignFrameActionWithValidSignature(signer, framesClient)(); + }); + + it("sends back the button postUrl for a tx frame in frame info", async () => { + const { framesClient } = await getV3Setup(); + await sendsBackButtonPostUrl(framesClient)(); + }); + }); + }); +}); diff --git a/packages/frames-client/src/client.ts b/packages/frames-client/src/client.ts index f1ce4929e..8ba6ff2a8 100644 --- a/packages/frames-client/src/client.ts +++ b/packages/frames-client/src/client.ts @@ -1,33 +1,28 @@ import { sha256 } from "@noble/hashes/sha256"; -import { - frames, - publicKey as publicKeyProto, - signature as signatureProto, -} from "@xmtp/proto"; -import type { Client } from "@xmtp/xmtp-js"; +import { frames } from "@xmtp/proto"; import Long from "long"; import { PROTOCOL_VERSION } from "./constants"; import { v1ToV2Bundle } from "./converters"; import OpenFramesProxy from "./proxy"; -import type { - FrameActionInputs, - FramePostPayload, - ReactNativeClient, -} from "./types"; import { - base64Encode, - buildOpaqueIdentifier, - isReactNativeClient, -} from "./utils"; + isV3FramesSigner, + type FrameActionInputs, + type FramePostPayload, + type FramesSigner, +} from "./types"; +import { base64Encode, buildOpaqueIdentifier } from "./utils"; export class FramesClient { - xmtpClient: Client | ReactNativeClient; + #proxy: OpenFramesProxy; + #signer: FramesSigner; - proxy: OpenFramesProxy; + constructor(signer: FramesSigner, proxy?: OpenFramesProxy) { + this.#signer = signer; + this.#proxy = proxy || new OpenFramesProxy(); + } - constructor(xmtpClient: Client | ReactNativeClient, proxy?: OpenFramesProxy) { - this.xmtpClient = xmtpClient; - this.proxy = proxy || new OpenFramesProxy(); + get proxy() { + return this.#proxy; } async signFrameAction(inputs: FrameActionInputs): Promise { @@ -55,7 +50,7 @@ export class FramesClient { untrustedData: { buttonIndex, opaqueConversationIdentifier, - walletAddress: this.xmtpClient.address, + walletAddress: await this.#signer.address(), inputText, url: frameUrl, timestamp: now, @@ -77,40 +72,31 @@ export class FramesClient { const actionBody = frames.FrameActionBody.encode(actionBodyInputs).finish(); const digest = sha256(actionBody); - const signature = await this.signDigest(digest); - - const publicKeyBundle = await this.getPublicKeyBundle(); - - return frames.FrameAction.encode({ - actionBody, - signature, - signedPublicKeyBundle: v1ToV2Bundle(publicKeyBundle), - }).finish(); - } - - private async signDigest( - digest: Uint8Array, - ): Promise { - if (isReactNativeClient(this.xmtpClient)) { - const signatureBytes = await this.xmtpClient.sign(digest, { - kind: "identity", - }); - return signatureProto.Signature.decode(signatureBytes); - } - - return this.xmtpClient.keystore.signDigest({ - digest, - identityKey: true, - prekeyIndex: undefined, - }); - } + let payload: frames.FrameAction; - private async getPublicKeyBundle(): Promise { - if (isReactNativeClient(this.xmtpClient)) { - const bundleBytes = await this.xmtpClient.exportPublicKeyBundle(); - return publicKeyProto.PublicKeyBundle.decode(bundleBytes); + if (isV3FramesSigner(this.#signer)) { + const signature = await this.#signer.sign(digest); + payload = { + actionBody, + inboxId: await this.#signer.inboxId(), + installationId: await this.#signer.installationId(), + installationSignature: signature, + signature: undefined, + signedPublicKeyBundle: undefined, + }; + } else { + const signature = await this.#signer.sign(digest); + const publicKeyBundle = await this.#signer.getPublicKeyBundle(); + payload = { + actionBody, + inboxId: "", + installationId: new Uint8Array(), + installationSignature: new Uint8Array(), + signature, + signedPublicKeyBundle: v1ToV2Bundle(publicKeyBundle), + }; } - return this.xmtpClient.keystore.getPublicKeyBundle(); + return frames.FrameAction.encode(payload).finish(); } } diff --git a/packages/frames-client/src/index.test.ts b/packages/frames-client/src/index.test.ts deleted file mode 100644 index 73e2a44a8..000000000 --- a/packages/frames-client/src/index.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { sha256 } from "@noble/hashes/sha256"; -import { fetcher, frames } from "@xmtp/proto"; -import { Client, Signature, SignedPublicKey } from "@xmtp/xmtp-js"; -import { Wallet } from "ethers"; -import { beforeEach, describe, expect, it } from "vitest"; -import { FramesClient } from "./client"; - -const { b64Decode } = fetcher; - -describe("signFrameAction", () => { - let client: Client; - let framesClient: FramesClient; - beforeEach(async () => { - client = await Client.create(Wallet.createRandom()); - framesClient = new FramesClient(client); - }); - it("should sign a frame action with a valid signature", async () => { - const frameUrl = "https://example.com"; - const buttonIndex = 1; - - const signedPayload = await framesClient.signFrameAction({ - frameUrl, - buttonIndex, - conversationTopic: "foo", - participantAccountAddresses: ["amal", "bola"], - state: "state", - address: "0x...", - transactionId: "123", - }); - - // Below addresses are typically the same but can technically be different - // walletAddress references address of XMTP client - expect(signedPayload.untrustedData.walletAddress).toEqual(client.address); - - // address references the address associated with initiating a transaction - expect(signedPayload.untrustedData.address).toEqual("0x..."); - expect(signedPayload.untrustedData.transactionId).toEqual("123"); - - expect(signedPayload.untrustedData.url).toEqual(frameUrl); - expect(signedPayload.untrustedData.buttonIndex).toEqual(buttonIndex); - expect( - signedPayload.untrustedData.opaqueConversationIdentifier, - ).toBeDefined(); - expect(signedPayload.untrustedData.timestamp).toBeGreaterThan(0); - - const signedPayloadProto = frames.FrameAction.decode( - b64Decode(signedPayload.trustedData.messageBytes), - ); - expect(signedPayloadProto.actionBody).toBeDefined(); - expect(signedPayloadProto.signature).toBeDefined(); - expect(signedPayloadProto.signedPublicKeyBundle).toBeDefined(); - - const signedPayloadBody = frames.FrameActionBody.decode( - signedPayloadProto.actionBody, - ); - - expect(signedPayloadBody.buttonIndex).toEqual(buttonIndex); - expect(signedPayloadBody.frameUrl).toEqual(frameUrl); - expect(signedPayloadBody.opaqueConversationIdentifier).toBeDefined(); - expect(signedPayloadBody.state).toEqual("state"); - - if ( - !signedPayloadProto.signature || - !signedPayloadProto.signedPublicKeyBundle?.identityKey - ) { - throw new Error("Missing signature"); - } - - const signatureInstance = new Signature(signedPayloadProto.signature); - const digest = sha256(signedPayloadProto.actionBody); - // Ensure the signature is valid - expect( - signatureInstance - .getPublicKey(digest) - ?.equals( - new SignedPublicKey( - signedPayloadProto.signedPublicKeyBundle.identityKey, - ).toLegacyKey(), - ), - ); - }); - - // Will add E2E tests back once we have Frames deployed with the new schema - it("works e2e", async () => { - const frameUrl = - "https://fc-polls-five.vercel.app/polls/01032f47-e976-42ee-9e3d-3aac1324f4b8"; - const metadata = await framesClient.proxy.readMetadata(frameUrl); - expect(metadata).toBeDefined(); - expect(metadata.frameInfo).toMatchObject({ - acceptedClients: { - farcaster: "vNext", - }, - buttons: { - "1": { - label: "Yes", - }, - "2": { - label: "No", - }, - }, - image: { - content: - "https://fc-polls-five.vercel.app/api/image?id=01032f47-e976-42ee-9e3d-3aac1324f4b8", - }, - postUrl: - "https://fc-polls-five.vercel.app/api/vote?id=01032f47-e976-42ee-9e3d-3aac1324f4b8", - }); - const signedPayload = await framesClient.signFrameAction({ - frameUrl, - buttonIndex: 1, - conversationTopic: "foo", - participantAccountAddresses: ["amal", "bola"], - }); - const postUrl = metadata.extractedTags["fc:frame:post_url"]; - const response = await framesClient.proxy.post(postUrl, signedPayload); - expect(response).toBeDefined(); - expect(response.extractedTags["fc:frame"]).toEqual("vNext"); - - const imageUrl = response.extractedTags["fc:frame:image"]; - const mediaUrl = framesClient.proxy.mediaUrl(imageUrl); - - const downloadedMedia = await fetch(mediaUrl); - expect(downloadedMedia.ok).toBeTruthy(); - expect(downloadedMedia.headers.get("content-type")).toEqual("image/png"); - }); - - it("sends back the button postUrl for a tx frame in frame info", async () => { - const frameUrl = - "https://tx-boilerplate-frame-git-main-xmtp-labs.vercel.app/"; - const metadata = await framesClient.proxy.readMetadata(frameUrl); - expect(metadata).toBeDefined(); - expect(metadata.frameInfo).toMatchObject({ - acceptedClients: { - xmtp: "2024-02-09", - farcaster: "vNext", - }, - buttons: { - "1": { - label: "Make transaction", - action: "tx", - target: - "https://tx-boilerplate-frame-git-main-xmtp-labs.vercel.app/api/transaction", - postUrl: - "https://tx-boilerplate-frame-git-main-xmtp-labs.vercel.app/api/transaction-success", - }, - }, - image: { - content: - "https://tx-boilerplate-frame-git-main-xmtp-labs.vercel.app/api/og?transaction=null", - }, - }); - }); -}); diff --git a/packages/frames-client/src/index.ts b/packages/frames-client/src/index.ts index 56201315d..dcba36a55 100644 --- a/packages/frames-client/src/index.ts +++ b/packages/frames-client/src/index.ts @@ -1,4 +1,5 @@ export type * from "./types"; +export { isV3FramesSigner } from "./types"; export * from "./constants"; export { FramesClient } from "./client"; export { default as OpenFramesProxy } from "./proxy"; diff --git a/packages/frames-client/src/types.ts b/packages/frames-client/src/types.ts index e2de54477..0c3363288 100644 --- a/packages/frames-client/src/types.ts +++ b/packages/frames-client/src/types.ts @@ -4,6 +4,10 @@ import type { TransactionResponse, } from "@open-frames/proxy-client"; import type { OpenFramesUntrustedData } from "@open-frames/types"; +import { + type publicKey as publicKeyProto, + type signature as signatureProto, +} from "@xmtp/proto"; export type FramesApiResponse = GetMetadataResponse; @@ -48,13 +52,23 @@ export type FrameActionInputs = { transactionId?: string; } & ConversationActionInputs; -type KeyType = { - kind: "identity" | "prekey"; - prekeyIndex?: number | undefined; +export type V2FramesSigner = { + address: () => Promise | string; + getPublicKeyBundle: () => Promise; + sign: (message: Uint8Array) => Promise; }; -export type ReactNativeClient = { - address: string; - exportPublicKeyBundle(): Promise; - sign(digest: Uint8Array, type: KeyType): Promise; +export type V3FramesSigner = { + installationId: () => Promise | Uint8Array; + inboxId: () => Promise | string; + address: () => Promise | string; + sign: (message: Uint8Array) => Promise | Uint8Array; +}; + +export type FramesSigner = V2FramesSigner | V3FramesSigner; + +export const isV3FramesSigner = ( + signer: FramesSigner, +): signer is V3FramesSigner => { + return "installationId" in signer && "inboxId" in signer; }; diff --git a/packages/frames-client/src/utils.ts b/packages/frames-client/src/utils.ts index 78f830867..d6ea9f890 100644 --- a/packages/frames-client/src/utils.ts +++ b/packages/frames-client/src/utils.ts @@ -1,8 +1,7 @@ import { sha256 } from "@noble/hashes/sha256"; import { fetcher } from "@xmtp/proto"; -import type { Client } from "@xmtp/xmtp-js"; import { InvalidArgumentsError } from "./errors"; -import type { FrameActionInputs, ReactNativeClient } from "./types"; +import type { FrameActionInputs } from "./types"; const { b64Encode } = fetcher; @@ -43,14 +42,3 @@ export function buildOpaqueIdentifier(inputs: FrameActionInputs): string { ), ); } - -export function isReactNativeClient( - client: Client | ReactNativeClient, -): client is ReactNativeClient { - const assertedClient = client as ReactNativeClient; - return ( - typeof assertedClient.sign === "function" && - typeof assertedClient.exportPublicKeyBundle === "function" && - !("keystore" in client) - ); -} diff --git a/packages/frames-client/tsconfig.json b/packages/frames-client/tsconfig.json index 8dd79af93..a5171b3d2 100644 --- a/packages/frames-client/tsconfig.json +++ b/packages/frames-client/tsconfig.json @@ -1,5 +1,12 @@ { - "extends": "tsconfig/react-sdk.json", - "include": ["src", "rollup.config.js", "vitest.config.ts"], + "extends": "tsconfig/base.json", + "compilerOptions": { + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "types": ["vitest", "vitest/globals"] + }, + "include": ["src", "rollup.config.js", "vitest.config.ts", "vitest.setup.ts"], "exclude": ["dist", "node_modules"] } diff --git a/packages/frames-client/vitest.config.ts b/packages/frames-client/vitest.config.ts index 5b9029d98..1b00207a5 100644 --- a/packages/frames-client/vitest.config.ts +++ b/packages/frames-client/vitest.config.ts @@ -12,6 +12,7 @@ const vitestConfig = defineVitestConfig({ globals: true, environment: "happy-dom", testTimeout: 60000, + globalSetup: ["./vitest.setup.ts"], }, }); diff --git a/packages/frames-client/vitest.setup.ts b/packages/frames-client/vitest.setup.ts new file mode 100644 index 000000000..c15b3a24c --- /dev/null +++ b/packages/frames-client/vitest.setup.ts @@ -0,0 +1,11 @@ +import { unlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { glob } from "fast-glob"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const teardown = async () => { + const files = await glob("**/*.db3*", { cwd: __dirname }); + await Promise.all(files.map((file) => unlink(join(__dirname, file)))); +}; diff --git a/packages/frames-validator/package.json b/packages/frames-validator/package.json index b2bdc7e60..8e1d214c8 100644 --- a/packages/frames-validator/package.json +++ b/packages/frames-validator/package.json @@ -40,7 +40,9 @@ "dependencies": { "@noble/curves": "^1.3.0", "@noble/hashes": "^1.4.0", - "@xmtp/proto": "^3.72.0", + "@xmtp/node-sdk": "^0.0.27", + "@xmtp/proto": "^3.72.3", + "uint8array-extras": "^1.4.0", "viem": "^2.16.5" }, "devDependencies": { @@ -50,6 +52,7 @@ "@xmtp/frames-client": "^0.5.5", "@xmtp/xmtp-js": "^12.1.0", "ethers": "^6.10.0", + "fast-glob": "^3.3.2", "rollup": "^4.27.3", "rollup-plugin-dts": "^6.1.1", "typescript": "^5.6.3", diff --git a/packages/frames-validator/src/index.test.ts b/packages/frames-validator/src/index.test.ts deleted file mode 100644 index d61a3f99f..000000000 --- a/packages/frames-validator/src/index.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { FramesClient } from "@xmtp/frames-client"; -import { fetcher, frames } from "@xmtp/proto"; -import { Client, PrivateKeyBundleV2 } from "@xmtp/xmtp-js"; -import { Wallet } from "ethers"; -import { beforeEach, describe, expect, it } from "vitest"; -import { deserializeProtoMessage, validateFramesPost } from "."; - -const { b64Decode, b64Encode } = fetcher; - -function scrambleBytes(bytes: Uint8Array) { - const scrambled = new Uint8Array(bytes.length); - for (let i = 0; i < bytes.length; i++) { - scrambled[i] = bytes[bytes.length - i - 1]; - } - return scrambled; -} - -describe("validations", () => { - let client: Client; - let framesClient: FramesClient; - - const FRAME_URL = "https://frame.xyz"; - const CONVERSATION_TOPIC = "/xmtp/0/1234"; - const PARTICIPANT_ACCOUNT_ADDRESSES = ["0x1234", "0x5678"]; - const BUTTON_INDEX = 2; - - beforeEach(async () => { - const wallet = Wallet.createRandom(); - client = await Client.create(wallet); - framesClient = new FramesClient(client); - }); - - it("succeeds in the happy path", async () => { - const postData = await framesClient.signFrameAction({ - buttonIndex: BUTTON_INDEX, - frameUrl: FRAME_URL, - conversationTopic: CONVERSATION_TOPIC, - participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, - }); - const validated = validateFramesPost(postData); - expect(validated.verifiedWalletAddress).toEqual(client.address); - }); - - it("fails if the signature verification fails", async () => { - const postData = await framesClient.signFrameAction({ - buttonIndex: BUTTON_INDEX, - frameUrl: FRAME_URL, - conversationTopic: CONVERSATION_TOPIC, - participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, - }); - // Monkey around with the signature - const deserialized = deserializeProtoMessage( - b64Decode(postData.trustedData.messageBytes), - ); - - if (!deserialized.signature.ecdsaCompact?.bytes) { - throw new Error("Signature bytes are empty"); - } - - deserialized.signature.ecdsaCompact.bytes = scrambleBytes( - deserialized.signature.ecdsaCompact.bytes, - ); - const reserialized = frames.FrameAction.encode({ - signature: deserialized.signature, - actionBody: deserialized.actionBodyBytes, - signedPublicKeyBundle: deserialized.signedPublicKeyBundle, - }).finish(); - - postData.trustedData.messageBytes = b64Encode( - reserialized, - 0, - reserialized.length, - ); - - expect(() => validateFramesPost(postData)).toThrow(); - }); - - it("fails if the wallet address doesn't match", async () => { - const postData = await framesClient.signFrameAction({ - buttonIndex: BUTTON_INDEX, - frameUrl: FRAME_URL, - conversationTopic: CONVERSATION_TOPIC, - participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, - }); - // Monkey around with the signature - const deserialized = deserializeProtoMessage( - b64Decode(postData.trustedData.messageBytes), - ); - - const throwAwayWallet = Wallet.createRandom(); - const wrongPublicKeyBundle = ( - await PrivateKeyBundleV2.generate(throwAwayWallet) - ).getPublicKeyBundle(); - - const reserialized = frames.FrameAction.encode({ - signature: deserialized.signature, - actionBody: deserialized.actionBodyBytes, - signedPublicKeyBundle: wrongPublicKeyBundle, - }).finish(); - - postData.trustedData.messageBytes = b64Encode( - reserialized, - 0, - reserialized.length, - ); - - expect(() => validateFramesPost(postData)).toThrow(); - }); -}); diff --git a/packages/frames-validator/src/index.ts b/packages/frames-validator/src/index.ts index 9248b3301..10d884381 100644 --- a/packages/frames-validator/src/index.ts +++ b/packages/frames-validator/src/index.ts @@ -1,2 +1,2 @@ -export * from "./openFrames.js"; +export * from "./validator.js"; export * from "./validation.js"; diff --git a/packages/frames-validator/src/validation.test.ts b/packages/frames-validator/src/validation.test.ts new file mode 100644 index 000000000..175434086 --- /dev/null +++ b/packages/frames-validator/src/validation.test.ts @@ -0,0 +1,211 @@ +import { getRandomValues } from "node:crypto"; +import { + FramesClient, + isV3FramesSigner, + type FramesSigner, + type V2FramesSigner, + type V3FramesSigner, +} from "@xmtp/frames-client"; +import { Client as V3Client } from "@xmtp/node-sdk"; +import { fetcher, frames } from "@xmtp/proto"; +import { Client, PrivateKeyBundleV2 } from "@xmtp/xmtp-js"; +import { getBytes, Wallet } from "ethers"; +import { describe, expect, it } from "vitest"; +import { deserializeProtoMessage, validateFramesPost } from "."; + +const { b64Decode, b64Encode } = fetcher; + +function scrambleBytes(bytes: Uint8Array) { + const scrambled = new Uint8Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + scrambled[i] = bytes[bytes.length - i - 1]; + } + return scrambled; +} + +const getV2Setup = async () => { + const client = await Client.create(Wallet.createRandom(), { env: "local" }); + const signer: V2FramesSigner = { + address: () => client.address, + getPublicKeyBundle: () => client.keystore.getPublicKeyBundle(), + sign: (digest: Uint8Array) => + client.keystore.signDigest({ + digest, + identityKey: true, + prekeyIndex: undefined, + }), + }; + const framesClient = new FramesClient(signer); + return { signer, framesClient }; +}; + +const getV3Setup = async () => { + const encryptionKey = getRandomValues(new Uint8Array(32)); + const wallet = Wallet.createRandom(); + const client = await V3Client.create( + { + getAddress: () => wallet.address, + signMessage: async (message: string) => + getBytes(await wallet.signMessage(message)), + }, + encryptionKey, + { env: "local" }, + ); + const signer: V3FramesSigner = { + address: () => client.accountAddress, + installationId: () => client.installationIdBytes, + inboxId: () => client.inboxId, + sign: (digest: Uint8Array) => + client.signWithInstallationKey(Buffer.from(digest).toString("hex")), + }; + const framesClient = new FramesClient(signer); + return { signer, framesClient }; +}; + +const FRAME_URL = "https://frame.xyz"; +const CONVERSATION_TOPIC = "/xmtp/0/1234"; +const PARTICIPANT_ACCOUNT_ADDRESSES = ["0x1234", "0x5678"]; +const BUTTON_INDEX = 2; + +const shouldValidateFramesPost = + (signer: FramesSigner, framesClient: FramesClient) => async () => { + const postData = await framesClient.signFrameAction({ + buttonIndex: BUTTON_INDEX, + frameUrl: FRAME_URL, + conversationTopic: CONVERSATION_TOPIC, + participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, + }); + const validated = await validateFramesPost(postData, "local"); + expect(validated.verifiedWalletAddress).toEqual(await signer.address()); + }; + +const shouldFailWithInvalidSignature = + (signer: FramesSigner, framesClient: FramesClient) => async () => { + const postData = await framesClient.signFrameAction({ + buttonIndex: BUTTON_INDEX, + frameUrl: FRAME_URL, + conversationTopic: CONVERSATION_TOPIC, + participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, + }); + const deserialized = deserializeProtoMessage( + b64Decode(postData.trustedData.messageBytes), + ); + + const isV3 = isV3FramesSigner(signer); + + if (isV3) { + deserialized.installationSignature = scrambleBytes( + deserialized.installationSignature, + ); + } else { + if (!deserialized.signature?.ecdsaCompact?.bytes) { + throw new Error("Signature bytes are empty"); + } + + deserialized.signature.ecdsaCompact.bytes = scrambleBytes( + deserialized.signature.ecdsaCompact.bytes, + ); + } + + const reserialized = frames.FrameAction.encode({ + actionBody: deserialized.actionBodyBytes, + signature: isV3 ? undefined : deserialized.signature, + signedPublicKeyBundle: isV3 + ? undefined + : deserialized.signedPublicKeyBundle, + installationSignature: isV3 + ? deserialized.installationSignature + : new Uint8Array(), + installationId: isV3 ? deserialized.installationId : new Uint8Array(), + inboxId: isV3 ? deserialized.inboxId : "", + }).finish(); + + postData.trustedData.messageBytes = b64Encode( + reserialized, + 0, + reserialized.length, + ); + + await expect(() => validateFramesPost(postData, "local")).rejects.toThrow(); + }; + +const shouldFailWithWalletAddressMismatch = + (signer: FramesSigner, framesClient: FramesClient) => async () => { + const postData = await framesClient.signFrameAction({ + buttonIndex: BUTTON_INDEX, + frameUrl: FRAME_URL, + conversationTopic: CONVERSATION_TOPIC, + participantAccountAddresses: PARTICIPANT_ACCOUNT_ADDRESSES, + }); + const deserialized = deserializeProtoMessage( + b64Decode(postData.trustedData.messageBytes), + ); + + const isV3 = isV3FramesSigner(signer); + + if (isV3) { + deserialized.inboxId = "wrong-inbox-id"; + } else { + const throwAwayWallet = Wallet.createRandom(); + deserialized.signedPublicKeyBundle = ( + await PrivateKeyBundleV2.generate(throwAwayWallet) + ).getPublicKeyBundle(); + } + + const reserialized = frames.FrameAction.encode({ + actionBody: deserialized.actionBodyBytes, + signature: isV3 ? undefined : deserialized.signature, + signedPublicKeyBundle: isV3 + ? undefined + : deserialized.signedPublicKeyBundle, + installationSignature: isV3 + ? deserialized.installationSignature + : new Uint8Array(), + installationId: isV3 ? deserialized.installationId : new Uint8Array(), + inboxId: isV3 ? deserialized.inboxId : "", + }).finish(); + + postData.trustedData.messageBytes = b64Encode( + reserialized, + 0, + reserialized.length, + ); + + await expect(() => validateFramesPost(postData, "local")).rejects.toThrow(); + }; + +describe("validations", () => { + describe("V2", () => { + it("succeeds in the happy path", async () => { + const { signer, framesClient } = await getV2Setup(); + await shouldValidateFramesPost(signer, framesClient)(); + }); + + it("fails if the signature verification fails", async () => { + const { signer, framesClient } = await getV2Setup(); + await shouldFailWithInvalidSignature(signer, framesClient)(); + }); + + it("fails if the wallet address doesn't match", async () => { + const { signer, framesClient } = await getV2Setup(); + await shouldFailWithWalletAddressMismatch(signer, framesClient)(); + }); + }); + + describe("V3", () => { + it("succeeds in the happy path", async () => { + const { signer, framesClient } = await getV3Setup(); + await shouldValidateFramesPost(signer, framesClient)(); + }); + + it("fails if the signature verification fails", async () => { + const { signer, framesClient } = await getV3Setup(); + await shouldFailWithInvalidSignature(signer, framesClient)(); + }); + + it("fails if the wallet address doesn't match", async () => { + const { signer, framesClient } = await getV3Setup(); + await shouldFailWithWalletAddressMismatch(signer, framesClient)(); + }); + }); +}); diff --git a/packages/frames-validator/src/validation.ts b/packages/frames-validator/src/validation.ts index 895e0dd86..d9713e7f1 100644 --- a/packages/frames-validator/src/validation.ts +++ b/packages/frames-validator/src/validation.ts @@ -1,4 +1,7 @@ +import { sha256 } from "@noble/hashes/sha256"; +import { Client, getInboxIdForAddress, type XmtpEnv } from "@xmtp/node-sdk"; import { fetcher, frames, type publicKey, type signature } from "@xmtp/proto"; +import { uint8ArrayToHex } from "uint8array-extras"; import type { UntrustedData, XmtpOpenFramesRequest, @@ -10,44 +13,70 @@ export type * from "./types.js"; const { b64Decode } = fetcher; -export function validateFramesPost( +export async function validateFramesPost( data: XmtpOpenFramesRequest, -): XmtpValidationResponse { + env?: XmtpEnv, +): Promise { const { untrustedData, trustedData } = data; const { walletAddress } = untrustedData; const { messageBytes: messageBytesString } = trustedData; const messageBytes = b64Decode(messageBytesString); - const { actionBody, actionBodyBytes, signature, signedPublicKeyBundle } = - deserializeProtoMessage(messageBytes); - - const verifiedWalletAddress = getVerifiedWalletAddress( + const { + actionBody, actionBodyBytes, signature, signedPublicKeyBundle, - ); + installationId, // not necessary + installationSignature, + inboxId, + } = deserializeProtoMessage(messageBytes); + + const isV2Frame = signature && signedPublicKeyBundle; + + if (isV2Frame) { + const verifiedWalletAddress = getVerifiedWalletAddress( + actionBodyBytes, + signature, + signedPublicKeyBundle, + ); - if (verifiedWalletAddress !== walletAddress) { - console.log(`${verifiedWalletAddress} !== ${walletAddress}`); - throw new Error("Invalid wallet address"); + if (verifiedWalletAddress !== walletAddress) { + console.log(`${verifiedWalletAddress} !== ${walletAddress}`); + throw new Error("Invalid wallet address"); + } + } else { + // make sure inbox IDs match + const addressInboxId = await getInboxIdForAddress(walletAddress, env); + if (inboxId !== addressInboxId) { + throw new Error("Invalid inbox ID"); + } + + const digest = sha256(actionBodyBytes); + + // make sure installation signature is valid + const valid = Client.verifySignedWithPublicKey( + uint8ArrayToHex(digest), + installationSignature, + installationId, + ); + + if (!valid) { + throw new Error("Invalid signature"); + } } checkUntrustedData(untrustedData, actionBody); return { actionBody, - verifiedWalletAddress, + verifiedWalletAddress: walletAddress, }; } export function deserializeProtoMessage(messageBytes: Uint8Array) { const frameAction = frames.FrameAction.decode(messageBytes); - if (!frameAction.signature || !frameAction.signedPublicKeyBundle) { - throw new Error( - "Invalid frame action: missing signature or signed public key bundle", - ); - } const actionBody = frames.FrameActionBody.decode(frameAction.actionBody); return { @@ -55,6 +84,9 @@ export function deserializeProtoMessage(messageBytes: Uint8Array) { actionBodyBytes: frameAction.actionBody, signature: frameAction.signature, signedPublicKeyBundle: frameAction.signedPublicKeyBundle, + installationId: frameAction.installationId, + installationSignature: frameAction.installationSignature, + inboxId: frameAction.inboxId, }; } diff --git a/packages/frames-validator/src/openFrames.ts b/packages/frames-validator/src/validator.ts similarity index 91% rename from packages/frames-validator/src/openFrames.ts rename to packages/frames-validator/src/validator.ts index 606aab5a0..835b2a498 100644 --- a/packages/frames-validator/src/openFrames.ts +++ b/packages/frames-validator/src/validator.ts @@ -3,6 +3,7 @@ import type { RequestValidator, ValidationResponse, } from "@open-frames/types"; +import type { XmtpEnv } from "@xmtp/node-sdk"; import type { XmtpOpenFramesRequest, XmtpValidationResponse } from "./types"; import { validateFramesPost } from "./validation"; @@ -37,11 +38,12 @@ export class XmtpValidator async validate( payload: XmtpOpenFramesRequest, + env?: XmtpEnv, ): Promise< ValidationResponse > { try { - const validationResponse = validateFramesPost(payload); + const validationResponse = await validateFramesPost(payload, env); return await Promise.resolve({ isValid: true, clientProtocol: payload.clientProtocol, diff --git a/packages/frames-validator/tsconfig.json b/packages/frames-validator/tsconfig.json index a8f57807b..a5171b3d2 100644 --- a/packages/frames-validator/tsconfig.json +++ b/packages/frames-validator/tsconfig.json @@ -1,5 +1,12 @@ { "extends": "tsconfig/base.json", - "include": ["src", "rollup.config.js", "vitest.config.ts"], + "compilerOptions": { + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "types": ["vitest", "vitest/globals"] + }, + "include": ["src", "rollup.config.js", "vitest.config.ts", "vitest.setup.ts"], "exclude": ["dist", "node_modules"] } diff --git a/packages/frames-validator/vitest.config.ts b/packages/frames-validator/vitest.config.ts index d01c48c56..38f2ddc5a 100644 --- a/packages/frames-validator/vitest.config.ts +++ b/packages/frames-validator/vitest.config.ts @@ -10,6 +10,7 @@ const vitestConfig = defineVitestConfig({ test: { globals: true, environment: "node", + globalSetup: ["./vitest.setup.ts"], }, }); diff --git a/packages/frames-validator/vitest.setup.ts b/packages/frames-validator/vitest.setup.ts new file mode 100644 index 000000000..c15b3a24c --- /dev/null +++ b/packages/frames-validator/vitest.setup.ts @@ -0,0 +1,11 @@ +import { unlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { glob } from "fast-glob"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const teardown = async () => { + const files = await glob("**/*.db3*", { cwd: __dirname }); + await Promise.all(files.map((file) => unlink(join(__dirname, file)))); +}; diff --git a/turbo.json b/turbo.json index e50811ac8..8920a8cd4 100644 --- a/turbo.json +++ b/turbo.json @@ -38,7 +38,8 @@ "@xmtp/frames-client#test": { "dependsOn": [ "@xmtp/consent-proof-signature#build", - "@xmtp/content-type-text#build" + "@xmtp/content-type-text#build", + "@xmtp/node-sdk#build" ], "outputs": [] }, diff --git a/yarn.lock b/yarn.lock index 1491a71fb..64021aed1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5033,9 +5033,11 @@ __metadata: "@open-frames/types": "npm:^0.1.1" "@rollup/plugin-terser": "npm:^0.4.4" "@rollup/plugin-typescript": "npm:^12.1.1" - "@xmtp/proto": "npm:^3.72.0" + "@xmtp/node-sdk": "npm:^0.0.27" + "@xmtp/proto": "npm:^3.72.3" "@xmtp/xmtp-js": "npm:^12.0.0" ethers: "npm:^6.13.1" + fast-glob: "npm:^3.3.2" long: "npm:^5.2.3" rollup: "npm:^4.27.3" rollup-plugin-dts: "npm:^6.1.1" @@ -5044,6 +5046,7 @@ __metadata: tsconfig: "workspace:*" typedoc: "npm:^0.26.11" typescript: "npm:^5.6.3" + uint8array-extras: "npm:^1.4.0" vite: "npm:^5.4.11" vite-tsconfig-paths: "npm:^5.1.2" vitest: "npm:^2.1.3" @@ -5062,12 +5065,15 @@ __metadata: "@rollup/plugin-typescript": "npm:^12.1.1" "@types/bl": "npm:^5.1.4" "@xmtp/frames-client": "npm:^0.5.5" - "@xmtp/proto": "npm:^3.72.0" + "@xmtp/node-sdk": "npm:^0.0.27" + "@xmtp/proto": "npm:^3.72.3" "@xmtp/xmtp-js": "npm:^12.1.0" ethers: "npm:^6.10.0" + fast-glob: "npm:^3.3.2" rollup: "npm:^4.27.3" rollup-plugin-dts: "npm:^6.1.1" typescript: "npm:^5.6.3" + uint8array-extras: "npm:^1.4.0" viem: "npm:^2.16.5" vitest: "npm:^2.1.3" languageName: unknown @@ -5080,7 +5086,7 @@ __metadata: languageName: node linkType: hard -"@xmtp/node-sdk@workspace:sdks/node-sdk": +"@xmtp/node-sdk@npm:^0.0.27, @xmtp/node-sdk@workspace:sdks/node-sdk": version: 0.0.0-use.local resolution: "@xmtp/node-sdk@workspace:sdks/node-sdk" dependencies: