Skip to content

Commit 8bbb6a4

Browse files
committed
feat(bounty): participant data layer (escrow client + hooks)
Add the builder/participant data layer to features/bounties, mirroring the hackathon participant scope and reusing the shared escrow runner. The visible builder UI (marketplace, detail, apply/submit/withdraw, my-bounties) consumes this. - participant-escrow-client: on-chain apply/submit/withdraw-application/ withdraw-submission/contribute + participant op poll + submit-signed (the no-org-prefix /api/bounties/{id}/escrow/* routes) - participant-client: public list/detail, v2 application records (apply/edit/withdraw/me), competition join/leave - use-participant hooks: useBountiesList/useBounty/useMyBountyApplication, useApply/Edit/WithdrawApplication, useJoin/LeaveCompetition, and escrow-op hooks (useSubmitBounty/useWithdrawSubmission/useContributeToBounty/ useApplyToBountyEscrow/useWithdrawApplicationEscrow) driven through the runner, default MANAGED - use-escrow: EscrowOpScope is now organizer | participant; the runner routes op-poll/submit-signed to the participant URLs - keys/types/index: participant query keys, type aliases, public exports MyBountyApplication is hand-typed until boundless-nestjs #331 lands and codegen runs; useMyBountyActivity is deferred until the participant dashboard (#332). Closes #621
1 parent 495689f commit 8bbb6a4

7 files changed

Lines changed: 612 additions & 20 deletions

File tree

features/bounties/api/keys.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,13 @@ export const bountyKeys = {
1010
[...bountyKeys.all, 'draft', organizationId, id] as const,
1111
escrowOp: (scope: string, opRowId: string) =>
1212
[...bountyKeys.all, 'escrow-op', scope, opRowId] as const,
13+
14+
// Builder / participant reads.
15+
list: (params: Record<string, unknown> = {}) =>
16+
[...bountyKeys.all, 'list', params] as const,
17+
detail: (bountyId: string) =>
18+
[...bountyKeys.all, 'detail', bountyId] as const,
19+
myApplication: (bountyId: string) =>
20+
[...bountyKeys.all, 'my-application', bountyId] as const,
21+
myActivity: () => [...bountyKeys.all, 'my-activity'] as const,
1322
};
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* Builder/participant bounty REST client: the public marketplace reads and the
3+
* v2 application-record surface (off-chain proposal carrier for the application
4+
* entry types), plus open-competition join/leave.
5+
*
6+
* Several backend handlers still return the raw entity without an `@ApiOkResponse`
7+
* (so the generated schema types them as `never`). Those are cast to the
8+
* hand-typed `MyBountyApplication` until boundless-nestjs #331 lands and codegen
9+
* picks up the real DTO.
10+
*/
11+
import { apiClient, unwrapData } from '@/lib/api';
12+
13+
import type {
14+
BountyPublic,
15+
BountyPublicList,
16+
CreateBountyApplicationRequest,
17+
EditBountyApplicationRequest,
18+
JoinCompetitionRequest,
19+
MyBountyApplication,
20+
} from '../types';
21+
22+
export interface BountiesListParams {
23+
organizationId?: string;
24+
status?: string;
25+
search?: string;
26+
page?: number;
27+
limit?: number;
28+
}
29+
30+
// ── Public marketplace ────────────────────────────────────────────────────────
31+
32+
/** Paginated public bounty list (the marketplace). */
33+
export const listBounties = async (
34+
params: BountiesListParams = {}
35+
): Promise<BountyPublicList> =>
36+
unwrapData(
37+
await apiClient.GET('/api/bounties', { params: { query: params } })
38+
);
39+
40+
/** A single public bounty (the detail page). */
41+
export const getBounty = async (bountyId: string): Promise<BountyPublic> =>
42+
unwrapData(
43+
await apiClient.GET('/api/bounties/{id}', {
44+
params: { path: { id: bountyId } },
45+
})
46+
);
47+
48+
// ── v2 application records (application entry types) ──────────────────────────
49+
50+
/** The caller's own application for a bounty, or null when they have not applied. */
51+
export const getMyBountyApplication = async (
52+
bountyId: string
53+
): Promise<MyBountyApplication | null> =>
54+
(unwrapData(
55+
await apiClient.GET('/api/bounties/{bountyId}/v2/applications/me', {
56+
params: { path: { bountyId } },
57+
})
58+
) as unknown as MyBountyApplication | null) ?? null;
59+
60+
/** Submit an application (light / full proposal) for an application-mode bounty. */
61+
export const applyToBounty = async (
62+
bountyId: string,
63+
body: CreateBountyApplicationRequest
64+
): Promise<MyBountyApplication> =>
65+
unwrapData(
66+
await apiClient.POST('/api/bounties/{bountyId}/v2/applications', {
67+
params: { path: { bountyId } },
68+
body,
69+
})
70+
) as unknown as MyBountyApplication;
71+
72+
/** Edit a SUBMITTED application (before shortlist). */
73+
export const editBountyApplication = async (
74+
bountyId: string,
75+
appId: string,
76+
body: EditBountyApplicationRequest
77+
): Promise<MyBountyApplication> =>
78+
unwrapData(
79+
await apiClient.PATCH('/api/bounties/{bountyId}/v2/applications/{appId}', {
80+
params: { path: { bountyId, appId } },
81+
body,
82+
})
83+
) as unknown as MyBountyApplication;
84+
85+
/** Withdraw a SUBMITTED application (the application record; status -> WITHDRAWN). */
86+
export const withdrawBountyApplication = async (
87+
bountyId: string,
88+
appId: string
89+
): Promise<MyBountyApplication> =>
90+
unwrapData(
91+
await apiClient.DELETE('/api/bounties/{bountyId}/v2/applications/{appId}', {
92+
params: { path: { bountyId, appId } },
93+
})
94+
) as unknown as MyBountyApplication;
95+
96+
// ── Open competition join / leave ─────────────────────────────────────────────
97+
98+
/** Join an open competition directly (no application step). */
99+
export const joinCompetition = async (
100+
bountyId: string,
101+
body: JoinCompetitionRequest
102+
): Promise<MyBountyApplication> =>
103+
unwrapData(
104+
await apiClient.POST('/api/bounties/{bountyId}/v2/competition/join', {
105+
params: { path: { bountyId } },
106+
body,
107+
})
108+
) as unknown as MyBountyApplication;
109+
110+
/** Leave an open competition the caller joined. */
111+
export const leaveCompetition = async (
112+
bountyId: string,
113+
body: JoinCompetitionRequest
114+
): Promise<MyBountyApplication> =>
115+
unwrapData(
116+
await apiClient.DELETE('/api/bounties/{bountyId}/v2/competition/join', {
117+
params: { path: { bountyId } },
118+
body,
119+
})
120+
) as unknown as MyBountyApplication;
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Builder/participant bounty escrow client (boundless-events contract), on the
3+
* typed openapi-fetch client. Counterpart to the organizer `escrow-client.ts`:
4+
* these are the participant-scoped, no-org-prefix routes (`/api/bounties/{id}/
5+
* escrow/...`) the builder lifecycle drives through the shared escrow runner.
6+
*
7+
* Like the organizer ops, each builds an EscrowOp:
8+
* MANAGED : backend signs with the caller's platform-held wallet + submits;
9+
* the op returns in PENDING_CONFIRM (the webapp only polls).
10+
* EXTERNAL : backend returns `unsignedXdr`; the webapp signs with a connected
11+
* wallet and POSTs to submit-signed, then polls.
12+
*/
13+
import { apiClient, unwrapData } from '@/lib/api';
14+
15+
import type {
16+
ApplyBountyRequest,
17+
BountyEscrowOpResponse,
18+
ContributeBountyRequest,
19+
SubmitBountyRequest,
20+
SubmitSignedXdrRequest,
21+
WithdrawApplicationRequest,
22+
WithdrawSubmissionRequest,
23+
} from '../types';
24+
25+
/** Apply to a bounty on-chain (APPLY_TO_BOUNTY; charges application credits). */
26+
export const applyToBountyEscrow = async (
27+
bountyId: string,
28+
body: ApplyBountyRequest
29+
): Promise<BountyEscrowOpResponse> =>
30+
unwrapData(
31+
await apiClient.POST('/api/bounties/{id}/escrow/apply', {
32+
params: { path: { id: bountyId } },
33+
body,
34+
})
35+
);
36+
37+
/** Withdraw a bounty application (refunds half the application credits). */
38+
export const withdrawBountyApplicationEscrow = async (
39+
bountyId: string,
40+
body: WithdrawApplicationRequest
41+
): Promise<BountyEscrowOpResponse> =>
42+
unwrapData(
43+
await apiClient.POST('/api/bounties/{id}/escrow/withdraw-application', {
44+
params: { path: { id: bountyId } },
45+
body,
46+
})
47+
);
48+
49+
/** Anchor a work submission on-chain (SUBMIT; requires an active application). */
50+
export const submitBountyWorkEscrow = async (
51+
bountyId: string,
52+
body: SubmitBountyRequest
53+
): Promise<BountyEscrowOpResponse> =>
54+
unwrapData(
55+
await apiClient.POST('/api/bounties/{id}/escrow/submit', {
56+
params: { path: { id: bountyId } },
57+
body,
58+
})
59+
);
60+
61+
/** Withdraw a submission anchor. */
62+
export const withdrawBountySubmissionEscrow = async (
63+
bountyId: string,
64+
body: WithdrawSubmissionRequest
65+
): Promise<BountyEscrowOpResponse> =>
66+
unwrapData(
67+
await apiClient.POST('/api/bounties/{id}/escrow/withdraw-submission', {
68+
params: { path: { id: bountyId } },
69+
body,
70+
})
71+
);
72+
73+
/** Contribute funds to a bounty's escrow pool (ADD_FUNDS; 10 USDC minimum). */
74+
export const contributeToBountyEscrow = async (
75+
bountyId: string,
76+
body: ContributeBountyRequest
77+
): Promise<BountyEscrowOpResponse> =>
78+
unwrapData(
79+
await apiClient.POST('/api/bounties/{id}/escrow/contribute', {
80+
params: { path: { id: bountyId } },
81+
body,
82+
})
83+
);
84+
85+
/** Submit a wallet-signed XDR for a participant op (EXTERNAL path). */
86+
export const submitSignedParticipantBounty = async (
87+
bountyId: string,
88+
opRowId: string,
89+
body: SubmitSignedXdrRequest
90+
): Promise<BountyEscrowOpResponse> =>
91+
unwrapData(
92+
await apiClient.POST(
93+
'/api/bounties/{id}/escrow/ops/{opRowId}/submit-signed',
94+
{ params: { path: { id: bountyId, opRowId } }, body }
95+
)
96+
);
97+
98+
/** Poll the current state of a participant escrow op. */
99+
export const getParticipantBountyOp = async (
100+
bountyId: string,
101+
opRowId: string
102+
): Promise<BountyEscrowOpResponse> =>
103+
unwrapData(
104+
await apiClient.GET('/api/bounties/{id}/escrow/ops/{opRowId}', {
105+
params: { path: { id: bountyId, opRowId } },
106+
})
107+
);

features/bounties/api/use-escrow.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ import {
3232
selectBountyWinners,
3333
submitSignedBountyEscrow,
3434
} from './escrow-client';
35+
import {
36+
getParticipantBountyOp,
37+
submitSignedParticipantBounty,
38+
} from './participant-escrow-client';
3539
import {
3640
isTerminalEscrowStatus,
3741
type BountyEscrowOpResponse,
@@ -41,42 +45,43 @@ import {
4145
type SelectBountyWinnersRequest,
4246
} from '../types';
4347

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

46-
export type EscrowOpScope = {
47-
kind: 'organizer';
48-
organizationId: string;
49-
bountyId: string;
50-
};
50+
export type EscrowOpScope =
51+
| { kind: 'organizer'; organizationId: string; bountyId: string }
52+
| { kind: 'participant'; bountyId: string };
5153

5254
function getOpForScope(
5355
scope: EscrowOpScope,
5456
opRowId: string
5557
): Promise<BountyEscrowOpResponse> {
56-
return getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId);
58+
return scope.kind === 'participant'
59+
? getParticipantBountyOp(scope.bountyId, opRowId)
60+
: getBountyEscrowOp(scope.organizationId, scope.bountyId, opRowId);
5761
}
5862

5963
function submitSignedForScope(
6064
scope: EscrowOpScope,
6165
opRowId: string,
6266
signedXdr: string
6367
): Promise<BountyEscrowOpResponse> {
64-
return submitSignedBountyEscrow(
65-
scope.organizationId,
66-
scope.bountyId,
67-
opRowId,
68-
{ signedXdr }
69-
);
68+
return scope.kind === 'participant'
69+
? submitSignedParticipantBounty(scope.bountyId, opRowId, { signedXdr })
70+
: submitSignedBountyEscrow(scope.organizationId, scope.bountyId, opRowId, {
71+
signedXdr,
72+
});
7073
}
7174

7275
function escrowOpKey(scope: EscrowOpScope, opRowId: string): QueryKey {
73-
return [
74-
'bounty-escrow-op',
75-
'organizer',
76-
scope.organizationId,
77-
scope.bountyId,
78-
opRowId,
79-
];
76+
return scope.kind === 'participant'
77+
? ['bounty-escrow-op', 'participant', scope.bountyId, opRowId]
78+
: [
79+
'bounty-escrow-op',
80+
'organizer',
81+
scope.organizationId,
82+
scope.bountyId,
83+
opRowId,
84+
];
8085
}
8186

8287
// ─── 1. Polling primitive ────────────────────────────────────────────────────

0 commit comments

Comments
 (0)