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
9 changes: 9 additions & 0 deletions features/bounties/api/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,13 @@ export const bountyKeys = {
[...bountyKeys.all, 'draft', organizationId, id] as const,
escrowOp: (scope: string, opRowId: string) =>
[...bountyKeys.all, 'escrow-op', scope, opRowId] as const,

// Builder / participant reads.
list: (params: Record<string, unknown> = {}) =>
[...bountyKeys.all, 'list', params] as const,
detail: (bountyId: string) =>
[...bountyKeys.all, 'detail', bountyId] as const,
myApplication: (bountyId: string) =>
[...bountyKeys.all, 'my-application', bountyId] as const,
myActivity: () => [...bountyKeys.all, 'my-activity'] as const,
};
120 changes: 120 additions & 0 deletions features/bounties/api/participant-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Builder/participant bounty REST client: the public marketplace reads and the
* v2 application-record surface (off-chain proposal carrier for the application
* entry types), plus open-competition join/leave.
*
* Several backend handlers still return the raw entity without an `@ApiOkResponse`
* (so the generated schema types them as `never`). Those are cast to the
* hand-typed `MyBountyApplication` until boundless-nestjs #331 lands and codegen
* picks up the real DTO.
*/
import { apiClient, unwrapData } from '@/lib/api';

import type {
BountyPublic,
BountyPublicList,
CreateBountyApplicationRequest,
EditBountyApplicationRequest,
JoinCompetitionRequest,
MyBountyApplication,
} from '../types';

export interface BountiesListParams {
organizationId?: string;
status?: string;
search?: string;
page?: number;
limit?: number;
}

// ── Public marketplace ────────────────────────────────────────────────────────

/** Paginated public bounty list (the marketplace). */
export const listBounties = async (
params: BountiesListParams = {}
): Promise<BountyPublicList> =>
unwrapData(
await apiClient.GET('/api/bounties', { params: { query: params } })
);

/** A single public bounty (the detail page). */
export const getBounty = async (bountyId: string): Promise<BountyPublic> =>
unwrapData(
await apiClient.GET('/api/bounties/{id}', {
params: { path: { id: bountyId } },
})
);

// ── v2 application records (application entry types) ──────────────────────────

/** The caller's own application for a bounty, or null when they have not applied. */
export const getMyBountyApplication = async (
bountyId: string
): Promise<MyBountyApplication | null> =>
(unwrapData(
await apiClient.GET('/api/bounties/{bountyId}/v2/applications/me', {
params: { path: { bountyId } },
})
) as unknown as MyBountyApplication | null) ?? null;

/** Submit an application (light / full proposal) for an application-mode bounty. */
export const applyToBounty = async (
bountyId: string,
body: CreateBountyApplicationRequest
): Promise<MyBountyApplication> =>
unwrapData(
await apiClient.POST('/api/bounties/{bountyId}/v2/applications', {
params: { path: { bountyId } },
body,
})
) as unknown as MyBountyApplication;

/** Edit a SUBMITTED application (before shortlist). */
export const editBountyApplication = async (
bountyId: string,
appId: string,
body: EditBountyApplicationRequest
): Promise<MyBountyApplication> =>
unwrapData(
await apiClient.PATCH('/api/bounties/{bountyId}/v2/applications/{appId}', {
params: { path: { bountyId, appId } },
body,
})
) as unknown as MyBountyApplication;

/** Withdraw a SUBMITTED application (the application record; status -> WITHDRAWN). */
export const withdrawBountyApplication = async (
bountyId: string,
appId: string
): Promise<MyBountyApplication> =>
unwrapData(
await apiClient.DELETE('/api/bounties/{bountyId}/v2/applications/{appId}', {
params: { path: { bountyId, appId } },
})
) as unknown as MyBountyApplication;

// ── Open competition join / leave ─────────────────────────────────────────────

/** Join an open competition directly (no application step). */
export const joinCompetition = async (
bountyId: string,
body: JoinCompetitionRequest
): Promise<MyBountyApplication> =>
unwrapData(
await apiClient.POST('/api/bounties/{bountyId}/v2/competition/join', {
params: { path: { bountyId } },
body,
})
) as unknown as MyBountyApplication;

/** Leave an open competition the caller joined. */
export const leaveCompetition = async (
bountyId: string,
body: JoinCompetitionRequest
): Promise<MyBountyApplication> =>
unwrapData(
await apiClient.DELETE('/api/bounties/{bountyId}/v2/competition/join', {
params: { path: { bountyId } },
body,
})
) as unknown as MyBountyApplication;
107 changes: 107 additions & 0 deletions features/bounties/api/participant-escrow-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Builder/participant bounty escrow client (boundless-events contract), on the
* typed openapi-fetch client. Counterpart to the organizer `escrow-client.ts`:
* these are the participant-scoped, no-org-prefix routes (`/api/bounties/{id}/
* escrow/...`) the builder lifecycle drives through the shared escrow runner.
*
* Like the organizer ops, each builds an EscrowOp:
* MANAGED : backend signs with the caller's platform-held wallet + submits;
* the op returns in PENDING_CONFIRM (the webapp only polls).
* EXTERNAL : backend returns `unsignedXdr`; the webapp signs with a connected
* wallet and POSTs to submit-signed, then polls.
*/
import { apiClient, unwrapData } from '@/lib/api';

import type {
ApplyBountyRequest,
BountyEscrowOpResponse,
ContributeBountyRequest,
SubmitBountyRequest,
SubmitSignedXdrRequest,
WithdrawApplicationRequest,
WithdrawSubmissionRequest,
} from '../types';

/** Apply to a bounty on-chain (APPLY_TO_BOUNTY; charges application credits). */
export const applyToBountyEscrow = async (
bountyId: string,
body: ApplyBountyRequest
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.POST('/api/bounties/{id}/escrow/apply', {
params: { path: { id: bountyId } },
body,
})
);

/** Withdraw a bounty application (refunds half the application credits). */
export const withdrawBountyApplicationEscrow = async (
bountyId: string,
body: WithdrawApplicationRequest
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.POST('/api/bounties/{id}/escrow/withdraw-application', {
params: { path: { id: bountyId } },
body,
})
);

/** Anchor a work submission on-chain (SUBMIT; requires an active application). */
export const submitBountyWorkEscrow = async (
bountyId: string,
body: SubmitBountyRequest
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.POST('/api/bounties/{id}/escrow/submit', {
params: { path: { id: bountyId } },
body,
})
);

/** Withdraw a submission anchor. */
export const withdrawBountySubmissionEscrow = async (
bountyId: string,
body: WithdrawSubmissionRequest
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.POST('/api/bounties/{id}/escrow/withdraw-submission', {
params: { path: { id: bountyId } },
body,
})
);

/** Contribute funds to a bounty's escrow pool (ADD_FUNDS; 10 USDC minimum). */
export const contributeToBountyEscrow = async (
bountyId: string,
body: ContributeBountyRequest
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.POST('/api/bounties/{id}/escrow/contribute', {
params: { path: { id: bountyId } },
body,
})
);

/** Submit a wallet-signed XDR for a participant op (EXTERNAL path). */
export const submitSignedParticipantBounty = async (
bountyId: string,
opRowId: string,
body: SubmitSignedXdrRequest
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.POST(
'/api/bounties/{id}/escrow/ops/{opRowId}/submit-signed',
{ params: { path: { id: bountyId, opRowId } }, body }
)
);

/** Poll the current state of a participant escrow op. */
export const getParticipantBountyOp = async (
bountyId: string,
opRowId: string
): Promise<BountyEscrowOpResponse> =>
unwrapData(
await apiClient.GET('/api/bounties/{id}/escrow/ops/{opRowId}', {
params: { path: { id: bountyId, opRowId } },
})
);
45 changes: 25 additions & 20 deletions features/bounties/api/use-escrow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import {
selectBountyWinners,
submitSignedBountyEscrow,
} from './escrow-client';
import {
getParticipantBountyOp,
submitSignedParticipantBounty,
} from './participant-escrow-client';
import {
isTerminalEscrowStatus,
type BountyEscrowOpResponse,
Expand All @@ -41,42 +45,43 @@ import {
type SelectBountyWinnersRequest,
} from '../types';

// ─── Scope: organizer (org + bounty) ─────────────────────────────────────────
// ─── Scope: organizer (org + bounty) or participant (bounty-only) ─────────────

export type EscrowOpScope = {
kind: 'organizer';
organizationId: string;
bountyId: string;
};
export type EscrowOpScope =
| { kind: 'organizer'; organizationId: string; bountyId: string }
| { kind: 'participant'; bountyId: string };

function getOpForScope(
scope: EscrowOpScope,
opRowId: string
): Promise<BountyEscrowOpResponse> {
return getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId);
return scope.kind === 'participant'
? getParticipantBountyOp(scope.bountyId, opRowId)
: getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId);
}

function submitSignedForScope(
scope: EscrowOpScope,
opRowId: string,
signedXdr: string
): Promise<BountyEscrowOpResponse> {
return submitSignedBountyEscrow(
scope.organizationId,
scope.bountyId,
opRowId,
{ signedXdr }
);
return scope.kind === 'participant'
? submitSignedParticipantBounty(scope.bountyId, opRowId, { signedXdr })
: submitSignedBountyEscrow(scope.organizationId, scope.bountyId, opRowId, {
signedXdr,
});
}

function escrowOpKey(scope: EscrowOpScope, opRowId: string): QueryKey {
return [
'bounty-escrow-op',
'organizer',
scope.organizationId,
scope.bountyId,
opRowId,
];
return scope.kind === 'participant'
? ['bounty-escrow-op', 'participant', scope.bountyId, opRowId]
: [
'bounty-escrow-op',
'organizer',
scope.organizationId,
scope.bountyId,
opRowId,
];
}

// ─── 1. Polling primitive ────────────────────────────────────────────────────
Expand Down
Loading