Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions components/organization/bounties/new/NewBountyTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<div className='bg-background-main-bg flex min-h-[60vh] flex-1 items-center justify-center text-white'>
Expand Down Expand Up @@ -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}
/>
</TabsContent>
</div>
Expand Down
271 changes: 271 additions & 0 deletions hooks/use-bounty-publish.ts
Original file line number Diff line number Diff line change
@@ -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<BountyPublishResponse | null>(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<BountyPublishResponse | null> => {
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,
};
};