Skip to content

Commit 465267a

Browse files
r4topunkclaude
andauthored
feat(propose): two-stage success screen + fix voting-delay useVotes crash (#78)
* feat(propose): two-stage success screen with subgraph indexing poll Decode ProposalCreated event from receipt logs to get the bytes32 proposalId, then poll /api/proposals/:id until the subgraph indexes the new proposal. Success screen now shows a live "View Proposal" button once the proposal number is known, removing the manual refresh loop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(useVotes): skip getPastVotes when snapshot is still in the future Viewing a freshly created proposal during its voting-delay window made the governor's proposalSnapshot() return a timestamp greater than clock(), which caused getPastVotes() to revert with ERC5805FutureLookup. Detect that case at mount and fall back to getVotes(), so the UI shows the signer's current voting power as a preview until the snapshot actually elapses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a4617a4 commit 465267a

3 files changed

Lines changed: 251 additions & 47 deletions

File tree

src/components/proposals/ProposalPreview.tsx

Lines changed: 160 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
"use client";
22

33
import { useEffect, useState, useTransition } from "react";
4+
import Image from "next/image";
45
import { AlertTriangle, CheckCircle, ExternalLink, Info, Loader2 } from "lucide-react";
56
import { useFormContext, useWatch } from "react-hook-form";
6-
import { getContract, prepareContractCall, readContract, sendTransaction, waitForReceipt } from "thirdweb";
7-
import { base } from "thirdweb/chains";
87
import { toast } from "sonner";
9-
import { useUserAddress } from "@/hooks/use-user-address";
10-
import { useWriteAccount } from "@/hooks/use-write-account";
11-
import { getThirdwebClient } from "@/lib/thirdweb";
12-
import { ensureOnChain } from "@/lib/thirdweb-tx";
13-
import Image from "next/image";
8+
import {
9+
getContract,
10+
prepareContractCall,
11+
readContract,
12+
sendTransaction,
13+
waitForReceipt,
14+
} from "thirdweb";
15+
import { base } from "thirdweb/chains";
16+
import { parseEventLogs } from "viem";
1417
import { TransactionsSummaryList } from "@/components/proposals/preview/TransactionsSummaryList";
1518
import { ProposalDebugPanel } from "@/components/proposals/ProposalDebugPanel";
1619
import { useProposalEligibilityContext } from "@/components/proposals/ProposalEligibilityContext";
1720
import { Alert, AlertDescription } from "@/components/ui/alert";
1821
import { Button } from "@/components/ui/button";
1922
import { Card, CardContent } from "@/components/ui/card";
2023
import { createProposalAction } from "@/app/propose/actions";
24+
import { useProposalIndexing } from "@/hooks/use-proposal-indexing";
25+
import { useUserAddress } from "@/hooks/use-user-address";
26+
import { useWriteAccount } from "@/hooks/use-write-account";
2127
import { DAO_ADDRESSES } from "@/lib/config";
2228
import { ipfsToGatewayUrl } from "@/lib/pinata";
2329
import { encodeTransactions } from "@/lib/proposal-utils";
30+
import { getThirdwebClient } from "@/lib/thirdweb";
31+
import { ensureOnChain } from "@/lib/thirdweb-tx";
2432
import { type ProposalFormValues } from "./schema";
2533

2634
const governorAbi = [
@@ -43,6 +51,38 @@ const governorAbi = [
4351
inputs: [],
4452
outputs: [{ name: "", type: "uint256" }],
4553
},
54+
{
55+
name: "ProposalCreated",
56+
type: "event",
57+
anonymous: false,
58+
inputs: [
59+
{ name: "proposalId", type: "bytes32", indexed: false },
60+
{ name: "targets", type: "address[]", indexed: false },
61+
{ name: "values", type: "uint256[]", indexed: false },
62+
{ name: "calldatas", type: "bytes[]", indexed: false },
63+
{ name: "description", type: "string", indexed: false },
64+
{ name: "descriptionHash", type: "bytes32", indexed: false },
65+
{
66+
name: "proposal",
67+
type: "tuple",
68+
indexed: false,
69+
components: [
70+
{ name: "proposer", type: "address" },
71+
{ name: "timeCreated", type: "uint32" },
72+
{ name: "againstVotes", type: "uint32" },
73+
{ name: "forVotes", type: "uint32" },
74+
{ name: "abstainVotes", type: "uint32" },
75+
{ name: "voteStart", type: "uint32" },
76+
{ name: "voteEnd", type: "uint32" },
77+
{ name: "proposalThreshold", type: "uint32" },
78+
{ name: "quorumVotes", type: "uint32" },
79+
{ name: "executed", type: "bool" },
80+
{ name: "canceled", type: "bool" },
81+
{ name: "vetoed", type: "bool" },
82+
],
83+
},
84+
],
85+
},
4686
] as const;
4787

4888
// Minimal token ABI used only for the send-time voting-power pre-check.
@@ -60,21 +100,30 @@ const tokenGetVotesAbi = [
60100

61101
export function ProposalPreview() {
62102
const eligibility = useProposalEligibilityContext();
63-
const { getValues, handleSubmit, formState: { errors } } = useFormContext<ProposalFormValues>();
103+
const {
104+
getValues,
105+
handleSubmit,
106+
formState: { errors },
107+
} = useFormContext<ProposalFormValues>();
64108
const [isActionPending, startTransition] = useTransition();
65109
const [preparedDescription, setPreparedDescription] = useState<string>("");
66110
const [validationError, setValidationError] = useState<string | null>(null);
67-
const [encodedTxData, setEncodedTxData] = useState<{
68-
targets: `0x${string}`[];
69-
values: bigint[];
70-
calldatas: `0x${string}`[];
71-
} | undefined>();
111+
const [encodedTxData, setEncodedTxData] = useState<
112+
| {
113+
targets: `0x${string}`[];
114+
values: bigint[];
115+
calldatas: `0x${string}`[];
116+
}
117+
| undefined
118+
>();
72119
const { address, isConnected } = useUserAddress();
73120
const writer = useWriteAccount();
74121
const [hash, setHash] = useState<`0x${string}` | undefined>(undefined);
75122
const [isConfirming, setIsConfirming] = useState(false);
76123
const [isWalletPending, setIsWalletPending] = useState(false);
77124
const [isSuccess, setIsSuccess] = useState(false);
125+
const [onchainProposalId, setOnchainProposalId] = useState<`0x${string}` | undefined>(undefined);
126+
const indexing = useProposalIndexing(onchainProposalId);
78127

79128
// Watch form values for reactive preview
80129
const watchedData = useWatch<ProposalFormValues>();
@@ -88,7 +137,7 @@ export function ProposalPreview() {
88137
try {
89138
const result = await createProposalAction(data);
90139
setPreparedDescription(result.description);
91-
140+
92141
// Also encode the transactions for preview
93142
if (data.transactions && data.transactions.length > 0) {
94143
const encoded = encodeTransactions(data.transactions);
@@ -110,6 +159,7 @@ export function ProposalPreview() {
110159
setHash(undefined);
111160
setIsConfirming(false);
112161
setIsSuccess(false);
162+
setOnchainProposalId(undefined);
113163

114164
startTransition(async () => {
115165
console.log("Inside startTransition");
@@ -198,8 +248,23 @@ export function ProposalPreview() {
198248
setIsWalletPending(false);
199249

200250
setIsConfirming(true);
201-
await waitForReceipt({ client, chain: base, transactionHash: txHash });
251+
const receipt = await waitForReceipt({ client, chain: base, transactionHash: txHash });
202252
setIsConfirming(false);
253+
254+
try {
255+
const events = parseEventLogs({
256+
abi: governorAbi,
257+
eventName: "ProposalCreated",
258+
logs: receipt.logs,
259+
});
260+
const created = events[0];
261+
if (created?.args?.proposalId) {
262+
setOnchainProposalId(created.args.proposalId as `0x${string}`);
263+
}
264+
} catch (parseErr) {
265+
console.warn("Could not decode ProposalCreated event:", parseErr);
266+
}
267+
203268
setIsSuccess(true);
204269
} catch (error) {
205270
console.error("Error submitting proposal:", error);
@@ -217,32 +282,45 @@ export function ProposalPreview() {
217282
const onValidationError = (errors: Record<string, unknown>) => {
218283
console.error("Form validation errors:", errors);
219284
console.log("Full errors object:", JSON.stringify(errors, null, 2));
220-
285+
221286
// Collect all error messages
222287
const errorMessages: string[] = [];
223-
224-
if (errors.title && typeof errors.title === 'object' && errors.title !== null && 'message' in errors.title) {
288+
289+
if (
290+
errors.title &&
291+
typeof errors.title === "object" &&
292+
errors.title !== null &&
293+
"message" in errors.title
294+
) {
225295
errorMessages.push(`Title: ${(errors.title as { message: string }).message}`);
226296
}
227-
if (errors.description && typeof errors.description === 'object' && errors.description !== null && 'message' in errors.description) {
297+
if (
298+
errors.description &&
299+
typeof errors.description === "object" &&
300+
errors.description !== null &&
301+
"message" in errors.description
302+
) {
228303
errorMessages.push(`Description: ${(errors.description as { message: string }).message}`);
229304
}
230305
if (errors.transactions && Array.isArray(errors.transactions)) {
231306
errors.transactions.forEach((txError: unknown, index: number) => {
232-
if (txError && typeof txError === 'object' && txError !== null) {
307+
if (txError && typeof txError === "object" && txError !== null) {
233308
Object.entries(txError).forEach(([field, error]: [string, unknown]) => {
234-
if (error && typeof error === 'object' && error !== null && 'message' in error) {
235-
errorMessages.push(`Transaction ${index + 1} - ${field}: ${(error as { message: string }).message}`);
309+
if (error && typeof error === "object" && error !== null && "message" in error) {
310+
errorMessages.push(
311+
`Transaction ${index + 1} - ${field}: ${(error as { message: string }).message}`,
312+
);
236313
}
237314
});
238315
}
239316
});
240317
}
241-
242-
const errorMessage = errorMessages.length > 0
243-
? errorMessages.join("; ")
244-
: "Please fix validation errors before submitting";
245-
318+
319+
const errorMessage =
320+
errorMessages.length > 0
321+
? errorMessages.join("; ")
322+
: "Please fix validation errors before submitting";
323+
246324
console.log("Validation error message:", errorMessage);
247325
setValidationError(errorMessage);
248326
toast.error("Validation Failed", {
@@ -271,26 +349,60 @@ export function ProposalPreview() {
271349
}, [canSubmit, data.title, data.transactions, isActionPending, isWalletPending, isConfirming]);
272350

273351
if (isSuccess) {
352+
const isIndexed = indexing.status === "ready" && indexing.proposalNumber !== null;
353+
const isPolling = indexing.status === "pending" && !!onchainProposalId;
354+
const timedOut = indexing.status === "timeout" || indexing.status === "error";
355+
274356
return (
275357
<Card>
276358
<CardContent className="p-8 text-center">
277359
<CheckCircle className="h-16 w-16 text-green-500 mx-auto mb-4" />
278360
<h3 className="text-2xl font-bold mb-2">Proposal Submitted!</h3>
279-
<p className="text-muted-foreground mb-4">
280-
Your proposal has been successfully submitted to the Gnars DAO.
281-
</p>
282-
{hash && (
283-
<Button variant="outline" asChild>
284-
<a
285-
href={`https://basescan.org/tx/${hash}`}
286-
target="_blank"
287-
rel="noopener noreferrer"
288-
className="inline-flex items-center"
289-
>
290-
View Transaction <ExternalLink className="h-4 w-4 ml-1" />
291-
</a>
292-
</Button>
361+
362+
{isIndexed ? (
363+
<p className="text-muted-foreground mb-4">
364+
Proposal #{indexing.proposalNumber} is live on the Gnars DAO.
365+
</p>
366+
) : isPolling ? (
367+
<p className="text-muted-foreground mb-4 inline-flex items-center justify-center gap-2">
368+
<Loader2 className="h-4 w-4 animate-spin" />
369+
Transaction confirmed. Waiting for the subgraph to index your proposal…
370+
</p>
371+
) : timedOut ? (
372+
<p className="text-muted-foreground mb-4">
373+
Transaction confirmed. Indexing is taking longer than expected — refresh the proposals
374+
page in a minute.
375+
</p>
376+
) : (
377+
<p className="text-muted-foreground mb-4">
378+
Your proposal has been successfully submitted to the Gnars DAO.
379+
</p>
293380
)}
381+
382+
<div className="flex flex-wrap gap-2 justify-center">
383+
{isIndexed && indexing.proposalNumber !== null && (
384+
<Button asChild>
385+
<a
386+
href={`/proposals/base/${indexing.proposalNumber}`}
387+
className="inline-flex items-center"
388+
>
389+
View Proposal <ExternalLink className="h-4 w-4 ml-1" />
390+
</a>
391+
</Button>
392+
)}
393+
{hash && (
394+
<Button variant="outline" asChild>
395+
<a
396+
href={`https://basescan.org/tx/${hash}`}
397+
target="_blank"
398+
rel="noopener noreferrer"
399+
className="inline-flex items-center"
400+
>
401+
View Transaction <ExternalLink className="h-4 w-4 ml-1" />
402+
</a>
403+
</Button>
404+
)}
405+
</div>
294406
</CardContent>
295407
</Card>
296408
);
@@ -331,8 +443,8 @@ export function ProposalPreview() {
331443
<TransactionsSummaryList transactions={data.transactions ?? []} />
332444

333445
{/* Debug Panel */}
334-
<ProposalDebugPanel
335-
formData={data}
446+
<ProposalDebugPanel
447+
formData={data}
336448
preparedDescription={preparedDescription}
337449
encodedTransactions={encodedTxData}
338450
/>
@@ -370,7 +482,9 @@ export function ProposalPreview() {
370482
{!isConnected && (
371483
<Alert className="mb-4">
372484
<Info className="h-4 w-4" />
373-
<AlertDescription>Connect your wallet to check eligibility and submit.</AlertDescription>
485+
<AlertDescription>
486+
Connect your wallet to check eligibility and submit.
487+
</AlertDescription>
374488
</Alert>
375489
)}
376490

@@ -393,8 +507,8 @@ export function ProposalPreview() {
393507
{typeof eligibility.votes === "bigint" && (
394508
<>
395509
{" "}
396-
You currently have <span className="font-semibold">{eligibility.votes.toString()}</span>{" "}
397-
votes.
510+
You currently have{" "}
511+
<span className="font-semibold">{eligibility.votes.toString()}</span> votes.
398512
</>
399513
)}
400514
{eligibility.isDelegating && eligibility.delegatedTo && address && (

0 commit comments

Comments
 (0)