Skip to content

Gate bounty claiming on a linked payout wallet — IssueActions lets a user claim with stellarAddress: null #72

Description

@chonilius

Overview

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:

async function handleClaim() {
  setError(null);
  setNotice(null);
  if (!user) {
    router.push("/connect");
    return;
  }
  setPending(true);
  try {
    await apiPost(`/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(err instanceof ApiRequestError ? 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:

async function withWallet(action: (walletAddress: string) => Promise<void>) {
  ...
  const walletAddress = address ?? (await connect());
  if (!walletAddress) {
    setError("Connect a Stellar wallet to continue.");
    return;
  }
  await action(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-62handleClaim, the function with no wallet-linkage check.
  • src/app/issues/[id]/IssueActions.tsx:19-36withWallet, 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-89AuthUser.stellarAddress: string | null, confirming the backend already models exactly this "authenticated but no payout wallet yet" state.
  • src/types/index.ts:4-12BountyStatus 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignarchitectureArchitecture/design issuebugSomething isn't workingvery hardVery difficult task, expert-level effort required

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions