From 4890662666d58231a183f7d493f87c83b5bed359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasnic=CC=8Ca=CC=81k?= Date: Mon, 4 Nov 2024 16:15:18 +0100 Subject: [PATCH] feat: mini app support --- packages/render/package.json | 13 +- packages/render/src/errors.ts | 6 + packages/render/src/farcaster/frames.tsx | 75 ++- packages/render/src/farcaster/signers.tsx | 5 +- packages/render/src/helpers.ts | 35 + .../farcaster/use-farcaster-identity.tsx | 31 +- .../use-farcaster-multi-identity.tsx | 38 +- packages/render/src/mini-app-messages.ts | 135 ++++ packages/render/src/next/POST.tsx | 86 ++- packages/render/src/unstable-types.ts | 34 +- packages/render/src/use-composer-action.ts | 613 ++++++++++++++++++ 11 files changed, 1017 insertions(+), 54 deletions(-) create mode 100644 packages/render/src/mini-app-messages.ts create mode 100644 packages/render/src/use-composer-action.ts diff --git a/packages/render/package.json b/packages/render/package.json index c97d0099f..41330a3e7 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -130,6 +130,16 @@ "default": "./dist/use-frame.cjs" } }, + "./use-composer-action": { + "import": { + "types": "./dist/use-composer-action.d.ts", + "default": "./dist/use-composer-action.js" + }, + "require": { + "types": "./dist/use-composer-action.d.cts", + "default": "./dist/use-composer-action.cjs" + } + }, "./unstable-use-debugger-frame-state": { "import": { "types": "./dist/unstable-use-debugger-frame-state.d.ts", @@ -285,6 +295,7 @@ "dependencies": { "@farcaster/core": "^0.14.7", "@noble/ed25519": "^2.0.0", - "frames.js": "^0.19.5" + "frames.js": "^0.19.5", + "zod": "^3.23.8" } } diff --git a/packages/render/src/errors.ts b/packages/render/src/errors.ts index ad7dd010b..f6f8f74e3 100644 --- a/packages/render/src/errors.ts +++ b/packages/render/src/errors.ts @@ -33,3 +33,9 @@ export class ComposerActionUnexpectedResponseError extends Error { super("Unexpected composer action response from the server"); } } + +export class ComposerActionUserRejectedRequestError extends Error { + constructor() { + super("User rejected the request"); + } +} diff --git a/packages/render/src/farcaster/frames.tsx b/packages/render/src/farcaster/frames.tsx index 7ccb9a013..64f3ec037 100644 --- a/packages/render/src/farcaster/frames.tsx +++ b/packages/render/src/farcaster/frames.tsx @@ -7,7 +7,7 @@ import { getFarcasterTime, makeFrameAction, } from "@farcaster/core"; -import { hexToBytes } from "viem"; +import { bytesToHex, hexToBytes } from "viem"; import type { FrameActionBodyPayload, FrameContext, @@ -15,9 +15,45 @@ import type { SignerStateActionContext, SignFrameActionFunc, } from "../types"; +import type { + SignComposerActionFunc, + SignerStateComposerActionContext, +} from "../unstable-types"; +import { tryCallAsync } from "../helpers"; import type { FarcasterSigner } from "./signers"; import type { FarcasterFrameContext } from "./types"; +/** + * Creates a singer request payload to fetch composer action url. + */ +export const signComposerAction: SignComposerActionFunc = + async function signComposerAction(signerPrivateKey, actionContext) { + const messageOrError = await tryCallAsync(() => + createComposerActionMessageWithSignerKey(signerPrivateKey, actionContext) + ); + + if (messageOrError instanceof Error) { + throw messageOrError; + } + + const { message, trustedBytes } = messageOrError; + + return { + untrustedData: { + buttonIndex: 1, + fid: actionContext.fid, + messageHash: bytesToHex(message.hash), + network: 1, + state: Buffer.from(message.data.frameActionBody.state).toString(), + timestamp: new Date().getTime(), + url: actionContext.url, + }, + trustedData: { + messageBytes: trustedBytes, + }, + }; + }; + /** Creates a frame action for use with `useFrame` and a proxy */ export const signFrameAction: SignFrameActionFunc< FarcasterSigner, @@ -104,6 +140,43 @@ export const signFrameAction: SignFrameActionFunc< }; }; +export async function createComposerActionMessageWithSignerKey( + signerKey: string, + { fid, state, url }: SignerStateComposerActionContext +): Promise<{ + message: FrameActionMessage; + trustedBytes: string; +}> { + const signer = new NobleEd25519Signer(Buffer.from(signerKey.slice(2), "hex")); + + const messageDataOptions = { + fid, + network: FarcasterNetwork.MAINNET, + }; + + const message = await makeFrameAction( + FrameActionBody.create({ + url: Buffer.from(url), + buttonIndex: 1, + state: Buffer.from(encodeURIComponent(JSON.stringify({ cast: state }))), + }), + messageDataOptions, + signer + ); + + if (message.isErr()) { + throw message.error; + } + + const messageData = message.value; + + const trustedBytes = Buffer.from( + Message.encode(message._unsafeUnwrap()).finish() + ).toString("hex"); + + return { message: messageData, trustedBytes }; +} + export async function createFrameActionMessageWithSignerKey( signerKey: string, { diff --git a/packages/render/src/farcaster/signers.tsx b/packages/render/src/farcaster/signers.tsx index 08cd75195..58fe6b2a6 100644 --- a/packages/render/src/farcaster/signers.tsx +++ b/packages/render/src/farcaster/signers.tsx @@ -1,4 +1,5 @@ import type { FrameActionBodyPayload, SignerStateInstance } from "../types"; +import type { SignComposerActionFunc } from "../unstable-types"; import type { FarcasterFrameContext } from "./types"; export type FarcasterSignerState = @@ -6,7 +7,9 @@ export type FarcasterSignerState = TSignerType, FrameActionBodyPayload, FarcasterFrameContext - >; + > & { + signComposerAction: SignComposerActionFunc; + }; export type FarcasterSignerPendingApproval = { status: "pending_approval"; diff --git a/packages/render/src/helpers.ts b/packages/render/src/helpers.ts index 3346cc3ba..c18382b23 100644 --- a/packages/render/src/helpers.ts +++ b/packages/render/src/helpers.ts @@ -2,6 +2,7 @@ import type { ParseFramesWithReportsResult, ParseResult, } from "frames.js/frame-parsers"; +import type { ComposerActionFormResponse } from "frames.js/types"; import type { PartialFrame } from "./ui/types"; export async function tryCallAsync( @@ -71,3 +72,37 @@ export function isPartialFrame( value.frame.buttons.length > 0 ); } + +export function isComposerFormActionResponse( + response: unknown +): response is ComposerActionFormResponse { + return ( + typeof response === "object" && + response !== null && + "type" in response && + response.type === "form" + ); +} + +/** + * Merges all search params in order from left to right into the URL. + * + * @param url - The URL to merge the search params into. Either fully qualified or path only. + */ +export function mergeSearchParamsToUrl( + url: string, + ...searchParams: URLSearchParams[] +): string { + const temporaryDomain = "temporary-for-parsing-purposes.tld"; + const parsedProxyUrl = new URL(url, `http://${temporaryDomain}`); + + searchParams.forEach((params) => { + params.forEach((value, key) => { + parsedProxyUrl.searchParams.set(key, value); + }); + }); + + return parsedProxyUrl.hostname === temporaryDomain + ? `${parsedProxyUrl.pathname}${parsedProxyUrl.search}` + : parsedProxyUrl.toString(); +} diff --git a/packages/render/src/identity/farcaster/use-farcaster-identity.tsx b/packages/render/src/identity/farcaster/use-farcaster-identity.tsx index a3bcb0226..db3d73b82 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-identity.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-identity.tsx @@ -8,11 +8,12 @@ import { } from "react"; import { convertKeypairToHex, createKeypairEDDSA } from "../crypto"; import type { FarcasterSignerState } from "../../farcaster"; -import { signFrameAction } from "../../farcaster"; +import { signComposerAction, signFrameAction } from "../../farcaster"; import type { Storage } from "../types"; import { useVisibilityDetection } from "../../hooks/use-visibility-detection"; import { WebStorage } from "../storage"; import { useStorage } from "../../hooks/use-storage"; +import { useFreshRef } from "../../hooks/use-fresh-ref"; import { IdentityPoller } from "./identity-poller"; import type { FarcasterCreateSignerResult, @@ -187,16 +188,12 @@ export function useFarcasterIdentity({ return value; }, }); - const onImpersonateRef = useRef(onImpersonate); - onImpersonateRef.current = onImpersonate; - const onLogInRef = useRef(onLogIn); - onLogInRef.current = onLogIn; - const onLogInStartRef = useRef(onLogInStart); - onLogInStartRef.current = onLogInStart; - const onLogOutRef = useRef(onLogOut); - onLogOutRef.current = onLogOut; - const generateUserIdRef = useRef(generateUserId); - generateUserIdRef.current = generateUserId; + const onImpersonateRef = useFreshRef(onImpersonate); + const onLogInRef = useFreshRef(onLogIn); + const onLogInStartRef = useFreshRef(onLogInStart); + const onLogOutRef = useFreshRef(onLogOut); + const generateUserIdRef = useFreshRef(generateUserId); + const onMissingIdentityRef = useFreshRef(onMissingIdentity); const createFarcasterSigner = useCallback(async (): Promise => { @@ -300,7 +297,7 @@ export function useFarcasterIdentity({ console.error("@frames.js/render: API Call failed", error); throw error; } - }, [setState, signerUrl]); + }, [generateUserIdRef, onLogInStartRef, setState, signerUrl]); const impersonateUser = useCallback( async (fid: number) => { @@ -329,14 +326,14 @@ export function useFarcasterIdentity({ setIsLoading(false); } }, - [setState] + [generateUserIdRef, onImpersonateRef, setState] ); const onSignerlessFramePress = useCallback((): Promise => { - onMissingIdentity(); + onMissingIdentityRef.current(); return Promise.resolve(); - }, [onMissingIdentity]); + }, [onMissingIdentityRef]); const createSigner = useCallback(async () => { setIsLoading(true); @@ -354,7 +351,7 @@ export function useFarcasterIdentity({ return identityReducer(currentState, { type: "LOGOUT" }); }); - }, [setState]); + }, [onLogOutRef, setState]); const farcasterUser = state.status === "init" ? null : state; @@ -418,6 +415,7 @@ export function useFarcasterIdentity({ visibilityDetector, setState, enableIdentityPolling, + onLogInRef, ]); return useMemo( @@ -428,6 +426,7 @@ export function useFarcasterIdentity({ farcasterUser?.status === "approved" || farcasterUser?.status === "impersonating", signFrameAction, + signComposerAction, isLoadingSigner: isLoading, impersonateUser, onSignerlessFramePress, diff --git a/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx b/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx index 1a96b68b9..b5142bbf2 100644 --- a/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx +++ b/packages/render/src/identity/farcaster/use-farcaster-multi-identity.tsx @@ -8,11 +8,12 @@ import { } from "react"; import { convertKeypairToHex, createKeypairEDDSA } from "../crypto"; import type { FarcasterSignerState } from "../../farcaster"; -import { signFrameAction } from "../../farcaster"; +import { signComposerAction, signFrameAction } from "../../farcaster"; import type { Storage } from "../types"; import { useVisibilityDetection } from "../../hooks/use-visibility-detection"; import { WebStorage } from "../storage"; import { useStorage } from "../../hooks/use-storage"; +import { useFreshRef } from "../../hooks/use-fresh-ref"; import { IdentityPoller } from "./identity-poller"; import type { FarcasterCreateSignerResult, @@ -259,20 +260,14 @@ export function useFarcasterMultiIdentity({ identities: [], }, }); - const onImpersonateRef = useRef(onImpersonate); - onImpersonateRef.current = onImpersonate; - const onLogInRef = useRef(onLogIn); - onLogInRef.current = onLogIn; - const onLogInStartRef = useRef(onLogInStart); - onLogInStartRef.current = onLogInStart; - const onLogOutRef = useRef(onLogOut); - onLogOutRef.current = onLogOut; - const onIdentityRemoveRef = useRef(onIdentityRemove); - onIdentityRemoveRef.current = onIdentityRemove; - const onIdentitySelectRef = useRef(onIdentitySelect); - onIdentitySelectRef.current = onIdentitySelect; - const generateUserIdRef = useRef(generateUserId); - generateUserIdRef.current = generateUserId; + const onImpersonateRef = useFreshRef(onImpersonate); + const onLogInRef = useFreshRef(onLogIn); + const onLogInStartRef = useFreshRef(onLogInStart); + const onLogOutRef = useFreshRef(onLogOut); + const onIdentityRemoveRef = useFreshRef(onIdentityRemove); + const onIdentitySelectRef = useFreshRef(onIdentitySelect); + const generateUserIdRef = useFreshRef(generateUserId); + const onMissingIdentityRef = useFreshRef(onMissingIdentity); const createFarcasterSigner = useCallback(async (): Promise => { @@ -388,7 +383,7 @@ export function useFarcasterMultiIdentity({ console.error("@frames.js/render: API Call failed", error); throw error; } - }, [setState, signerUrl]); + }, [generateUserIdRef, onLogInStartRef, setState, signerUrl]); const impersonateUser = useCallback( async (fid: number) => { @@ -417,14 +412,14 @@ export function useFarcasterMultiIdentity({ setIsLoading(false); } }, - [setState] + [generateUserIdRef, onImpersonateRef, setState] ); const onSignerlessFramePress = useCallback((): Promise => { - onMissingIdentity(); + onMissingIdentityRef.current(); return Promise.resolve(); - }, [onMissingIdentity]); + }, [onMissingIdentityRef]); const createSigner = useCallback(async () => { setIsLoading(true); @@ -442,7 +437,7 @@ export function useFarcasterMultiIdentity({ return identityReducer(currentState, { type: "LOGOUT" }); }); - }, [setState]); + }, [onLogOutRef, setState]); const removeIdentity = useCallback(async () => { await setState((currentState) => { @@ -452,7 +447,7 @@ export function useFarcasterMultiIdentity({ return identityReducer(currentState, { type: "REMOVE" }); }); - }, [setState]); + }, [onIdentityRemoveRef, setState]); const farcasterUser = state.activeIdentity; @@ -534,6 +529,7 @@ export function useFarcasterMultiIdentity({ farcasterUser?.status === "approved" || farcasterUser?.status === "impersonating", signFrameAction, + signComposerAction, isLoadingSigner: isLoading, impersonateUser, onSignerlessFramePress, diff --git a/packages/render/src/mini-app-messages.ts b/packages/render/src/mini-app-messages.ts new file mode 100644 index 000000000..f1b1937cd --- /dev/null +++ b/packages/render/src/mini-app-messages.ts @@ -0,0 +1,135 @@ +import type { Abi, TypedDataDomain } from "viem"; +import { z } from "zod"; + +export type TransactionResponse = + | TransactionResponseSuccess + | TransactionResponseFailure; + +export type TransactionResponseSuccess = { + jsonrpc: "2.0"; + id: string | number | null; + result: TransactionSuccessBody; +}; + +export type TransactionSuccessBody = + | EthSendTransactionSuccessBody + | EthSignTypedDataV4SuccessBody; + +export type EthSendTransactionSuccessBody = { + address: `0x${string}`; + transactionHash: `0x${string}`; +}; + +export type EthSignTypedDataV4SuccessBody = { + address: `0x${string}`; + signature: `0x${string}`; +}; + +export type TransactionResponseFailure = { + jsonrpc: "2.0"; + id: string | number | null; + error: { + code: number; + message: string; + }; +}; + +export type CreateCastResponse = { + jsonrpc: "2.0"; + id: string | number | null; + result: { + success: true; + }; +}; + +export type MiniAppResponse = TransactionResponse | CreateCastResponse; + +const createCastRequestSchemaLegacy = z.object({ + type: z.literal("createCast"), + data: z.object({ + cast: z.object({ + parent: z.string().optional(), + text: z.string(), + embeds: z.array(z.string().min(1).url()).min(1), + }), + }), +}); + +export type CreateCastLegacyMessage = z.infer< + typeof createCastRequestSchemaLegacy +>; + +const createCastRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.union([z.string(), z.number(), z.null()]), + method: z.literal("fc_createCast"), + params: z.object({ + cast: z.object({ + parent: z.string().optional(), + text: z.string(), + embeds: z.array(z.string().min(1).url()).min(1), + }), + }), +}); + +export type CreateCastMessage = z.infer; + +const ethSendTransactionActionSchema = z.object({ + chainId: z.string(), + method: z.literal("eth_sendTransaction"), + attribution: z.boolean().optional(), + params: z.object({ + abi: z.custom(), + to: z.custom<`0x${string}`>( + (val): val is `0x${string}` => + typeof val === "string" && val.startsWith("0x") + ), + value: z.string().optional(), + data: z + .custom<`0x${string}`>((val): val is `0x${string}` => typeof val === "string" && val.startsWith("0x")) + .optional(), + }), +}); + +export type EthSendTransactionAction = z.infer< + typeof ethSendTransactionActionSchema +>; + +const ethSignTypedDataV4ActionSchema = z.object({ + chainId: z.string(), + method: z.literal("eth_signTypedData_v4"), + params: z.object({ + domain: z.custom(), + types: z.unknown(), + primaryType: z.string(), + message: z.record(z.unknown()), + }), +}); + +export type EthSignTypedDataV4Action = z.infer< + typeof ethSignTypedDataV4ActionSchema +>; + +const walletActionRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.string(), + method: z.literal("fc_requestWalletAction"), + params: z.object({ + action: z.union([ + ethSendTransactionActionSchema, + ethSignTypedDataV4ActionSchema, + ]), + }), +}); + +export type RequestWalletActionMessage = z.infer< + typeof walletActionRequestSchema +>; + +export const miniAppMessageSchema = z.union([ + createCastRequestSchemaLegacy, + walletActionRequestSchema, + createCastRequestSchema, +]); + +export type MiniAppMessage = z.infer; diff --git a/packages/render/src/next/POST.tsx b/packages/render/src/next/POST.tsx index c0d61e9f7..e9f216bb0 100644 --- a/packages/render/src/next/POST.tsx +++ b/packages/render/src/next/POST.tsx @@ -7,9 +7,48 @@ import { type ParseFramesWithReportsResult } from "frames.js/frame-parsers"; import { parseFramesWithReports } from "frames.js/parseFramesWithReports"; import type { JsonObject, JsonValue } from "frames.js/types"; import type { NextRequest } from "next/server"; +import { z } from "zod"; import { tryCallAsync } from "../helpers"; import { isSpecificationValid } from "./validators"; +const castActionMessageParser = z.object({ + type: z.literal("message"), + message: z.string().min(1), +}); + +const castActionFrameParser = z.object({ + type: z.literal("frame"), + frameUrl: z.string().min(1).url(), +}); + +const composerActionFormParser = z.object({ + type: z.literal("form"), + url: z.string().min(1).url(), + title: z.string().min(1), +}); + +const jsonResponseParser = z.preprocess( + (data) => { + if (typeof data === "object" && data !== null && !("type" in data)) { + return { + type: "message", + ...data, + }; + } + + return data; + }, + z.discriminatedUnion("type", [ + castActionFrameParser, + castActionMessageParser, + composerActionFormParser, + ]) +); + +const errorResponseParser = z.object({ + message: z.string().min(1), +}); + export type POSTResponseError = { message: string }; export type POSTResponseRedirect = { location: string }; @@ -23,15 +62,6 @@ export type POSTResponse = | POSTResponseRedirect | JsonObject; -function isJsonErrorObject(data: JsonValue): data is { message: string } { - return ( - typeof data === "object" && - data !== null && - "message" in data && - typeof data.message === "string" - ); -} - /** Proxies frame actions to avoid CORS issues and preserve user IP privacy */ export async function POST(req: Request | NextRequest): Promise { try { @@ -78,9 +108,11 @@ export async function POST(req: Request | NextRequest): Promise { ); } - if (isJsonErrorObject(jsonError)) { + const result = errorResponseParser.safeParse(jsonError); + + if (result.success) { return Response.json( - { message: jsonError.message } satisfies POSTResponseError, + { message: result.data.message } satisfies POSTResponseError, { status: response.status } ); } @@ -136,9 +168,11 @@ export async function POST(req: Request | NextRequest): Promise { ); } - if (isJsonErrorObject(jsonError)) { + const result = errorResponseParser.safeParse(jsonError); + + if (result.success) { return Response.json( - { message: jsonError.message } satisfies POSTResponseError, + { message: result.data.message } satisfies POSTResponseError, { status: response.status } ); } @@ -178,6 +212,32 @@ export async function POST(req: Request | NextRequest): Promise { return Response.json(transaction satisfies JsonObject); } + // Content type is JSON, could be an action + if ( + response.headers + .get("content-type") + ?.toLowerCase() + .includes("application/json") + ) { + const parseResult = await z + .promise(jsonResponseParser) + .safeParseAsync(response.clone().json()); + + if (!parseResult.success) { + throw new Error("Invalid frame response"); + } + + const headers = new Headers(response.headers); + // Proxied requests could have content-encoding set, which breaks the response + headers.delete("content-encoding"); + + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); + } + const html = await response.text(); if (multiSpecificationEnabled) { diff --git a/packages/render/src/unstable-types.ts b/packages/render/src/unstable-types.ts index cc0db711d..b6282cc5d 100644 --- a/packages/render/src/unstable-types.ts +++ b/packages/render/src/unstable-types.ts @@ -11,7 +11,10 @@ import type { ParseResultWithFrameworkDetails, } from "frames.js/frame-parsers"; import type { Dispatch } from "react"; -import type { ErrorMessageResponse } from "frames.js/types"; +import type { + ComposerActionState, + ErrorMessageResponse, +} from "frames.js/types"; import type { ButtonPressFunction, FrameContext, @@ -641,3 +644,32 @@ export type FrameState< TExtraMesssage >; }; + +export type SignerStateComposerActionContext = { + fid: number; + url: string; + state: ComposerActionState; +}; + +export type SignerComposerActionResult = { + untrustedData: { + fid: number; + url: string; + messageHash: `0x${string}`; + timestamp: number; + network: number; + buttonIndex: 1; + state: string; + }; + trustedData: { + messageBytes: string; + }; +}; + +/** + * Used to sign composer action + */ +export type SignComposerActionFunc = ( + signerPrivateKey: string, + actionContext: SignerStateComposerActionContext +) => Promise; diff --git a/packages/render/src/use-composer-action.ts b/packages/render/src/use-composer-action.ts new file mode 100644 index 000000000..514de6519 --- /dev/null +++ b/packages/render/src/use-composer-action.ts @@ -0,0 +1,613 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import type { + ComposerActionFormResponse, + ComposerActionState, +} from "frames.js/types"; +import type { FarcasterSignerState } from "./farcaster"; +import { useFreshRef } from "./hooks/use-fresh-ref"; +import { + isComposerFormActionResponse, + mergeSearchParamsToUrl, + tryCall, + tryCallAsync, +} from "./helpers"; +import { + ComposerActionUnexpectedResponseError, + ComposerActionUserRejectedRequestError, +} from "./errors"; +import type { + EthSendTransactionAction, + EthSignTypedDataV4Action, + MiniAppMessage, + MiniAppResponse, +} from "./mini-app-messages"; +import { miniAppMessageSchema } from "./mini-app-messages"; +import type { FarcasterSigner } from "./identity/farcaster"; + +export type { MiniAppMessage, MiniAppResponse }; + +type FetchComposerActionFunctionArg = { + actionState: ComposerActionState; + proxyUrl: string; + signer: FarcasterSigner | null; + url: string; +}; + +type FetchComposerActionFunction = ( + arg: FetchComposerActionFunctionArg +) => Promise; + +type RegisterMessageListener = ( + formResponse: ComposerActionFormResponse, + messageListener: MiniAppMessageListener +) => () => void; + +type MiniAppMessageListener = (message: MiniAppMessage) => Promise; + +type OnTransactionFunction = (arg: { + action: EthSendTransactionAction; +}) => Promise<{ + hash: `0x${string}`; + address: `0x${string}`; +} | null>; +type OnSignatureFunction = (arg: { + action: EthSignTypedDataV4Action; +}) => Promise<{ + hash: `0x${string}`; + address: `0x${string}`; +} | null>; +type OnCreateCastFunction = (arg: { + cast: ComposerActionState; +}) => Promise; + +export type UseComposerActionOptions = { + /** + * Current action state, value should be memoized. It doesn't cause composer action / mini app to refetch. + */ + actionState: ComposerActionState; + /** + * URL to composer action / mini app server + * + * If value changes it will refetch the composer action / mini app + */ + url: string; + /** + * Signer used to sign the composer action. + * + * If value changes it will refetch the composer action / mini app + */ + signer: FarcasterSignerState; + /** + * URL to the action proxy server. If value changes composer action / mini app will be refetched. + * + * Proxy must handle POST requests. + */ + proxyUrl: string; + /** + * If enabled if will fetch the composer action / mini app on mount. + * + * @defaultValue true + */ + enabled?: boolean; + onError?: (error: Error) => void; + onCreateCast: OnCreateCastFunction; + onTransaction: OnTransactionFunction; + onSignature: OnSignatureFunction; + /** + * Called when a response to a message is sent to target (e.g. iframe). + */ + onPostResponseToTarget: (response: MiniAppResponse) => unknown; + /** + * Allows to override how the message listener is registered. Function must return a function that removes the listener. + * + * Changes in the value aren't reflected so it's recommended to use a memoized function. + * + * By default it uses window.addEventListener("message", ...) + */ + registerMessageListener?: RegisterMessageListener; +}; + +type UseComposerActionResult = { + refetch: () => Promise; +} & ( + | { + status: "idle"; + data: undefined; + error: undefined; + } + | { + status: "loading"; + data: undefined; + error: undefined; + } + | { + status: "error"; + data: undefined; + error: Error; + } + | { + status: "success"; + data: ComposerActionFormResponse; + error: undefined; + } +); + +export function useComposerAction({ + actionState, + enabled = true, + proxyUrl, + signer, + url, + onError, + onCreateCast, + onSignature, + onTransaction, + registerMessageListener = defaultRegisterMessageListener, + onPostResponseToTarget, +}: UseComposerActionOptions): UseComposerActionResult { + const onErrorRef = useFreshRef(onError); + const [state, dispatch] = useReducer(composerActionReducer, { + status: "idle", + enabled, + }); + const registerMessageListenerRef = useFreshRef(registerMessageListener); + const actionStateRef = useFreshRef(actionState); + const onCreateCastRef = useFreshRef(onCreateCast); + const onPostResponseToTargetRef = useFreshRef(onPostResponseToTarget); + const onTransactionRef = useFreshRef(onTransaction); + const onSignatureRef = useFreshRef(onSignature); + const lastFetchActionArgRef = useRef( + null + ); + const signerRef = useFreshRef(signer); + + const messageListener = useCallback( + async (message) => { + if ("type" in message || message.method === "fc_createCast") { + const cast = + "type" in message ? message.data.cast : message.params.cast; + + const resultOrError = await tryCallAsync(() => + onCreateCastRef.current({ + cast, + }) + ); + + if (resultOrError instanceof Error) { + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: "method" in message ? message.id : null, + error: { + code: -32000, + message: resultOrError.message, + }, + }); + } + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: "method" in message ? message.id : null, + result: { + success: true, + }, + }); + } else if (message.method === "fc_requestWalletAction") { + if (message.params.action.method === "eth_sendTransaction") { + const action = message.params.action; + + const resultOrError = await tryCallAsync(() => + onTransactionRef.current({ + action, + }) + ); + + if (resultOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(resultOrError)); + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: resultOrError.message, + }, + }); + + return; + } + + if (!resultOrError) { + const error = new ComposerActionUserRejectedRequestError(); + + tryCall(() => onErrorRef.current?.(error)); + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: error.message, + }, + }); + return; + } + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + result: { + address: resultOrError.address, + transactionHash: resultOrError.hash, + }, + }); + } else if (message.params.action.method === "eth_signTypedData_v4") { + const action = message.params.action; + + const resultOrError = await tryCallAsync(() => + onSignatureRef.current({ + action, + }) + ); + + if (resultOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(resultOrError)); + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: resultOrError.message, + }, + }); + + return; + } + + if (!resultOrError) { + const error = new ComposerActionUserRejectedRequestError(); + + tryCall(() => onErrorRef.current?.(error)); + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32000, + message: error.message, + }, + }); + + return; + } + + onPostResponseToTargetRef.current({ + jsonrpc: "2.0", + id: message.id, + result: { + address: resultOrError.address, + signature: resultOrError.hash, + }, + }); + } else { + tryCall(() => + onErrorRef.current?.( + new Error( + `Unknown fc_requestWalletAction action method: ${message.params.action.method}` + ) + ) + ); + } + } else { + tryCall(() => onErrorRef.current?.(new Error("Unknown message"))); + } + }, + [ + onCreateCastRef, + onErrorRef, + onPostResponseToTargetRef, + onSignatureRef, + onTransactionRef, + ] + ); + + const fetchAction = useCallback( + async (arg) => { + const currentSigner = arg.signer; + + if ( + currentSigner?.status !== "approved" && + currentSigner?.status !== "impersonating" + ) { + await signerRef.current.onSignerlessFramePress(); + return; + } + + dispatch({ type: "loading-url" }); + + const signedDataOrError = await tryCallAsync(() => + signerRef.current.signComposerAction(currentSigner.privateKey, { + url: arg.url, + state: arg.actionState, + fid: currentSigner.fid, + }) + ); + + if (signedDataOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(signedDataOrError)); + dispatch({ type: "error", error: signedDataOrError }); + + return; + } + + const proxiedUrl = mergeSearchParamsToUrl( + arg.proxyUrl, + new URLSearchParams({ postUrl: arg.url }) + ); + + const actionResponseOrError = await tryCallAsync(() => + fetch(proxiedUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(signedDataOrError), + cache: "no-cache", + }) + ); + + if (actionResponseOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(actionResponseOrError)); + dispatch({ type: "error", error: actionResponseOrError }); + + return; + } + + if (!actionResponseOrError.ok) { + const error = new Error( + `Unexpected response status ${actionResponseOrError.status}` + ); + + tryCall(() => onErrorRef.current?.(error)); + dispatch({ type: "error", error }); + + return; + } + + const actionResponseDataOrError = await tryCallAsync( + () => actionResponseOrError.clone().json() as Promise + ); + + if (actionResponseDataOrError instanceof Error) { + tryCall(() => onErrorRef.current?.(actionResponseDataOrError)); + dispatch({ type: "error", error: actionResponseDataOrError }); + + return; + } + + if (!isComposerFormActionResponse(actionResponseDataOrError)) { + const error = new ComposerActionUnexpectedResponseError(); + tryCall(() => onErrorRef.current?.(error)); + dispatch({ type: "error", error }); + + return; + } + + dispatch({ type: "done", response: actionResponseDataOrError }); + }, + [onErrorRef] + ); + + const stateRef = useFreshRef(state); + const refetch = useCallback(() => { + if (!stateRef.current.enabled || !lastFetchActionArgRef.current) { + return Promise.resolve(); + } + + return fetchAction(lastFetchActionArgRef.current); + }, [fetchAction, stateRef]); + + useEffect(() => { + dispatch({ type: "enabled-change", enabled }); + }, [enabled]); + + useEffect(() => { + if (!enabled) { + return; + } + + lastFetchActionArgRef.current = { + actionState: actionStateRef.current, + signer: signer.signer as unknown as FarcasterSigner | null, + url, + proxyUrl, + }; + + fetchAction(lastFetchActionArgRef.current).catch((e) => { + onErrorRef.current?.(e instanceof Error ? e : new Error(String(e))); + }); + }, [ + url, + proxyUrl, + signer.signer, + enabled, + fetchAction, + actionStateRef, + onErrorRef, + ]); + + // register message listener when state changes to success + useEffect(() => { + if (state.status === "success") { + return registerMessageListenerRef.current( + state.response, + messageListener + ); + } + }, [messageListener, registerMessageListenerRef, state]); + + return useMemo(() => { + switch (state.status) { + case "idle": + return { + status: "idle", + data: undefined, + error: undefined, + refetch, + }; + case "loading": + return { + status: "loading", + data: undefined, + error: undefined, + refetch, + }; + case "error": + return { + status: "error", + data: undefined, + error: state.error, + refetch, + }; + default: + return { + status: "success", + data: state.response, + error: undefined, + refetch, + }; + } + }, [state, refetch]); +} + +const defaultRegisterMessageListener: RegisterMessageListener = + function defaultRegisterMessageListener(formResponse, messageListener) { + if (typeof window === "undefined") { + // eslint-disable-next-line no-console -- provide feedback + console.warn( + "@frames.js/render: You are using default registerMessageListener in an environment without window object" + ); + + return () => { + // noop + }; + } + + const miniAppOrigin = new URL(formResponse.url).origin; + + const messageParserListener = (event: MessageEvent): void => { + // make sure that we only listen to messages from the mini app + if (event.origin !== miniAppOrigin) { + return; + } + + const result = miniAppMessageSchema.safeParse(event.data); + + if (!result.success) { + // eslint-disable-next-line no-console -- provide feedback + console.warn( + "@frames.js/render: Invalid message received", + event.data, + result.error + ); + return; + } + + const message = result.data; + + messageListener(message).catch((e) => { + // eslint-disable-next-line no-console -- provide feedback + console.error(`@frames.js/render:`, e); + }); + }; + + window.addEventListener("message", messageParserListener); + + return () => { + window.removeEventListener("message", messageParserListener); + }; + }; + +type SharedComposerActionReducerState = { + enabled: boolean; +}; + +type ComposerActionReducerState = SharedComposerActionReducerState & + ( + | { + status: "idle"; + } + | { + status: "loading"; + } + | { + status: "error"; + error: Error; + } + | { + status: "success"; + response: ComposerActionFormResponse; + } + ); + +type ComposerActionReducerAction = + | { + type: "loading-url"; + } + | { + type: "error"; + error: Error; + } + | { + type: "done"; + response: ComposerActionFormResponse; + } + | { + type: "enabled-change"; + enabled: boolean; + }; + +function composerActionReducer( + state: ComposerActionReducerState, + action: ComposerActionReducerAction +): ComposerActionReducerState { + if (action.type === "enabled-change") { + if (action.enabled) { + return { + ...state, + enabled: true, + }; + } + + return { + status: "idle", + enabled: false, + }; + } + + if (!state.enabled) { + return state; + } + + switch (action.type) { + case "done": + return { + ...state, + status: "success", + response: action.response, + }; + case "loading-url": + return { + ...state, + status: "loading", + }; + case "error": + return { + ...state, + status: "error", + error: action.error, + }; + default: + return state; + } +}