Skip to content

Commit

Permalink
feat: handle transactions from composer actions in debugger (framesjs…
Browse files Browse the repository at this point in the history
…#513)

* feat: handle transactions from composer actions in debugger

* feat: update types, support signatures

* chore: changeset

* feat: miniapp transaction example

* fix: build
  • Loading branch information
stephancill authored Oct 24, 2024
1 parent bd4f30f commit ac0af6c
Show file tree
Hide file tree
Showing 13 changed files with 480 additions and 36 deletions.
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

0 comments on commit ac0af6c

Please sign in to comment.