diff --git a/components/organization/bounties/new/NewBountyTab.tsx b/components/organization/bounties/new/NewBountyTab.tsx index d8b5105e..d0429a63 100644 --- a/components/organization/bounties/new/NewBountyTab.tsx +++ b/components/organization/bounties/new/NewBountyTab.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner'; import { Tabs, TabsContent } from '@/components/ui/tabs'; import { useBountySteps } from '@/hooks/use-bounty-steps'; import { useBountyDraft } from '@/hooks/use-bounty-draft'; +import { useBountyPublish } from '@/hooks/use-bounty-publish'; import ScopeTab from './tabs/ScopeTab'; import ModeTab from './tabs/ModeTab'; import SubmissionModelTab from './tabs/SubmissionModelTab'; @@ -154,6 +155,16 @@ export default function NewBountyTab({ ? { entryType: stepData.mode.entryType, claimType: stepData.mode.claimType } : undefined; + // Publish via the shared escrow runner. Defaults to MANAGED (the connected + // custodial wallet funds + the backend signs); the funding-source picker + // (external wallet / treasury) is a follow-up. + const { publish, isPublishing } = useBountyPublish({ + organizationId: derivedOrgId || '', + stepData, + draftId, + fundingMode: 'MANAGED', + }); + if (isLoadingDraft) { return (
@@ -231,12 +242,10 @@ export default function NewBountyTab({ onEdit={navigateToStep} onSaveDraft={saveDraft} isSavingDraft={isSavingDraft} - // TODO(#601): wire the real publish + funding flow. For now the - // CTA enables once every section is valid, then reports that - // publishing lands in #601. - onPublish={() => { - toast.info('Publishing lands in #601 (escrow publish flow).'); + onPublish={async () => { + await publish(); }} + isLoading={isPublishing} />
diff --git a/hooks/use-bounty-publish.ts b/hooks/use-bounty-publish.ts new file mode 100644 index 00000000..730d1c42 --- /dev/null +++ b/hooks/use-bounty-publish.ts @@ -0,0 +1,271 @@ +import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +import { useWalletContext } from '@/components/providers/wallet-provider'; +import { getTokenAddress } from '@/lib/config/tokens'; +import { signXdrWithKit } from '@/lib/wallet/wallet-kit'; +import { + clearPublishOpId, + persistPublishOpId, + readPublishOpId, +} from '@/lib/utils/publish-op-storage'; +import { + getBountyEscrowOp, + useEscrowOpRunner, + usePublishBountyEscrow, + type FundingMode, + type PublishBountyEscrowRequest, +} from '@/features/bounties'; +import { + getBountyPrizePool, + getBountyTotalFunding, + buildBountyWinnerDistribution, +} from '@/lib/utils/bounty-escrow'; +import { getBountyDraft } from '@/features/bounties'; +import type { BountyFormData } from '@/components/organization/bounties/new/constants'; + +interface UseBountyPublishProps { + organizationId: string; + stepData: BountyFormData; + draftId?: string | null; + /** Signing path. Defaults to MANAGED (custodial). */ + fundingMode?: FundingMode; + /** + * For EXTERNAL funding, the connected wallet that funds + signs (Stellar + * Wallets Kit). Becomes the on-chain owner. Ignored for MANAGED. + */ + externalOwnerAddress?: string | null; +} + +export interface BountyPublishResponse { + id: string; + message: string; + transactionHash: string | null; +} + +/** Lowercase bounty statuses that mean the bounty has left DRAFT already. */ +const PUBLISHED_STATUSES = new Set([ + 'open', + 'in_progress', + 'submitted', + 'under_review', + 'completed', + 'cancelled', + 'disputed', +]); + +/** + * Publishes a bounty draft onto the boundless-events contract. The draft IS a + * Bounty row in `draft` status, so we hit the v2 escrow surface directly: + * + * 1. POST .../bounties/:draftId/escrow/publish (CREATE_EVENT). MANAGED has the + * backend sign + submit (op comes back PENDING_CONFIRM); EXTERNAL returns + * an unsigned XDR the connected wallet signs. + * 2. Poll the op to COMPLETED; the backend subscriber flips the bounty + * draft_awaiting_funding -> open on settle. + * + * Mirrors useHackathonPublish. Treasury funding (sourceWalletId) is omitted + * until the backend treasury-parity issue (boundless-nestjs #314) lands. + */ +export const useBountyPublish = ({ + organizationId, + stepData, + draftId, + fundingMode = 'MANAGED', + externalOwnerAddress, +}: UseBountyPublishProps) => { + const { walletAddress, balances } = useWalletContext(); + const bountyId = draftId || ''; + const isExternal = fundingMode === 'EXTERNAL'; + + // The on-chain owner/source. EXTERNAL funds from the connected wallet; + // MANAGED from the caller's platform-held custodial wallet. + const ownerAddress = isExternal + ? (externalOwnerAddress ?? null) + : walletAddress; + + const publishMutation = usePublishBountyEscrow(organizationId, bountyId); + const runner = useEscrowOpRunner( + { kind: 'organizer', organizationId, bountyId }, + isExternal ? { signXdr: signXdrWithKit } : undefined + ); + + const [hasStarted, setHasStarted] = useState(false); + const [publishResponse, setPublishResponse] = + useState(null); + const finalizedRef = useRef(false); + + useEffect(() => { + if (!hasStarted || finalizedRef.current) return; + + if (runner.isCompleted) { + finalizedRef.current = true; + setPublishResponse({ + id: bountyId, + message: 'Bounty published successfully', + transactionHash: runner.txHash, + }); + clearPublishOpId(bountyId); + toast.success('Bounty published!', { duration: 3000 }); + setHasStarted(false); + } else if (runner.isFailed) { + finalizedRef.current = true; + // The backend returns the bounty to DRAFT on a failed CREATE_EVENT, so + // drop the stale op id; the next attempt starts a fresh publish. + clearPublishOpId(bountyId); + toast.error(runner.error || 'Failed to publish bounty'); + setHasStarted(false); + } + }, [ + hasStarted, + runner.isCompleted, + runner.isFailed, + runner.error, + runner.txHash, + bountyId, + ]); + + const publish = async (): Promise => { + if (!organizationId) { + toast.error('Organization ID is required'); + return null; + } + if (!bountyId) { + toast.error('Draft ID is required'); + return null; + } + if (!ownerAddress) { + toast.error( + isExternal + ? 'Connect a wallet to fund externally.' + : 'Please connect your wallet first' + ); + return null; + } + + // Pre-flight: a prior publish may have moved the bounty out of draft. The + // backend rejects re-publishing a draft_awaiting_funding row, so resume the + // in-flight op (or report the terminal state) instead. + let currentStatus: string | undefined; + try { + const draft = await getBountyDraft(organizationId, bountyId); + currentStatus = draft.status; + } catch { + // Non-fatal: fall through to the normal publish path. + } + + if (currentStatus && PUBLISHED_STATUSES.has(currentStatus)) { + clearPublishOpId(bountyId); + toast.success('This bounty is already published.'); + return null; + } + + if (currentStatus === 'draft_awaiting_funding') { + const storedOpRowId = readPublishOpId(bountyId); + if (!storedOpRowId) { + toast.info( + 'This bounty is already being funded on-chain. It will finish ' + + 'publishing shortly, or return to draft if the transaction fails — ' + + 'refresh in a moment to check.' + ); + return null; + } + finalizedRef.current = false; + setHasStarted(true); + toast.info('Resuming bounty funding…'); + await runner.run(() => + getBountyEscrowOp(organizationId, bountyId, storedOpRowId) + ); + return null; + } + + // ---- Normal draft publish path ---- + const reward = stepData.reward; + const submission = stepData.submission; + if (!reward || (reward.prizeTiers?.length ?? 0) === 0) { + toast.error('Add at least one prize tier before publishing'); + return null; + } + if (!submission?.submissionDeadline) { + toast.error('A submission deadline is required before publishing'); + return null; + } + + const budget = getBountyPrizePool(reward); + if (!(budget > 0)) { + toast.error('Total prize amount must be greater than zero'); + return null; + } + + // create_event debits budget + platform fee from the owner's USDC. Catch an + // insufficient/missing balance here (MANAGED only — EXTERNAL funds from a + // wallet whose balance we don't track) with a clear message. + const required = getBountyTotalFunding(reward); + const usdcEntry = balances.find(b => b.asset_code === 'USDC'); + const usdcBalance = parseFloat(usdcEntry?.balance ?? '0'); + const fmt = (n: number) => + n.toLocaleString('en-US', { maximumFractionDigits: 2 }); + if (!isExternal && balances.length > 0 && usdcBalance < required) { + toast.error( + usdcEntry + ? `Insufficient USDC. Publishing locks ${fmt(required)} USDC ` + + `(${fmt(budget)} prizes + ${fmt(required - budget)} fee), but ` + + `your wallet has ${fmt(usdcBalance)} USDC.` + : `Your wallet has no USDC. Publishing locks ${fmt(required)} USDC. ` + + `Add USDC (and a USDC trustline) before publishing.` + ); + return null; + } + + let tokenAddress: string; + try { + tokenAddress = getTokenAddress('USDC'); + } catch (err) { + toast.error( + err instanceof Error ? err.message : 'Prize token is not configured' + ); + return null; + } + + const deadlineMs = new Date(submission.submissionDeadline).getTime(); + if (Number.isNaN(deadlineMs)) { + toast.error('Submission deadline is invalid'); + return null; + } + const submissionDeadline = Math.floor(deadlineMs / 1000); + + const body: PublishBountyEscrowRequest = { + ownerAddress, + tokenAddress, + budget: String(budget), + submissionDeadline, + applicationCreditCost: submission.applicationCreditCost ?? 1, + winnerDistribution: buildBountyWinnerDistribution(reward), + fundingMode, + }; + + finalizedRef.current = false; + setHasStarted(true); + setPublishResponse(null); + toast.info('Publishing bounty...'); + + const op = await runner.run(async () => { + const created = await publishMutation.mutateAsync(body); + persistPublishOpId(bountyId, created.id); + return created; + }); + + if (!op) setHasStarted(false); + return null; + }; + + return { + isPublishing: hasStarted && (publishMutation.isPending || runner.isRunning), + publish, + publishResponse, + escrowStatus: runner.status, + escrowPhase: runner.phase, + escrowError: runner.error, + escrowTxHash: runner.txHash, + }; +};