Skip to content

Commit 3b56591

Browse files
authored
Merge pull request #84 from sktbrd/feat/poidh-v3-completion
fix(poidh): complete V3 implementation — fix broken vote/resolve, add vote dashboard and withdrawal banner
2 parents 0b5a301 + b375fbe commit 3b56591

8 files changed

Lines changed: 2402 additions & 288 deletions

File tree

docs/superpowers/plans/2026-04-24-poidh-v3-completion.md

Lines changed: 1423 additions & 0 deletions
Large diffs are not rendered by default.

scripts/test-poidh.ts

Lines changed: 539 additions & 0 deletions
Large diffs are not rendered by default.

src/components/bounties/BountiesView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
1111
import { formatEther } from 'viem';
1212
import { useEthPrice, formatEthToUsd } from '@/hooks/use-eth-price';
1313
import type { PoidhBounty } from '@/types/poidh';
14+
import { PendingWithdrawalBanner } from '@/components/bounties/PendingWithdrawalBanner';
1415

1516
const CATEGORIES = [
1617
{ key: 'all', label: 'All' },
@@ -76,6 +77,7 @@ export function BountiesView({ initialBounties }: BountiesViewProps) {
7677
return (
7778
<div className="container mx-auto px-4 py-8 max-w-7xl">
7879
<div className="space-y-6">
80+
<PendingWithdrawalBanner />
7981
{/* Header */}
8082
<div className="flex items-start justify-between gap-4">
8183
<div>

src/components/bounties/BountyDetailView.tsx

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,17 @@ import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
3232
import { ClaimBountyModal } from '@/components/bounties/ClaimBountyModal';
3333
import { MediaEmbed } from '@/components/bounties/MediaEmbed';
3434
import { AddressDisplay } from '@/components/ui/address-display';
35-
import { usePoidhCancelBounty, usePoidhJoinBounty, usePoidhWithdrawFromBounty, usePoidhAcceptClaim, usePoidhSubmitClaimForVote, usePoidhVoteClaim, usePoidhResolveVote } from '@/hooks/usePoidhContract';
35+
import {
36+
usePoidhCancelBounty,
37+
usePoidhJoinBounty,
38+
usePoidhClaimRefundFromCancelledBounty,
39+
usePoidhAcceptClaim,
40+
usePoidhSubmitClaimForVote,
41+
usePoidhVoteClaim,
42+
usePoidhResolveVote,
43+
usePoidhResetVotingPeriod,
44+
} from '@/hooks/usePoidhContract';
45+
import { VoteDashboard } from '@/components/bounties/VoteDashboard';
3646
import { POIDH_ABI } from '@/lib/poidh/abi';
3747
import { useEthPrice, formatEthToUsd } from '@/hooks/use-eth-price';
3848
import { useUserAddress } from '@/hooks/use-user-address';
@@ -166,25 +176,17 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
166176

167177
const cancelHook = usePoidhCancelBounty(chainId);
168178
const joinHook = usePoidhJoinBounty(chainId);
169-
const withdrawHook = usePoidhWithdrawFromBounty(chainId);
179+
const claimRefundHook = usePoidhClaimRefundFromCancelledBounty(chainId);
170180
const acceptClaimHook = usePoidhAcceptClaim(chainId);
171181
const submitForVoteHook = usePoidhSubmitClaimForVote(chainId);
172182
const voteClaimHook = usePoidhVoteClaim(chainId);
173183
const resolveVoteHook = usePoidhResolveVote(chainId);
184+
const resetVotingHook = usePoidhResetVotingPeriod(chainId);
174185

175186
const deadlineTimestamp = bounty?.deadline ?? null;
176187
const countdown = useCountdown(deadlineTimestamp);
177188

178-
// Read the authoritative on-chain isOpenBounty flag (overrides API field which can be null on V2)
179-
const { data: onChainBountyData } = useReadContract({
180-
address: POIDH_CONTRACTS[chainId],
181-
abi: POIDH_ABI,
182-
functionName: 'getBounty',
183-
args: [BigInt(bounty?.onChainId ?? 0)],
184-
chainId,
185-
query: { enabled: !!(bounty?.onChainId) },
186-
});
187-
const isJoinable = onChainBountyData ? onChainBountyData.isOpenBounty : (bounty?.isOpenBounty || bounty?.isMultiplayer);
189+
const isJoinable = bounty?.isOpenBounty || bounty?.isMultiplayer;
188190

189191

190192
const { data: participantsData } = useReadContract({
@@ -198,6 +200,15 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
198200
const participants = participantsData?.[0] as `0x${string}`[] | undefined;
199201
const participantAmounts = participantsData?.[1] as bigint[] | undefined;
200202

203+
const { data: hadExternalContributor } = useReadContract({
204+
address: POIDH_CONTRACTS[chainId],
205+
abi: POIDH_ABI,
206+
functionName: 'everHadExternalContributor',
207+
args: [BigInt(bounty?.onChainId ?? 0)],
208+
chainId,
209+
query: { enabled: !!(bounty?.onChainId) },
210+
});
211+
201212
if (!bounty) return null;
202213

203214
const chainName = CHAIN_NAMES[chainId as keyof typeof CHAIN_NAMES] || 'Unknown';
@@ -447,8 +458,8 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
447458
</>
448459
)}
449460
</div>
450-
{/* Accept button (creator only, if not already accepted) */}
451-
{isCreator && !claim.accepted && !bounty.isCanceled && (
461+
{/* Accept button (creator only, solo bounties or open bounties with no contributors) */}
462+
{isCreator && !claim.accepted && !bounty.isCanceled && !hadExternalContributor && (
452463
<Button
453464
size="sm"
454465
variant="default"
@@ -465,6 +476,12 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
465476
)}
466477
</Button>
467478
)}
479+
{/* Guide issuer to use vote flow when open bounty had contributors */}
480+
{isCreator && !claim.accepted && !bounty.isCanceled && hadExternalContributor && !bounty.isVoting && (
481+
<p className="text-xs text-muted-foreground">
482+
Use <strong>Submit for Vote</strong> — contributors must vote to accept.
483+
</p>
484+
)}
468485
</div>
469486
{/* Accept success message */}
470487
{acceptClaimHook.isSuccess && acceptClaimHook.hash && (
@@ -519,7 +536,7 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
519536
variant="outline"
520537
className="flex-1 border-emerald-500/30 text-emerald-500 hover:bg-emerald-500/10"
521538
disabled={voteClaimHook.isPending}
522-
onClick={() => voteClaimHook.vote(bounty.onChainId, claim.id, true)}
539+
onClick={() => voteClaimHook.vote(bounty.onChainId, true)}
523540
>
524541
{voteClaimHook.isPending ? (
525542
<Loader2 className="w-3 h-3 animate-spin" />
@@ -532,7 +549,7 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
532549
variant="outline"
533550
className="flex-1 border-red-500/30 text-red-500 hover:bg-red-500/10"
534551
disabled={voteClaimHook.isPending}
535-
onClick={() => voteClaimHook.vote(bounty.onChainId, claim.id, false)}
552+
onClick={() => voteClaimHook.vote(bounty.onChainId, false)}
536553
>
537554
{voteClaimHook.isPending ? (
538555
<Loader2 className="w-3 h-3 animate-spin" />
@@ -562,7 +579,7 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
562579
variant="outline"
563580
className="w-full"
564581
disabled={resolveVoteHook.isPending}
565-
onClick={() => resolveVoteHook.resolve(bounty.onChainId, claim.id)}
582+
onClick={() => resolveVoteHook.resolve(bounty.onChainId)}
566583
>
567584
{resolveVoteHook.isPending ? (
568585
<><Loader2 className="w-3 h-3 mr-1 animate-spin" />{resolveVoteHook.hash ? 'Confirming…' : 'Confirm in wallet…'}</>
@@ -587,6 +604,34 @@ export function BountyDetailView({ initialBounty, chainId, bountyId }: BountyDet
587604
</a>
588605
</div>
589606
)}
607+
{/* Reset voting period — recovery if vote failed (contract reverts if vote would have passed) */}
608+
<Button
609+
size="sm"
610+
variant="ghost"
611+
className="w-full text-xs text-muted-foreground"
612+
disabled={resetVotingHook.isPending}
613+
onClick={() => resetVotingHook.resetVoting(bounty.onChainId)}
614+
>
615+
{resetVotingHook.isPending
616+
? <><Loader2 className="w-3 h-3 mr-1 animate-spin" />Resetting…</>
617+
: 'Reset voting period (if vote failed)'
618+
}
619+
</Button>
620+
{resetVotingHook.error && (
621+
<div className="flex items-start gap-2 rounded-md bg-destructive/10 border border-destructive/20 px-2 py-1.5 text-xs text-destructive">
622+
<AlertCircle className="w-3 h-3 shrink-0 mt-0.5" />
623+
<span>{resetVotingHook.error.message.split('\n')[0]}</span>
624+
</div>
625+
)}
626+
{resetVotingHook.isSuccess && resetVotingHook.hash && (
627+
<div className="flex items-center gap-2 py-1.5 px-2 rounded-md bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs">
628+
<CheckCircle2 className="w-3 h-3 shrink-0" />
629+
<span>Voting period reset.</span>
630+
<a href={getTxUrl(chainId, resetVotingHook.hash)} target="_blank" rel="noopener noreferrer" className="ml-auto flex items-center gap-1 hover:underline">
631+
View tx <ExternalLink className="w-3 h-3" />
632+
</a>
633+
</div>
634+
)}
590635
</div>
591636
)}
592637
</div>
@@ -696,34 +741,38 @@ joinHook.join(bounty.onChainId, joinAmount);
696741
</Card>
697742
)}
698743

699-
{/* Withdraw from canceled bounty (participant) */}
744+
{/* Withdraw from canceled bounty (contributor pull-payment) */}
700745
{bounty.isCanceled && isJoinable && !isCreator && (
701746
<Card className="border-border">
702747
<CardHeader className="pb-3">
703748
<CardTitle className="text-base">Withdraw Your Contribution</CardTitle>
704749
<CardDescription>This bounty was canceled. Recover your contribution.</CardDescription>
705750
</CardHeader>
706751
<CardContent className="space-y-3">
707-
{withdrawHook.isSuccess ? (
752+
{claimRefundHook.isSuccess ? (
708753
<div className="flex flex-col items-center gap-2 py-2 text-center">
709754
<CheckCircle2 className="w-8 h-8 text-emerald-500" />
710755
<p className="text-sm font-medium">Withdrawal confirmed!</p>
711-
{withdrawHook.hash && (
712-
<a href={getTxUrl(chainId, withdrawHook.hash)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-xs text-primary hover:underline">
756+
{claimRefundHook.hash && (
757+
<a href={getTxUrl(chainId, claimRefundHook.hash)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-xs text-primary hover:underline">
713758
View tx <ExternalLink className="w-3 h-3" />
714759
</a>
715760
)}
716761
</div>
717762
) : (
718763
<>
719-
{withdrawHook.error && (
764+
{claimRefundHook.error && (
720765
<div className="flex items-start gap-2 rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
721766
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
722-
<span>{withdrawHook.error.message.split('\n')[0]}</span>
767+
<span>{claimRefundHook.error.message.split('\n')[0]}</span>
723768
</div>
724769
)}
725-
<Button variant="outline" className="w-full" disabled={withdrawHook.isPending} onClick={() => withdrawHook.withdraw(bounty.onChainId)}>
726-
{withdrawHook.isPending ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />{withdrawHook.hash ? 'Confirming…' : 'Confirm in wallet…'}</> : 'Withdraw Funds'}
770+
<Button variant="outline" className="w-full" disabled={claimRefundHook.isPending}
771+
onClick={() => claimRefundHook.claimRefund(bounty.onChainId)}>
772+
{claimRefundHook.isPending
773+
? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />{claimRefundHook.hash ? 'Confirming…' : 'Confirm in wallet…'}</>
774+
: 'Withdraw Funds'
775+
}
727776
</Button>
728777
</>
729778
)}
@@ -776,6 +825,11 @@ joinHook.join(bounty.onChainId, joinAmount);
776825
)}
777826

778827

828+
{/* Vote Dashboard — live yes/no tallies and deadline */}
829+
{bounty.isVoting && bounty.onChainId > 0 && (
830+
<VoteDashboard chainId={chainId} onChainBountyId={bounty.onChainId} />
831+
)}
832+
779833
{/* Bounty Details */}
780834
<Card>
781835
<CardHeader>
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
'use client';
2+
3+
import { useReadContract } from 'wagmi';
4+
import { formatEther } from 'viem';
5+
import { Wallet, Loader2, CheckCircle2, ExternalLink, AlertCircle } from 'lucide-react';
6+
import { Button } from '@/components/ui/button';
7+
import { POIDH_ABI } from '@/lib/poidh/abi';
8+
import { POIDH_CONTRACTS, CHAIN_NAMES, getTxUrl, SUPPORTED_CHAINS } from '@/lib/poidh/config';
9+
import { usePoidhWithdraw } from '@/hooks/usePoidhContract';
10+
import { useUserAddress } from '@/hooks/use-user-address';
11+
12+
interface ChainBannerProps {
13+
chainId: number;
14+
userAddress: `0x${string}`;
15+
}
16+
17+
function ChainWithdrawalBanner({ chainId, userAddress }: ChainBannerProps) {
18+
const contractAddress = POIDH_CONTRACTS[chainId];
19+
const chainName = CHAIN_NAMES[chainId as keyof typeof CHAIN_NAMES];
20+
const { withdraw, isPending, isSuccess, hash, error } = usePoidhWithdraw(chainId);
21+
22+
const { data: pending, refetch } = useReadContract({
23+
address: contractAddress,
24+
abi: POIDH_ABI,
25+
functionName: 'pendingWithdrawals',
26+
args: [userAddress],
27+
chainId,
28+
query: { enabled: !!contractAddress, refetchInterval: 30_000 },
29+
});
30+
31+
if (isSuccess) void refetch();
32+
33+
if (!pending || pending === 0n) return null;
34+
35+
const ethAmount = parseFloat(formatEther(pending)).toFixed(6);
36+
37+
return (
38+
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 p-3 rounded-md border border-amber-500/30 bg-amber-500/10 text-sm">
39+
<Wallet className="w-4 h-4 text-amber-400 shrink-0 mt-0.5 sm:mt-0" />
40+
<div className="flex-1 min-w-0">
41+
<span className="font-medium text-amber-300">
42+
{ethAmount} ETH claimable on {chainName}
43+
</span>
44+
<p className="text-xs text-muted-foreground mt-0.5">
45+
From bounty winnings or a cancelled open bounty.
46+
</p>
47+
</div>
48+
49+
{isSuccess ? (
50+
<div className="flex items-center gap-1.5 text-emerald-400 text-xs shrink-0">
51+
<CheckCircle2 className="w-4 h-4" />
52+
<span>Withdrawn!</span>
53+
{hash && (
54+
<a
55+
href={getTxUrl(chainId, hash)}
56+
target="_blank"
57+
rel="noopener noreferrer"
58+
className="flex items-center gap-0.5 hover:underline ml-1"
59+
>
60+
Tx <ExternalLink className="w-3 h-3" />
61+
</a>
62+
)}
63+
</div>
64+
) : (
65+
<div className="flex flex-col items-end gap-1 shrink-0">
66+
<Button
67+
size="sm"
68+
variant="outline"
69+
className="border-amber-500/40 text-amber-300 hover:bg-amber-500/10 whitespace-nowrap"
70+
disabled={isPending}
71+
onClick={() => withdraw()}
72+
>
73+
{isPending ? (
74+
<><Loader2 className="w-3 h-3 mr-1.5 animate-spin" />Withdrawing…</>
75+
) : (
76+
'Withdraw'
77+
)}
78+
</Button>
79+
{error && (
80+
<div className="flex items-start gap-1 text-destructive text-xs max-w-[12rem]">
81+
<AlertCircle className="w-3 h-3 shrink-0 mt-0.5" />
82+
<span className="break-words">{error.message.split('\n')[0]}</span>
83+
</div>
84+
)}
85+
</div>
86+
)}
87+
</div>
88+
);
89+
}
90+
91+
export function PendingWithdrawalBanner() {
92+
const { address, isConnected } = useUserAddress();
93+
94+
if (!isConnected || !address) return null;
95+
96+
return (
97+
<div className="space-y-2 mb-4">
98+
{Object.values(SUPPORTED_CHAINS).map((chainId) => (
99+
<ChainWithdrawalBanner
100+
key={chainId}
101+
chainId={chainId}
102+
userAddress={address as `0x${string}`}
103+
/>
104+
))}
105+
</div>
106+
);
107+
}

0 commit comments

Comments
 (0)