From 0e7244de63cd50960004ff67dec438e8965aa49a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasni=C4=8D=C3=A1k?= Date: Wed, 8 Jan 2025 12:28:49 +0100 Subject: [PATCH 1/2] chore: update dependencies and fix debugger issues (#534) * feat: update dependencies, add new methods * fix: allow localhost as domain in account association * fix: typo * fix: load dev env variables from backend * fix: in non strict mode only warn instead of error on non https urls * test: correctly test non strict parser * feat: farcaster v2 sign in * chore: changeset * refactor: remove signer dependency from useFrameApp hook --- .changeset/five-zoos-wash.md | 7 + packages/debugger/app/client-info/route.ts | 17 + ...ster-domain-account-association-dialog.tsx | 55 ++- .../app/components/frame-app-debugger.tsx | 455 ++++++++++++------ packages/debugger/package.json | 5 +- packages/frames.js/package.json | 6 +- .../src/frame-parsers/farcasterV2.test.ts | 24 +- .../src/frame-parsers/farcasterV2.ts | 22 +- packages/render/package.json | 10 +- packages/render/src/frame-app/types.ts | 42 +- packages/render/src/use-frame-app.ts | 154 +++--- yarn.lock | 198 ++++++-- 12 files changed, 697 insertions(+), 298 deletions(-) create mode 100644 .changeset/five-zoos-wash.md create mode 100644 packages/debugger/app/client-info/route.ts diff --git a/.changeset/five-zoos-wash.md b/.changeset/five-zoos-wash.md new file mode 100644 index 000000000..f946cc539 --- /dev/null +++ b/.changeset/five-zoos-wash.md @@ -0,0 +1,7 @@ +--- +"frames.js": patch +"@frames.js/debugger": patch +"@frames.js/render": patch +--- + +feat: farcaster v2 sign in and libs update diff --git a/packages/debugger/app/client-info/route.ts b/packages/debugger/app/client-info/route.ts new file mode 100644 index 000000000..c47278274 --- /dev/null +++ b/packages/debugger/app/client-info/route.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export function GET() { + return Response.json( + { + fid: z.coerce + .number() + .int() + .parse(process.env.FARCASTER_DEVELOPER_FID || "-1"), + }, + { + headers: { + "Cache-Control": "no-store", + }, + } + ); +} diff --git a/packages/debugger/app/components/farcaster-domain-account-association-dialog.tsx b/packages/debugger/app/components/farcaster-domain-account-association-dialog.tsx index b65c1403c..1036c9890 100644 --- a/packages/debugger/app/components/farcaster-domain-account-association-dialog.tsx +++ b/packages/debugger/app/components/farcaster-domain-account-association-dialog.tsx @@ -12,8 +12,9 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useAccount, useSignMessage, useSwitchChain } from "wagmi"; -import { FormEvent, useCallback, useState } from "react"; +import { FormEvent, useCallback, useRef, useState } from "react"; import { CopyIcon, CopyCheckIcon, CopyXIcon, Loader2Icon } from "lucide-react"; +import { z } from "zod"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity"; @@ -28,6 +29,7 @@ type FarcasterDomainAccountAssociationDialogProps = { export function FarcasterDomainAccountAssociationDialog({ onClose, }: FarcasterDomainAccountAssociationDialogProps) { + const domainInputRef = useRef(null); const copyCompact = useCopyToClipboard(); const copyJSON = useCopyToClipboard(); const account = useAccount(); @@ -44,7 +46,7 @@ export function FarcasterDomainAccountAssociationDialog({ async (event: FormEvent) => { event.preventDefault(); - const data = new FormData(event.currentTarget); + const data = Object.fromEntries(new FormData(event.currentTarget)); try { if (farcasterSigner.signer?.status !== "approved") { @@ -55,12 +57,36 @@ export function FarcasterDomainAccountAssociationDialog({ throw new Error("Account address is not available"); } - const domain = data.get("domain"); + const parser = z.object({ + domain: z + .preprocess((val) => { + if (typeof val === "string") { + // prepend with prefix because normally it is the domain but we want to validate + // it is in valid format + return `http://${val}`; + } - if (typeof domain !== "string" || !domain) { - throw new Error("Domain is required"); + return val; + }, z.string().url("Invalid domain")) + // remove the protocol prefix + .transform((val) => val.substring(7)), + }); + + const parseResult = parser.safeParse(data); + + if (!parseResult.success) { + parseResult.error.errors.map((error) => { + domainInputRef.current?.setCustomValidity(error.message); + }); + + event.currentTarget.reportValidity(); + + return; } + domainInputRef.current?.setCustomValidity(""); + event.currentTarget.reportValidity(); + setIsGenerating(true); await switchChainAsync({ @@ -69,8 +95,9 @@ export function FarcasterDomainAccountAssociationDialog({ const result = await sign({ fid: farcasterSigner.signer.fid, - payload: - constructJSONFarcasterSignatureAccountAssociationPaylod(domain), + payload: constructJSONFarcasterSignatureAccountAssociationPaylod( + parseResult.data.domain + ), signer: { type: "custody", custodyAddress: account.address, @@ -117,15 +144,25 @@ export function FarcasterDomainAccountAssociationDialog({ Domain Account Association {!associationResult && ( -
+ + + A domain of your frame, e.g. for https://framesjs.org the domain + is framesjs.org, for http://localhost:3000 the domain is + localhost. +
)} {associationResult && ( diff --git a/packages/debugger/app/components/frame-app-debugger.tsx b/packages/debugger/app/components/frame-app-debugger.tsx index e86b19402..6f710b042 100644 --- a/packages/debugger/app/components/frame-app-debugger.tsx +++ b/packages/debugger/app/components/frame-app-debugger.tsx @@ -1,3 +1,5 @@ +import "@farcaster/auth-kit/styles.css"; +import { createAppClient, viemConnector, QRCode } from "@farcaster/auth-kit"; import { Button } from "@/components/ui/button"; import type { FrameLaunchedInContext } from "./frame-debugger"; import { WithTooltip } from "./with-tooltip"; @@ -23,6 +25,11 @@ import type { FramePrimaryButton, ResolveClientFunction, } from "@frames.js/render/frame-app/types"; +import { useConfig } from "wagmi"; +import type { EIP6963ProviderInfo } from "@farcaster/frame-sdk"; +import { z } from "zod"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboad"; type TabValues = "events" | "console" | "notifications"; @@ -47,13 +54,36 @@ const addFrameRequestsCache = new (class extends Set { } })(); +const appClient = createAppClient({ + ethereum: viemConnector(), +}); + export function FrameAppDebugger({ context, farcasterSigner, onClose, }: FrameAppDebuggerProps) { + const copyFarcasterSignInLink = useCopyToClipboard(); + const [ + farcasterSignInAbortControllerAndURL, + setFarcasterSignInAbortControllerURL, + ] = useState<{ controller: AbortController; url: URL } | null>(null); + const config = useConfig(); const farcasterSignerRef = useRef(farcasterSigner); farcasterSignerRef.current = farcasterSigner; + + const userContext = useRef<{ fid: number }>({ fid: -1 }); + + if ( + (farcasterSigner.signer?.status === "approved" || + farcasterSigner.signer?.status === "impersonating") && + userContext.current.fid !== farcasterSigner.signer.fid + ) { + userContext.current = { + fid: farcasterSigner.signer.fid, + }; + } + const frameAppNotificationManager = useFrameAppNotificationsManager({ farcasterSigner, context, @@ -79,8 +109,20 @@ export function FrameAppDebugger({ ); const resolveClient: ResolveClientFunction = useCallback(async () => { try { + const clientInfoResponse = await fetch("/client-info"); + + if (!clientInfoResponse.ok) { + throw new Error("Failed to fetch client info"); + } + + const parseClientInfo = z.object({ + fid: z.number().int(), + }); + + const clientInfo = parseClientInfo.parse(await clientInfoResponse.json()); + const { manager } = await frameAppNotificationManagerPromiseRef.current; - const clientFid = parseInt(process.env.FARCASTER_DEVELOPER_FID ?? "-1"); + const clientFid = clientInfo.fid; if (!manager.state || manager.state.frame.status === "removed") { return { @@ -90,7 +132,7 @@ export function FrameAppDebugger({ } return { - clientFid: parseInt(process.env.FARCASTER_DEVELOPER_FID ?? "-1"), + clientFid, added: true, notificationDetails: manager.state.frame.notificationDetails ?? undefined, @@ -104,12 +146,12 @@ export function FrameAppDebugger({ "Failed to load notifications settings. Check the console for more details.", variant: "destructive", }); - } - return { - clientFid: parseInt(process.env.FARCASTER_DEVELOPER_FID ?? "-1"), - added: false, - }; + return { + clientFid: -1, + added: false, + }; + } }, [toast]); const frameApp = useFrameAppInIframe({ debug: true, @@ -122,9 +164,10 @@ export function FrameAppDebugger({ } : { type: "cast_embed", + embed: "", cast: fallbackFrameContext.castId, }, - farcasterSigner, + user: userContext.current, provider, proxyUrl: "/frames", addFrameRequestsCache, @@ -233,150 +276,282 @@ export function FrameAppDebugger({ throw e; } }, + onEIP6963RequestProviderRequested({ endpoint }) { + if (!config._internal.mipd) { + return; + } + + config._internal.mipd.getProviders().map((providerInfo) => { + endpoint.emit({ + event: "eip6963:announceProvider", + info: providerInfo.info as EIP6963ProviderInfo, + }); + }); + }, + async onSignIn({ nonce, notBefore, expirationTime, frame }) { + let abortTimeout: NodeJS.Timeout | undefined; + + try { + const frameUrl = frame.frame.button?.action?.url; + + if (!frameUrl) { + throw new Error("Frame is malformed, action url is missing"); + } + + const createChannelResult = await appClient.createChannel({ + nonce, + notBefore, + expirationTime, + siweUri: frameUrl, + domain: new URL(frameUrl).hostname, + }); + + if (createChannelResult.isError) { + throw ( + createChannelResult.error || + new Error("Failed to create sign in channel") + ); + } + + const abortController = new AbortController(); + + setFarcasterSignInAbortControllerURL({ + controller: abortController, + url: new URL(createChannelResult.data.url), + }); + + const signInTimeoutReason = "Sign in timed out"; + + // abort controller after 30 seconds + abortTimeout = setTimeout(() => { + abortController.abort(signInTimeoutReason); + }, 30000); + + let status: Awaited>; + + while (true) { + if (abortController.signal.aborted) { + if (abortTimeout) { + clearTimeout(abortTimeout); + } + + if (abortController.signal.reason === signInTimeoutReason) { + toast({ + title: "Sign in timed out", + variant: "destructive", + }); + } + + throw new Error(abortController.signal.reason); + } + + status = await appClient.status({ + channelToken: createChannelResult.data.channelToken, + }); + + if (!status.isError && status.data.state === "completed") { + break; + } + + await new Promise((r) => setTimeout(r, 1000)); + } + + clearTimeout(abortTimeout); + + const { message, signature } = status.data; + + if (!(signature && message)) { + throw new Error("Signature or message is missing"); + } + + return { + signature, + message, + }; + } finally { + clearTimeout(abortTimeout); + setFarcasterSignInAbortControllerURL(null); + } + }, }); return ( -
-
-
- Reload frame app

}> - -
-
-
-
-
+ {!!farcasterSignInAbortControllerAndURL && ( + { + farcasterSignInAbortControllerAndURL.controller.abort( + "User closed sign in dialog" + ); + }} > - {frameApp.status === "pending" || - (!isAppReady && ( -
+
+

Sign in with Farcaster

+ + or + +
+ +
+ )} +
+
+
+ Reload frame app

}> + +
+
+
+
+
+ {frameApp.status === "pending" || + (!isAppReady && ( +
+ {context.frame.button.action.splashImageUrl && ( +
+ {`${name} +
+ +
+ )} +
+ ))} + {frameApp.status === "success" && ( + <> +