Skip to content

Commit fc10b9b

Browse files
committed
feat: handle transactions from composer actions in debugger
1 parent b8a0e0a commit fc10b9b

File tree

7 files changed

+267
-11
lines changed

7 files changed

+267
-11
lines changed

packages/debugger/app/components/action-debugger.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ export const ActionDebugger = React.forwardRef<
447447
composerActionState: composerState,
448448
});
449449
}}
450+
onTransaction={farcasterFrameConfig.onTransaction}
450451
/>
451452
)}
452453
</TabsContent>

packages/debugger/app/components/composer-form-action-dialog.tsx

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,82 @@ import {
55
DialogFooter,
66
DialogContent,
77
} from "@/components/ui/dialog";
8+
import { OnTransactionFunc } from "@frames.js/render";
89
import type {
910
ComposerActionFormResponse,
1011
ComposerActionState,
1112
} from "frames.js/types";
12-
import { useEffect, useRef } from "react";
13+
import { useCallback, useEffect, useRef } from "react";
1314
import { z } from "zod";
1415

15-
const composerFormCreateCastMessageSchema = z.object({
16-
type: z.literal("createCast"),
17-
data: z.object({
18-
cast: z.object({
19-
parent: z.string().optional(),
20-
text: z.string(),
21-
embeds: z.array(z.string().min(1).url()).min(1),
22-
}),
23-
}),
16+
const miniappMessageSchema = z.object({
17+
type: z.string(),
18+
data: z.record(z.unknown()),
2419
});
2520

2621
type ComposerFormActionDialogProps = {
2722
composerActionForm: ComposerActionFormResponse;
2823
onClose: () => void;
2924
onSave: (arg: { composerState: ComposerActionState }) => void;
25+
onTransaction?: OnTransactionFunc;
3026
};
3127

3228
export function ComposerFormActionDialog({
3329
composerActionForm,
3430
onClose,
3531
onSave,
32+
onTransaction,
3633
}: ComposerFormActionDialogProps) {
3734
const onSaveRef = useRef(onSave);
3835
onSaveRef.current = onSave;
3936

37+
const iframeRef = useRef<HTMLIFrameElement>(null);
38+
39+
const postMessageToIframe = useCallback(
40+
(message: any) => {
41+
if (iframeRef.current && iframeRef.current.contentWindow) {
42+
iframeRef.current.contentWindow.postMessage(
43+
message,
44+
new URL(composerActionForm.url).origin
45+
);
46+
}
47+
},
48+
[composerActionForm.url]
49+
);
50+
4051
useEffect(() => {
4152
const handleMessage = (event: MessageEvent) => {
42-
const result = composerFormCreateCastMessageSchema.safeParse(event.data);
53+
const result = miniappMessageSchema.parse(event.data);
54+
55+
if (result?.type === "requestTransaction") {
56+
onTransaction?.({
57+
transactionData: result.data.tx as any,
58+
}).then((txHash) => {
59+
if (txHash) {
60+
postMessageToIframe({
61+
type: "transactionResponse",
62+
data: {
63+
requestId: result.data.requestId,
64+
success: true,
65+
receipt: {
66+
// address: farcasterFrameConfig.connectedAddress,
67+
transactionId: txHash,
68+
},
69+
},
70+
});
71+
} else {
72+
postMessageToIframe({
73+
type: "transactionResponse",
74+
data: {
75+
requestId: result.data.requestId,
76+
success: false,
77+
message: "User rejected the request",
78+
},
79+
});
80+
}
81+
});
82+
return;
83+
}
4384

4485
// on error is not called here because there can be different messages that don't have anything to do with composer form actions
4586
// instead we are just waiting for the correct message
@@ -80,6 +121,7 @@ export function ComposerFormActionDialog({
80121
<div>
81122
<iframe
82123
className="h-[600px] w-full opacity-100 transition-opacity duration-300"
124+
ref={iframeRef}
83125
src={composerActionForm.url}
84126
sandbox="allow-forms allow-scripts allow-same-origin"
85127
></iframe>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { NextRequest } from "next/server";
2+
import { appURL } from "../../../../../utils";
3+
import { frames } from "../../frames";
4+
import { composerAction, composerActionForm, error } from "frames.js/core";
5+
6+
export const GET = async (req: NextRequest) => {
7+
return composerAction({
8+
action: {
9+
type: "post",
10+
},
11+
icon: "credit-card",
12+
name: "Send a tx",
13+
aboutUrl: `${appURL()}/examples/transaction-miniapp`,
14+
description: "Send ETH to address",
15+
imageUrl: "https://framesjs.org/logo.png",
16+
});
17+
};
18+
19+
export const POST = frames(async (ctx) => {
20+
const walletAddress = await ctx.walletAddress();
21+
22+
const miniappUrl = new URL("/examples/transaction-miniapp/miniapp", appURL());
23+
24+
if (walletAddress) {
25+
miniappUrl.searchParams.set("fromAddress", walletAddress);
26+
} else {
27+
return error("Must be authenticated");
28+
}
29+
30+
return composerActionForm({
31+
title: "Send ETH",
32+
url: miniappUrl.toString(),
33+
});
34+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { createFrames } from "frames.js/next";
2+
import { appURL } from "../../../utils";
3+
import {
4+
farcasterHubContext,
5+
warpcastComposerActionState,
6+
} from "frames.js/middleware";
7+
import { DEFAULT_DEBUGGER_HUB_URL } from "../../../debug";
8+
9+
export const frames = createFrames({
10+
baseUrl: `${appURL()}/examples/transaction-miniapp/frames`,
11+
debug: process.env.NODE_ENV === "development",
12+
middleware: [
13+
farcasterHubContext({
14+
hubHttpUrl: DEFAULT_DEBUGGER_HUB_URL,
15+
}),
16+
warpcastComposerActionState(), // necessary to detect and parse state necessary for composer actions
17+
],
18+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/* eslint-disable react/jsx-key */
2+
import { Button } from "frames.js/next";
3+
import { frames } from "./frames";
4+
import { appURL } from "../../../utils";
5+
6+
function constructCastActionUrl(params: { url: string }): string {
7+
// Construct the URL
8+
const baseUrl = "https://warpcast.com/~/composer-action";
9+
const urlParams = new URLSearchParams({
10+
url: params.url,
11+
});
12+
13+
return `${baseUrl}?${urlParams.toString()}`;
14+
}
15+
16+
export const GET = frames(async (ctx) => {
17+
const transactionMiniappUrl = constructCastActionUrl({
18+
url: `${appURL()}/examples/transaction-miniapp/frames/actions/miniapp`,
19+
});
20+
21+
return {
22+
image: <div>Transaction Miniapp Example</div>,
23+
buttons: [
24+
<Button action="link" target={transactionMiniappUrl}>
25+
Transaction Miniapp
26+
</Button>,
27+
],
28+
};
29+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useState } from "react";
4+
5+
// pass state from frame message
6+
export default function MiniappPage({
7+
searchParams,
8+
}: {
9+
// provided by URL returned from composer action server
10+
searchParams: {
11+
fromAddress: string;
12+
};
13+
}) {
14+
const [message, setMessage] = useState<any>(null);
15+
16+
const handleMessage = useCallback((m: MessageEvent) => {
17+
console.log("received", m);
18+
19+
if (m.source === window.parent) {
20+
setMessage(m.data);
21+
}
22+
}, []);
23+
24+
useEffect(() => {
25+
window.addEventListener("message", handleMessage);
26+
27+
return () => {
28+
window.removeEventListener("message", handleMessage);
29+
};
30+
}, []);
31+
32+
const handleSubmit = useCallback(
33+
(e: React.FormEvent<HTMLFormElement>) => {
34+
e.preventDefault();
35+
const formData = new FormData(e.currentTarget);
36+
const ethAmount = formData.get("ethAmount") as string;
37+
const recipientAddress = formData.get(
38+
"recipientAddress"
39+
) as `0x${string}`;
40+
41+
// Handle form submission here
42+
window.parent.postMessage(
43+
{
44+
type: "requestTransaction",
45+
data: {
46+
requestId: "01ef6570-5a51-48fa-910c-f419400a6d0d",
47+
tx: {
48+
chainId: "eip155:10",
49+
method: "eth_sendTransaction",
50+
params: {
51+
abi: [],
52+
to: recipientAddress,
53+
value: (BigInt(ethAmount) * BigInt(10 ** 18)).toString(),
54+
},
55+
},
56+
},
57+
},
58+
"*"
59+
);
60+
},
61+
[window?.parent]
62+
);
63+
64+
return (
65+
<div>
66+
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
67+
<label htmlFor="eth-amount" className="font-semibold">
68+
ETH Amount
69+
</label>
70+
<input
71+
className="rounded border border-slate-800 p-2"
72+
id="eth-amount"
73+
name="ethAmount"
74+
placeholder="0.1"
75+
type="number"
76+
step="0.000000000000000001"
77+
min="0"
78+
value={"0"}
79+
required
80+
/>
81+
82+
<label htmlFor="recipient-address" className="font-semibold">
83+
Recipient Address
84+
</label>
85+
<input
86+
className="rounded border border-slate-800 p-2"
87+
id="recipient-address"
88+
name="recipientAddress"
89+
placeholder="0x..."
90+
type="text"
91+
value={searchParams.fromAddress}
92+
required
93+
/>
94+
95+
<button className="rounded bg-slate-800 text-white p-2" type="submit">
96+
Send ETH
97+
</button>
98+
</form>
99+
{message?.data?.success ? (
100+
<div>Transaction sent successfully</div>
101+
) : (
102+
<div className="text-red-500">{message?.data?.message}</div>
103+
)}
104+
</div>
105+
);
106+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { createExampleURL } from "../../utils";
2+
import type { Metadata } from "next";
3+
import { fetchMetadata } from "frames.js/next";
4+
import { Frame } from "../../components/Frame";
5+
6+
export async function generateMetadata(): Promise<Metadata> {
7+
return {
8+
title: "Frames.js Transaction Miniapp",
9+
other: {
10+
...(await fetchMetadata(
11+
createExampleURL("/examples/transaction-miniapp/frames")
12+
)),
13+
},
14+
};
15+
}
16+
17+
export default async function Home() {
18+
const metadata = await generateMetadata();
19+
20+
return (
21+
<Frame
22+
metadata={metadata}
23+
url={createExampleURL("/examples/transaction-miniapp/frames")}
24+
/>
25+
);
26+
}

0 commit comments

Comments
 (0)