diff --git a/.changeset/four-jars-laugh.md b/.changeset/four-jars-laugh.md new file mode 100644 index 00000000..2348f91c --- /dev/null +++ b/.changeset/four-jars-laugh.md @@ -0,0 +1,5 @@ +--- +"@xmtp/frames-client": patch +--- + +Adds ability to post frame to a destination and see an updated response diff --git a/packages/frames-client/README.md b/packages/frames-client/README.md index cdc25b1a..361522b5 100644 --- a/packages/frames-client/README.md +++ b/packages/frames-client/README.md @@ -1 +1,17 @@ # frames-client + +## Usage + +```ts +const xmtpClient = await Client.create(wallet); +const framesClient = new FramesClient(xmtpClient); + +const frameUrl = "https://www.myframe.xyz"; + +// Read data from a frame +const frameMetadata = await readMetadata(frameUrl); + +// Handle a click to button 2 from a conversation with topic "/xmtp/0/123" on messageId "45678" +const payload = await signFrameAction(frameUrl, 2, "/xmtp/0/123", "45678"); +const updatedFrameMetadata = await postToFrame(frameUrl, payload); +``` diff --git a/packages/frames-client/package.json b/packages/frames-client/package.json index 04e33827..a0161c7c 100644 --- a/packages/frames-client/package.json +++ b/packages/frames-client/package.json @@ -60,8 +60,10 @@ "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^11.1.6", "@xmtp/tsconfig": "workspace:*", + "@xmtp/xmtp-js": "^11.3.7", "eslint": "^8.56.0", "eslint-config-xmtp-web": "workspace:*", + "ethers": "^6.10.0", "prettier": "^3.2.4", "rollup": "^4.9.6", "rollup-plugin-dts": "^6.1.0", diff --git a/packages/frames-client/src/converters.test.ts b/packages/frames-client/src/converters.test.ts new file mode 100644 index 00000000..ce939eb6 --- /dev/null +++ b/packages/frames-client/src/converters.test.ts @@ -0,0 +1,17 @@ +import { Wallet } from "ethers"; +import { it, expect, describe } from "vitest"; +import { PrivateKeyBundleV1, SignedPublicKeyBundle } from "@xmtp/xmtp-js"; +import { v1ToV2Bundle } from "./converters"; + +describe("converters", () => { + it("can convert a valid public key bundle", async () => { + const v1Bundle = await PrivateKeyBundleV1.generate(Wallet.createRandom()); + const publicKeyBundle = v1Bundle.getPublicKeyBundle(); + + const v2Bundle = v1ToV2Bundle(publicKeyBundle); + const v2BundleInstance = new SignedPublicKeyBundle(v2Bundle); + const downgradedBundle = v2BundleInstance.toLegacyBundle(); + + expect(downgradedBundle.equals(publicKeyBundle)).toBe(true); + }); +}); diff --git a/packages/frames-client/src/crypto.ts b/packages/frames-client/src/crypto.ts index b45c2f8f..6505e0b3 100644 --- a/packages/frames-client/src/crypto.ts +++ b/packages/frames-client/src/crypto.ts @@ -1,4 +1,9 @@ +// eslint-disable-next-line +const webCrypto: Crypto = + // eslint-disable-next-line + typeof crypto === "undefined" ? require("crypto").webcrypto : crypto; + export async function sha256(data: Uint8Array): Promise { - const digest = await crypto.subtle.digest("SHA-256", data); + const digest = await webCrypto.subtle.digest("SHA-256", data); return new Uint8Array(digest); } diff --git a/packages/frames-client/src/index.test.ts b/packages/frames-client/src/index.test.ts new file mode 100644 index 00000000..9f0b9fa2 --- /dev/null +++ b/packages/frames-client/src/index.test.ts @@ -0,0 +1,82 @@ +import { Client, Signature, SignedPublicKey } from "@xmtp/xmtp-js"; +import { Wallet } from "ethers"; +import { frames, fetcher } from "@xmtp/proto"; +import { it, expect, describe, beforeEach } from "vitest"; +import { FramesClient } from "."; +import { sha256 } from "./crypto"; + +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 conversationIdentifier = "testConversationIdentifier"; + const messageId = "testMessageId"; + + const signedPayload = await framesClient.signFrameAction( + frameUrl, + buttonIndex, + conversationIdentifier, + messageId, + ); + + expect(signedPayload.untrustedData.walletAddress).toEqual(client.address); + expect(signedPayload.untrustedData.url).toEqual(frameUrl); + expect(signedPayload.untrustedData.buttonIndex).toEqual(buttonIndex); + expect(signedPayload.untrustedData.conversationIdentifier).toEqual( + conversationIdentifier, + ); + 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.messageId).toEqual(messageId); + expect(new TextDecoder().decode(signedPayloadBody.buttonIndex)).toEqual( + buttonIndex.toString(), + ); + expect(new TextDecoder().decode(signedPayloadBody.frameUrl)).toEqual( + frameUrl, + ); + expect(signedPayloadBody.conversationIdentifier).toEqual( + conversationIdentifier, + ); + expect(new TextDecoder().decode(signedPayloadBody.frameUrl)).toEqual( + frameUrl, + ); + + if ( + !signedPayloadProto.signature || + !signedPayloadProto?.signedPublicKeyBundle?.identityKey + ) { + throw new Error("Missing signature"); + } + + const signatureInstance = new Signature(signedPayloadProto.signature); + const digest = await sha256(signedPayloadProto.actionBody); + // Ensure the signature is valid + expect( + signatureInstance + .getPublicKey(digest) + ?.equals( + new SignedPublicKey( + signedPayloadProto.signedPublicKeyBundle.identityKey, + ).toLegacyKey(), + ), + ); + }); +}); diff --git a/packages/frames-client/src/index.ts b/packages/frames-client/src/index.ts index 22b78c89..d47c9810 100644 --- a/packages/frames-client/src/index.ts +++ b/packages/frames-client/src/index.ts @@ -21,6 +21,30 @@ export class FramesClient { return (await response.json()) as FramesApiResponse; } + static async postToFrame( + url: string, + payload: FramePostPayload, + ): Promise { + const response = await fetch( + `${OG_PROXY_URL}?url=${encodeURIComponent(url)}`, + { + method: "POST", + body: JSON.stringify(payload), + headers: { + "Content-Type": "application/json", + }, + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to post to frame: ${response.status} ${response.statusText}`, + ); + } + + return (await response.json()) as FramesApiResponse; + } + async signFrameAction( frameUrl: string, buttonIndex: number, diff --git a/yarn.lock b/yarn.lock index 1a1ca439..aafc306d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3297,6 +3297,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:18.15.13": + version: 18.15.13 + resolution: "@types/node@npm:18.15.13" + checksum: b9bbe923573797ef7c5fd2641a6793489e25d9369c32aeadcaa5c7c175c85b42eb12d6fe173f6781ab6f42eaa1ebd9576a419eeaa2a1ec810094adb8adaa9a54 + languageName: node + linkType: hard + "@types/node@npm:^12.12.54, @types/node@npm:^12.7.1": version: 12.20.55 resolution: "@types/node@npm:12.20.55" @@ -4306,8 +4313,10 @@ __metadata: "@rollup/plugin-typescript": "npm:^11.1.6" "@xmtp/proto": "npm:3.41.0-beta.2" "@xmtp/tsconfig": "workspace:*" + "@xmtp/xmtp-js": "npm:^11.3.7" eslint: "npm:^8.56.0" eslint-config-xmtp-web: "workspace:*" + ethers: "npm:^6.10.0" prettier: "npm:^3.2.4" rollup: "npm:^4.9.6" rollup-plugin-dts: "npm:^6.1.0" @@ -4579,6 +4588,13 @@ __metadata: languageName: node linkType: hard +"aes-js@npm:4.0.0-beta.5": + version: 4.0.0-beta.5 + resolution: "aes-js@npm:4.0.0-beta.5" + checksum: 8f745da2e8fb38e91297a8ec13c2febe3219f8383303cd4ed4660ca67190242ccfd5fdc2f0d1642fd1ea934818fb871cd4cc28d3f28e812e3dc6c3d0f1f97c24 + languageName: node + linkType: hard + "aes-js@npm:^3.1.2": version: 3.1.2 resolution: "aes-js@npm:3.1.2" @@ -7198,6 +7214,21 @@ __metadata: languageName: node linkType: hard +"ethers@npm:^6.10.0": + version: 6.10.0 + resolution: "ethers@npm:6.10.0" + dependencies: + "@adraffy/ens-normalize": "npm:1.10.0" + "@noble/curves": "npm:1.2.0" + "@noble/hashes": "npm:1.3.2" + "@types/node": "npm:18.15.13" + aes-js: "npm:4.0.0-beta.5" + tslib: "npm:2.4.0" + ws: "npm:8.5.0" + checksum: 04fdd3f76ea93a8b45b2fe4d9c8e2bd0d688823faba672897dd19cc3303c202a166902fe6058004562f13aaecf9f77a9f70ff113f995e94107efef2457b016dd + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.7": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" @@ -12833,6 +12864,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:2.4.0": + version: 2.4.0 + resolution: "tslib@npm:2.4.0" + checksum: d8379e68b36caf082c1905ec25d17df8261e1d68ddc1abfd6c91158a064f6e4402039ae7c02cf4c81d12e3a2a2c7cd8ea2f57b233eb80136a2e3e7279daf2911 + languageName: node + linkType: hard + "tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0": version: 2.5.0 resolution: "tslib@npm:2.5.0" @@ -13970,6 +14008,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.5.0": + version: 8.5.0 + resolution: "ws@npm:8.5.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: f0ee700970a0bf925b1ec213ca3691e84fb8b435a91461fe3caf52f58c6cec57c99ed5890fbf6978824c932641932019aafc55d864cad38ac32577496efd5d3a + languageName: node + linkType: hard + "ws@npm:^7.4.5, ws@npm:^7.5.1": version: 7.5.9 resolution: "ws@npm:7.5.9"