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

feat: handle transactions from composer actions in debugger #513

Merged
merged 5 commits into from
Oct 24, 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
5 changes: 5 additions & 0 deletions .changeset/gentle-beers-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@frames.js/render": patch
---

fix: allow onTransaction/onSignature to be called from contexts outside of frame e.g. miniapp
6 changes: 6 additions & 0 deletions .changeset/lazy-deers-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"template-next-starter-with-examples": patch
"create-frames": patch
---

feat: miniapp transaction example
5 changes: 5 additions & 0 deletions .changeset/violet-elephants-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@frames.js/debugger": patch
---

feat: composer action transaction support
3 changes: 3 additions & 0 deletions packages/debugger/app/components/action-debugger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ export const ActionDebugger = React.forwardRef<

{!!composeFormActionDialogSignal && (
<ComposerFormActionDialog
connectedAddress={farcasterFrameConfig.connectedAddress}
composerActionForm={composeFormActionDialogSignal.data}
onClose={() => {
composeFormActionDialogSignal.resolve(undefined);
Expand All @@ -447,6 +448,8 @@ export const ActionDebugger = React.forwardRef<
composerActionState: composerState,
});
}}
onTransaction={farcasterFrameConfig.onTransaction}
onSignature={farcasterFrameConfig.onSignature}
/>
)}
</TabsContent>
Expand Down
159 changes: 148 additions & 11 deletions packages/debugger/app/components/composer-form-action-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogFooter,
DialogContent,
} from "@/components/ui/dialog";
import { OnSignatureFunc, OnTransactionFunc } from "@frames.js/render";
import type {
ComposerActionFormResponse,
ComposerActionState,
} from "frames.js/types";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { Abi, TypedDataDomain } from "viem";
import { z } from "zod";

const composerFormCreateCastMessageSchema = z.object({
Expand All @@ -23,38 +25,172 @@ const composerFormCreateCastMessageSchema = z.object({
}),
});

const ethSendTransactionActionSchema = z.object({
chainId: z.string(),
method: z.literal("eth_sendTransaction"),
attribution: z.boolean().optional(),
params: z.object({
abi: z.custom<Abi>(),
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(),
}),
});

const ethSignTypedDataV4ActionSchema = z.object({
chainId: z.string(),
method: z.literal("eth_signTypedData_v4"),
params: z.object({
domain: z.custom<TypedDataDomain>(),
types: z.unknown(),
primaryType: z.string(),
message: z.record(z.unknown()),
}),
});

const transactionRequestBodySchema = z.object({
requestId: z.string(),
tx: z.union([ethSendTransactionActionSchema, ethSignTypedDataV4ActionSchema]),
});

const composerActionMessageSchema = z.discriminatedUnion("type", [
composerFormCreateCastMessageSchema,
z.object({
type: z.literal("requestTransaction"),
data: transactionRequestBodySchema,
}),
]);

type ComposerFormActionDialogProps = {
composerActionForm: ComposerActionFormResponse;
onClose: () => void;
onSave: (arg: { composerState: ComposerActionState }) => void;
onTransaction?: OnTransactionFunc;
onSignature?: OnSignatureFunc;
// TODO: Consider moving this into return value of onTransaction
connectedAddress?: `0x${string}`;
};

export function ComposerFormActionDialog({
composerActionForm,
onClose,
onSave,
onTransaction,
onSignature,
connectedAddress,
}: ComposerFormActionDialogProps) {
const onSaveRef = useRef(onSave);
onSaveRef.current = onSave;

const iframeRef = useRef<HTMLIFrameElement>(null);

const postMessageToIframe = useCallback(
(message: any) => {
if (iframeRef.current && iframeRef.current.contentWindow) {
iframeRef.current.contentWindow.postMessage(
message,
new URL(composerActionForm.url).origin
);
}
},
[composerActionForm.url]
);

useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const result = composerFormCreateCastMessageSchema.safeParse(event.data);
const result = composerActionMessageSchema.safeParse(event.data);

// on error is not called here because there can be different messages that don't have anything to do with composer form actions
// instead we are just waiting for the correct message
if (!result.success) {
console.warn("Invalid message received", event.data);
console.warn("Invalid message received", event.data, result.error);
return;
}

if (result.data.data.cast.embeds.length > 2) {
console.warn("Only first 2 embeds are shown in the cast");
}
const message = result.data;

onSaveRef.current({
composerState: result.data.data.cast,
});
if (message.type === "requestTransaction") {
if (message.data.tx.method === "eth_sendTransaction") {
onTransaction?.({
transactionData: message.data.tx,
}).then((txHash) => {
if (txHash) {
postMessageToIframe({
type: "transactionResponse",
data: {
requestId: message.data.requestId,
success: true,
receipt: {
address: connectedAddress,
transactionId: txHash,
},
},
});
} else {
postMessageToIframe({
type: "transactionResponse",
data: {
requestId: message.data.requestId,
success: false,
message: "User rejected the request",
},
});
}
});
} else if (message.data.tx.method === "eth_signTypedData_v4") {
onSignature?.({
signatureData: {
chainId: message.data.tx.chainId,
method: "eth_signTypedData_v4",
params: {
domain: message.data.tx.params.domain,
types: message.data.tx.params.types as any,
primaryType: message.data.tx.params.primaryType,
message: message.data.tx.params.message,
},
},
}).then((signature) => {
if (signature) {
postMessageToIframe({
type: "signatureResponse",
data: {
requestId: message.data.requestId,
success: true,
receipt: {
address: connectedAddress,
transactionId: signature,
},
},
});
} else {
postMessageToIframe({
type: "signatureResponse",
data: {
requestId: message.data.requestId,
success: false,
message: "User rejected the request",
},
});
}
});
}
} else if (message.type === "createCast") {
if (message.data.cast.embeds.length > 2) {
console.warn("Only first 2 embeds are shown in the cast");
}

onSaveRef.current({
composerState: message.data.cast,
});
}
};

window.addEventListener("message", handleMessage);
Expand All @@ -80,6 +216,7 @@ export function ComposerFormActionDialog({
<div>
<iframe
className="h-[600px] w-full opacity-100 transition-opacity duration-300"
ref={iframeRef}
src={composerActionForm.url}
sandbox="allow-forms allow-scripts allow-same-origin"
></iframe>
Expand Down
Loading
Loading