You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Claiming a bounty in src/app/issues/[id]/IssueActions.tsx only checks that the user is signed in with GitHub — it never checks that they have a Stellar wallet linked at all:
asyncfunctionhandleClaim(){setError(null);setNotice(null);if(!user){router.push("/connect");return;}setPending(true);try{awaitapiPost(`/bounties/${bounty.id}/claim`,{contributorId: user.id});setNotice("You've claimed this issue. Open a pull request to get started.");router.refresh();}catch(err){setError(errinstanceofApiRequestError ? err.message : "Something went wrong.");}finally{setPending(false);}}
Compare this to handleFund, which routes through withWallet and hard-requires a connected wallet address before it will call the backend at all:
asyncfunctionwithWallet(action: (walletAddress: string)=>Promise<void>){
...
constwalletAddress=address??(awaitconnect());if(!walletAddress){setError("Connect a Stellar wallet to continue.");return;}awaitaction(walletAddress);
...
}
handleClaim has no equivalent gate. It doesn't check useWallet()'s address at all, and — more importantly — it doesn't check user.stellarAddress (the field the README and AuthUser type establish as the durable, backend-linked payout address; see types/index.ts:82-89: stellarAddress: string | null). A contributor can complete GitHub OAuth, never visit /connect's wallet section, never install/authorize Freighter at all, and still successfully claim a funded bounty via this button — because nothing here checks user.stellarAddress !== null.
Per the README's own documented lifecycle ("MergeFi listens for GitHub webhooks. When a pull request that references a funded issue is merged, the backend verifies the event and calls the escrow contract's release function automatically. There's no manual approval step once a PR is merged.") and the platform's whole value proposition ("contributors get paid the moment their pull request is merged"), a claimed-but-wallet-less bounty is a real, consequential dead end: the contributor does the work, the maintainer merges the PR, the webhook fires, the backend tries to release payment — and there's nowhere for it to go. Whatever recovery path exists for that state (does the backend hold the funds? re-notify the contributor? does the maintainer see a stuck "awaiting payout wallet" status anywhere?) isn't represented anywhere in this frontend — BountyStatus doesn't even have a state for it (open | funded | claimed | in_review | merged | paid | refunded | expired; nothing like awaiting_payout_wallet).
This is distinct from the WalletContext/Freighter-account-staleness issue and from #52's GitHub↔wallet ConnectPanel ordering — this is specifically about IssueActions.tsx permitting the claim action itself with no business-rule check that the claiming user can actually be paid.
Requirements
Before allowing handleClaim to proceed, check user.stellarAddress (or the live-reconciled wallet address, depending on how the WalletContext staleness issue elsewhere in this batch is resolved). If it's null, block the claim and route the user to connect a wallet first — mirroring the existing if (!user) { router.push("/connect"); return; } pattern already used for the GitHub-auth gate one line above.
Decide whether this should be a hard block (cannot claim without a wallet) or a soft warning (can claim, but with a clear, persistent "add a payout wallet before this is merged" prompt) — the README's "automatic, no manual approval step" payout description suggests a hard block is the safer default, since there's no evidence of a manual recovery flow for a stuck payout, but this is worth an explicit decision recorded in the PR rather than assumed silently.
If a hard block is chosen, the disabled/blocked state should be visually distinct from the existing pending (in-flight request) state so a user understands why they can't claim, not just that the button doesn't work — this parallels the "loading vs. disabled must be visually distinct" concern already raised in Make Button a correct polymorphic, accessible primitive (link vs button semantics) #55 for Button.tsx generally; reuse whatever comes out of that work rather than inventing a third disabled-state treatment.
Audit whether the same gap exists for team-split claims/contributions (teamSplits[].contributor) — does a named team member need their own linked wallet before payout, and if so, is that checked anywhere either? Document the finding even if out of scope to fully fix here.
Acceptance Criteria
A signed-in GitHub user with stellarAddress: null cannot successfully call POST /bounties/:id/claim from the UI — the action is blocked client-side with a clear explanation and a path to connect a wallet.
A signed-in GitHub user with a linked stellarAddress can claim exactly as before — no regression to the happy path.
The blocked state is visually and semantically distinct from the pending (in-flight) and generic-error states already present in this component.
The decision (hard block vs. soft warning) and its rationale are documented in the PR description.
Additional Notes
Precise references:
src/app/issues/[id]/IssueActions.tsx:45-62 — handleClaim, the function with no wallet-linkage check.
src/app/issues/[id]/IssueActions.tsx:19-36 — withWallet, the existing pattern handleClaim could mirror (though note handleClaim shouldn't necessarily require a live Freighter connection at claim time the way handleFund does — the distinction to preserve is "has a linked payout address on file" (user.stellarAddress), not "has an active Freighter session right now," since those are different requirements at different points in the flow).
src/types/index.ts:82-89 — AuthUser.stellarAddress: string | null, confirming the backend already models exactly this "authenticated but no payout wallet yet" state.
src/types/index.ts:4-12 — BountyStatus union, confirmed to have no state representing "claimed but contributor has no payout wallet," meaning even if this is fixed at claim-time, there's no visible status for an already-claimed bounty whose contributor later disconnects/never had a wallet (out of scope for this issue, but worth flagging as a related gap for whoever picks this up).
README.md's "Example user journey" and "How does MergeFi decide when to release payment?" FAQ answer — both describe fully-automatic payout release with no manual step, reinforcing why a stuck payout here is a real dead end rather than something a human ops process would catch.
Edge cases: a user who claims with a wallet linked, then disconnects/unlinks it before the PR merges (if that's even possible today — worth checking whether ConnectPanel/WalletContext expose any "disconnect" that also clears user.stellarAddress server-side, versus just clearing the local cache) would slip back into the same unpayable state after passing this new gate at claim time. If disconnect() in WalletContext only clears localStorage (confirmed: src/context/WalletContext.tsx:76-80, disconnect() never calls the backend) then this specific edge case is likely not currently reachable — worth confirming in the PR rather than assuming either way.
Test/reproduction plan: render IssueActions for a "funded" bounty with a mocked useAuth() returning user: { ...fixture, stellarAddress: null }, click "Claim this issue," and assert apiPost for the claim endpoint is never called and the blocking UI renders instead. Repeat with stellarAddress set and assert the existing claim flow still works unchanged.
Overview
Claiming a bounty in
src/app/issues/[id]/IssueActions.tsxonly checks that the user is signed in with GitHub — it never checks that they have a Stellar wallet linked at all:Compare this to
handleFund, which routes throughwithWalletand hard-requires a connected wallet address before it will call the backend at all:handleClaimhas no equivalent gate. It doesn't checkuseWallet()'saddressat all, and — more importantly — it doesn't checkuser.stellarAddress(the field the README andAuthUsertype establish as the durable, backend-linked payout address; seetypes/index.ts:82-89:stellarAddress: string | null). A contributor can complete GitHub OAuth, never visit/connect's wallet section, never install/authorize Freighter at all, and still successfully claim a funded bounty via this button — because nothing here checksuser.stellarAddress !== null.Per the README's own documented lifecycle ("MergeFi listens for GitHub webhooks. When a pull request that references a funded issue is merged, the backend verifies the event and calls the escrow contract's release function automatically. There's no manual approval step once a PR is merged.") and the platform's whole value proposition ("contributors get paid the moment their pull request is merged"), a claimed-but-wallet-less bounty is a real, consequential dead end: the contributor does the work, the maintainer merges the PR, the webhook fires, the backend tries to release payment — and there's nowhere for it to go. Whatever recovery path exists for that state (does the backend hold the funds? re-notify the contributor? does the maintainer see a stuck "awaiting payout wallet" status anywhere?) isn't represented anywhere in this frontend —
BountyStatusdoesn't even have a state for it (open | funded | claimed | in_review | merged | paid | refunded | expired; nothing likeawaiting_payout_wallet).This is distinct from the WalletContext/Freighter-account-staleness issue and from #52's GitHub↔wallet ConnectPanel ordering — this is specifically about
IssueActions.tsxpermitting the claim action itself with no business-rule check that the claiming user can actually be paid.Requirements
handleClaimto proceed, checkuser.stellarAddress(or the live-reconciled wallet address, depending on how the WalletContext staleness issue elsewhere in this batch is resolved). If it'snull, block the claim and route the user to connect a wallet first — mirroring the existingif (!user) { router.push("/connect"); return; }pattern already used for the GitHub-auth gate one line above.pending(in-flight request) state so a user understands why they can't claim, not just that the button doesn't work — this parallels the "loading vs. disabled must be visually distinct" concern already raised in Make Button a correct polymorphic, accessible primitive (link vs button semantics) #55 forButton.tsxgenerally; reuse whatever comes out of that work rather than inventing a third disabled-state treatment.teamSplits[].contributor) — does a named team member need their own linked wallet before payout, and if so, is that checked anywhere either? Document the finding even if out of scope to fully fix here.Acceptance Criteria
stellarAddress: nullcannot successfully callPOST /bounties/:id/claimfrom the UI — the action is blocked client-side with a clear explanation and a path to connect a wallet.stellarAddresscan claim exactly as before — no regression to the happy path.pending(in-flight) and generic-error states already present in this component.Additional Notes
Precise references:
src/app/issues/[id]/IssueActions.tsx:45-62—handleClaim, the function with no wallet-linkage check.src/app/issues/[id]/IssueActions.tsx:19-36—withWallet, the existing patternhandleClaimcould mirror (though notehandleClaimshouldn't necessarily require a live Freighter connection at claim time the wayhandleFunddoes — the distinction to preserve is "has a linked payout address on file" (user.stellarAddress), not "has an active Freighter session right now," since those are different requirements at different points in the flow).src/types/index.ts:82-89—AuthUser.stellarAddress: string | null, confirming the backend already models exactly this "authenticated but no payout wallet yet" state.src/types/index.ts:4-12—BountyStatusunion, confirmed to have no state representing "claimed but contributor has no payout wallet," meaning even if this is fixed at claim-time, there's no visible status for an already-claimed bounty whose contributor later disconnects/never had a wallet (out of scope for this issue, but worth flagging as a related gap for whoever picks this up).Edge cases: a user who claims with a wallet linked, then disconnects/unlinks it before the PR merges (if that's even possible today — worth checking whether
ConnectPanel/WalletContextexpose any "disconnect" that also clearsuser.stellarAddressserver-side, versus just clearing the local cache) would slip back into the same unpayable state after passing this new gate at claim time. Ifdisconnect()inWalletContextonly clearslocalStorage(confirmed:src/context/WalletContext.tsx:76-80,disconnect()never calls the backend) then this specific edge case is likely not currently reachable — worth confirming in the PR rather than assuming either way.Test/reproduction plan: render
IssueActionsfor a"funded"bounty with a mockeduseAuth()returninguser: { ...fixture, stellarAddress: null }, click "Claim this issue," and assertapiPostfor the claim endpoint is never called and the blocking UI renders instead. Repeat withstellarAddressset and assert the existing claim flow still works unchanged.