Skip to content

Commit be4a226

Browse files
feat: add domain account association generator to debugger
1 parent 792db1a commit be4a226

File tree

3 files changed

+284
-1
lines changed

3 files changed

+284
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import {
2+
constructJSONFarcasterSignatureAccountAssociationPaylod,
3+
sign,
4+
type SignResult,
5+
} from "frames.js/farcaster-v2/json-signature";
6+
import { Button } from "@/components/ui/button";
7+
import {
8+
Dialog,
9+
DialogContent,
10+
DialogFooter,
11+
DialogHeader,
12+
DialogTitle,
13+
} from "@/components/ui/dialog";
14+
import { useAccount, useSignMessage, useSwitchChain } from "wagmi";
15+
import { FormEvent, useCallback, useState } from "react";
16+
import { CopyIcon, CopyCheckIcon, CopyXIcon, Loader2Icon } from "lucide-react";
17+
import { Input } from "@/components/ui/input";
18+
import { Label } from "@/components/ui/label";
19+
import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity";
20+
import { optimism } from "viem/chains";
21+
import { useToast } from "@/components/ui/use-toast";
22+
import { useCopyToClipboard } from "../hooks/useCopyToClipboad";
23+
24+
type FarcasterDomainAccountAssociationDialogProps = {
25+
onClose: () => void;
26+
};
27+
28+
export function FarcasterDomainAccountAssociationDialog({
29+
onClose,
30+
}: FarcasterDomainAccountAssociationDialogProps) {
31+
const copyCompact = useCopyToClipboard();
32+
const copyJSON = useCopyToClipboard();
33+
const account = useAccount();
34+
const { toast } = useToast();
35+
const farcasterSigner = useFarcasterIdentity();
36+
const { switchChainAsync } = useSwitchChain();
37+
const { signMessageAsync } = useSignMessage();
38+
const [isGenerating, setIsGenerating] = useState(false);
39+
const [associationResult, setAssociationResult] = useState<SignResult | null>(
40+
null
41+
);
42+
43+
const handleSubmit = useCallback(
44+
async (event: FormEvent<HTMLFormElement>) => {
45+
event.preventDefault();
46+
47+
const data = new FormData(event.currentTarget);
48+
49+
try {
50+
if (farcasterSigner.signer?.status !== "approved") {
51+
throw new Error("Farcaster signer is not approved");
52+
}
53+
54+
if (!account.address) {
55+
throw new Error("Account address is not available");
56+
}
57+
58+
const domain = data.get("domain");
59+
60+
if (typeof domain !== "string" || !domain) {
61+
throw new Error("Domain is required");
62+
}
63+
64+
setIsGenerating(true);
65+
66+
await switchChainAsync({
67+
chainId: optimism.id,
68+
});
69+
70+
const result = await sign({
71+
fid: farcasterSigner.signer.fid,
72+
payload:
73+
constructJSONFarcasterSignatureAccountAssociationPaylod(domain),
74+
signer: {
75+
type: "custody",
76+
custodyAddress: account.address,
77+
},
78+
signMessage(message) {
79+
return signMessageAsync({
80+
message,
81+
});
82+
},
83+
});
84+
85+
setAssociationResult(result);
86+
} catch (e) {
87+
console.error(e);
88+
toast({
89+
title: "An error occurred",
90+
description: "Please check the console for more information",
91+
variant: "destructive",
92+
});
93+
} finally {
94+
setIsGenerating(false);
95+
}
96+
},
97+
[
98+
account.address,
99+
farcasterSigner.signer,
100+
signMessageAsync,
101+
switchChainAsync,
102+
toast,
103+
]
104+
);
105+
106+
return (
107+
<Dialog
108+
open
109+
onOpenChange={(isOpen) => {
110+
if (!isOpen) {
111+
onClose();
112+
}
113+
}}
114+
>
115+
<DialogContent>
116+
<DialogHeader>
117+
<DialogTitle>Domain Account Association</DialogTitle>
118+
</DialogHeader>
119+
{!associationResult && (
120+
<form id="domain-account-association-form" onSubmit={handleSubmit}>
121+
<Label htmlFor="domain">Domain</Label>
122+
<Input
123+
id="domain"
124+
name="domain"
125+
pattern="^.+\..+$"
126+
required
127+
type="text"
128+
/>
129+
</form>
130+
)}
131+
{associationResult && (
132+
<div className="flex flex-col gap-4">
133+
<div>
134+
<Label htmlFor="domain-account-association-form-compact-signature">
135+
Compact signature
136+
</Label>
137+
<div className="flex w-full items-center space-x-2">
138+
<Input
139+
id="domain-account-association-form-compact-signature"
140+
readOnly
141+
value={associationResult.compact}
142+
/>
143+
<Button
144+
onClick={() => {
145+
copyCompact.copyToClipboard(associationResult.compact);
146+
}}
147+
size="icon"
148+
type="button"
149+
variant="secondary"
150+
>
151+
{copyCompact.copyState === "idle" && <CopyIcon size={14} />}
152+
{copyCompact.copyState === "copied" && (
153+
<CopyCheckIcon size={14} />
154+
)}
155+
{copyCompact.copyState === "failed" && (
156+
<CopyXIcon size={14} />
157+
)}
158+
</Button>
159+
</div>
160+
</div>
161+
<div>
162+
<Label htmlFor="domain-account-association-form-json-signature">
163+
JSON
164+
</Label>
165+
<div className="relative">
166+
<textarea
167+
className="p-2 bg-gray-100 rounded-md font-mono w-full resize-none text-sm"
168+
readOnly
169+
name="json"
170+
rows={5}
171+
id="domain-account-association-form-json-signature"
172+
value={JSON.stringify(associationResult.json, null, 2)}
173+
></textarea>
174+
<Button
175+
className="absolute top-0 right-0 p-2"
176+
onClick={() => {
177+
copyJSON.copyToClipboard(
178+
JSON.stringify(associationResult.json, null, 2)
179+
);
180+
}}
181+
size="icon"
182+
type="button"
183+
variant="ghost"
184+
>
185+
{copyJSON.copyState === "idle" && <CopyIcon size={14} />}
186+
{copyJSON.copyState === "copied" && (
187+
<CopyCheckIcon size={14} />
188+
)}
189+
{copyJSON.copyState === "failed" && <CopyXIcon size={14} />}
190+
</Button>
191+
</div>
192+
</div>
193+
</div>
194+
)}
195+
<DialogFooter>
196+
{associationResult && (
197+
<Button
198+
onClick={() => {
199+
setAssociationResult(null);
200+
}}
201+
type="button"
202+
>
203+
Reset
204+
</Button>
205+
)}
206+
{!associationResult && (
207+
<Button
208+
disabled={isGenerating}
209+
form="domain-account-association-form"
210+
type="submit"
211+
>
212+
{isGenerating ? (
213+
<>
214+
<Loader2Icon className="animate-spin mr-2" />
215+
Generating...
216+
</>
217+
) : (
218+
"Generate"
219+
)}
220+
</Button>
221+
)}
222+
</DialogFooter>
223+
</DialogContent>
224+
</Dialog>
225+
);
226+
}

packages/debugger/app/components/protocol-config-button.tsx

+21-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
99
import { isAddress } from "viem";
1010
import FarcasterSignerWindow from "./farcaster-signer-config";
11-
import { forwardRef, useMemo } from "react";
11+
import { forwardRef, useMemo, useState } from "react";
1212
import { WithTooltip } from "./with-tooltip";
1313
import { type AnonymousSignerInstance } from "@frames.js/render/identity/anonymous";
1414
import {
@@ -23,6 +23,7 @@ import {
2323
useXmtpFrameContext,
2424
type XmtpSignerInstance,
2525
} from "@frames.js/render/identity/xmtp";
26+
import { FarcasterDomainAccountAssociationDialog } from "./farcaster-domain-account-association-dialog";
2627

2728
export type ProtocolConfiguration =
2829
| {
@@ -273,6 +274,7 @@ export const ProtocolConfigurationButton = forwardRef<
273274
storedUsers={farcasterSignerState.identities}
274275
onIdentitySelect={farcasterSignerState.selectIdentity}
275276
/>
277+
<FarcasterDomainAccountAssociation />
276278
</TabsContent>
277279
<TabsContent value="xmtp">
278280
<div>
@@ -442,3 +444,21 @@ function protocolToConfigurationToButtonLabel(
442444
return protocol.protocol;
443445
}
444446
}
447+
448+
function FarcasterDomainAccountAssociation() {
449+
const [isDialogOpen, setIsDialogOpen] = useState(false);
450+
451+
return (
452+
<>
453+
<div className="flex flex-col border-t mt-4 pt-4 gap-2">
454+
<h3 className="font-bold">Domain Account Association</h3>
455+
<Button onClick={() => setIsDialogOpen(true)}>Generate</Button>
456+
</div>
457+
{isDialogOpen && (
458+
<FarcasterDomainAccountAssociationDialog
459+
onClose={() => setIsDialogOpen(false)}
460+
/>
461+
)}
462+
</>
463+
);
464+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useEffect, useState } from "react";
2+
3+
/**
4+
* Hook that returns copy state as idle | copied | failed and copy function
5+
*/
6+
export function useCopyToClipboard() {
7+
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">(
8+
"idle"
9+
);
10+
11+
useEffect(() => {
12+
if (copyState === "copied" || copyState === "failed") {
13+
const timeout = setTimeout(() => {
14+
setCopyState("idle");
15+
}, 1000);
16+
17+
return () => {
18+
clearTimeout(timeout);
19+
};
20+
}
21+
}, [copyState]);
22+
23+
const copyToClipboard = async (text: string) => {
24+
try {
25+
await navigator.clipboard.writeText(text);
26+
setCopyState("copied");
27+
} catch (error) {
28+
console.error("Failed to copy to clipboard", error);
29+
setCopyState("failed");
30+
}
31+
};
32+
33+
return {
34+
copyState,
35+
copyToClipboard,
36+
};
37+
}

0 commit comments

Comments
 (0)