Skip to content

Commit

Permalink
Merge pull request #731 from xmtp/rygine/frames-update
Browse files Browse the repository at this point in the history
Add V3 support to Frames client and validator
  • Loading branch information
rygine authored Nov 23, 2024
2 parents a0997d0 + 86a8f9f commit cdd1f45
Show file tree
Hide file tree
Showing 22 changed files with 626 additions and 362 deletions.
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
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");
};

// 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)();
});
});
});
});
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(),
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),
};
}

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

0 comments on commit cdd1f45

Please sign in to comment.