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

Add V3 support to Frames client and validator #731

Merged
merged 12 commits into from
Nov 23, 2024
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
6 changes: 6 additions & 0 deletions .changeset/blue-wasps-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@xmtp/frames-client": major
"@xmtp/frames-validator": major
---

Add V3 support to Frames client and validator
rygine marked this conversation as resolved.
Show resolved Hide resolved
5 changes: 4 additions & 1 deletion packages/frames-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,25 @@
"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",
"rollup-plugin-tsconfig-paths": "^1.5.2",
"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"
Expand Down
235 changes: 235 additions & 0 deletions packages/frames-client/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -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();

Check warning on line 96 in packages/frames-client/src/client.test.ts

View workflow job for this annotation

GitHub Actions / Lint

`signature` is deprecated
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");
};
rygine marked this conversation as resolved.
Show resolved Hide resolved

// 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");
};
rygine marked this conversation as resolved.
Show resolved Hide resolved

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)();
});
});
});
});
92 changes: 39 additions & 53 deletions packages/frames-client/src/client.ts
Original file line number Diff line number Diff line change
@@ -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<FramePostPayload> {
Expand Down Expand Up @@ -55,7 +50,7 @@ export class FramesClient {
untrustedData: {
buttonIndex,
opaqueConversationIdentifier,
walletAddress: this.xmtpClient.address,
walletAddress: await this.#signer.address(),
rygine marked this conversation as resolved.
Show resolved Hide resolved
inputText,
url: frameUrl,
timestamp: now,
Expand All @@ -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<signatureProto.Signature> {
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<publicKeyProto.PublicKeyBundle> {
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),
};
rygine marked this conversation as resolved.
Show resolved Hide resolved
}

return this.xmtpClient.keystore.getPublicKeyBundle();
return frames.FrameAction.encode(payload).finish();
}
}
Loading
Loading