Skip to content

Security MEDIUM: GitlawbBounty.disputeBounty erases agent submitted work, blocking rightful payout #13

Description

@philpof102-svg

Gitlawb/contracts — MEDIUM — disputeBounty erases agent's submitted work

Phil-ready issue body for gh issue create --repo Gitlawb/contracts.
Reviewed by C1 with the bug-bounty-hunter sub-agent. Cooling-off:
filed 2026-06-07 — wait at least 30 min before posting, or
sleep on it. Per feedback_pre_submit_cooling_off.

Severity — MEDIUM

No creator funds at risk, but agent's rightful payout is blocked
permanently after they've already invested labor.

Mechanism

GitlawbBounty.disputeBounty(uint256) allows ANY caller to dispute a
bounty in either Claimed OR Submitted status once the deadline
passes:

function disputeBounty(uint256 bountyId) external {
    Bounty storage b = bounties[bountyId];
    // Can dispute Claimed or Submitted if deadline passed
    if (b.status != Status.Claimed && b.status != Status.Submitted) {
        revert InvalidStatus(bountyId, Status.Claimed, b.status);
    }
    if (block.timestamp <= b.claimedAt + b.deadline) {
        revert DeadlineNotExceeded(bountyId);
    }
    b.status = Status.Open;
    b.claimantDid = "";
    b.claimantAddress = address(0);
    b.prId = "";
}

The contract comment above the function says it is for cases where
the agent missed the deadline. But the post-submission wipe also
fires when the agent met the deadline and the creator simply hasn't
approved yet.

Attack path

  1. Creator opens a bounty with defaultDeadline = 7 days.
  2. Agent claims on day 1 (claimedAt = T).
  3. Agent submits a valid PR on day 5 (status = Submitted,
    submittedAt = T + 5 days, prId populated).
  4. Creator delays approval (out of town, busy, intentional).
  5. At block.timestamp > T + 7 days, any third party — a competing
    agent, a frontrunner watching the mempool — calls
    disputeBounty(id).
  6. Both guards pass. The bounty resets to Open. claimantAddress = 0.
    prId = "". The agent's completed work is erased from the
    contract's view; their on-chain claim to the payout disappears.

The agent's labor cannot be recovered onchain. They must either
re-claim and re-submit (assuming the grief continues) or coordinate
out-of-band with the creator.

Why this is distinct from prior disclosures

  • PR Security: claimBounty() permissionless DoS + agentDid spoofing #6 ("claimBounty permissionless DoS + agentDid spoofing")
    covers claimBounty — different function, different state
    transition (Open → Claimed), different victim (claimant locked
    out from claiming, not agent losing already-submitted work).
  • No existing PR or merged change addresses the Submitted-state
    dispute path.

Test gap

test/GitlawbBounty.t.sol has test_disputeBounty_afterDeadline but
the existing case only exercises a Claimed-status bounty. No test
exercises disputeBounty against a Submitted bounty.

Suggested fix (1 line)

 function disputeBounty(uint256 bountyId) external {
     Bounty storage b = bounties[bountyId];
-    // Can dispute Claimed or Submitted if deadline passed
-    if (b.status != Status.Claimed && b.status != Status.Submitted) {
+    // Can only dispute Claimed bounties — not Submitted
+    // (agent fulfilled obligation; only creator's approveBounty path
+    // should advance from here)
+    if (b.status != Status.Claimed) {
         revert InvalidStatus(bountyId, Status.Claimed, b.status);
     }

If the team wants a fallback for stuck Submitted bounties (creator
unreachable), the safer shape is a separate function gated on a
longer timeout (e.g. 30 days post-submission) that refunds creator
AND records the agent's PR for off-chain dispute resolution — not a
state-erasure.

Foundry test (drop into test/GitlawbBounty.t.sol)

function test_disputeBounty_doesNotEraseSubmittedWork() public {
    uint256 id = _createAndClaim();
    vm.prank(AGENT);
    bounty.submitBounty(id, "pr-42");

    // Fast-forward past deadline
    vm.warp(block.timestamp + 8 days);

    // Should revert — submitted work must survive until
    // creator's approveBounty path runs
    vm.expectRevert();
    bounty.disputeBounty(id);
}

Deployment

Sepolia: 0x8fc59d42b56fc153bcb9f871aae8e32bcf530789 (bytecode
confirmed live). Mainnet imminent per the network status page.

3-skeptic pass log

  1. "Creator should approve before deadline — operational issue, not a
    bug." → Permissionless griefing party (competitor, frontrunner)
    can erase submission without creator's knowledge. Contract comment
    explicitly says "Dispute a bounty if the agent missed the
    deadline." Agent who submitted before deadline has not missed it.

  2. "PR Security: claimBounty() permissionless DoS + agentDid spoofing #6 covers this." → PR Security: claimBounty() permissionless DoS + agentDid spoofing #6 scopes to claimBounty. The
    disputeBounty Submitted-wipe is a distinct function, distinct
    transition, distinct victim.

  3. "Severity overstated — creator funds are safe." → Agent's labor
    is the harm. agentEarnings and agentCompletedCount indicate
    agent compensation is an explicit protocol goal. High-value
    bounty = days of unpaid work irrecoverable. MEDIUM stands.

Phil-side filing checklist

  • 30+ min cooling-off from this draft, or sleep on it
  • cd D:\Users\VolKov\veilleIA\agent-veille
  • gh issue create --repo Gitlawb/contracts --title 'Security MEDIUM: GitlawbBounty.disputeBounty erases agent submitted work, blocking rightful payout' --body-file distribution/gitlawb-round9-disputeBounty-medium.md
  • Reference the file or strip the front-matter "Phil-ready" line
    before posting if you prefer

Activity

  1. safal207 commented on Aug 9, 2026

    @safal207

    Hi! I independently reproduced this behavior locally against the pinned "GitlawbBounty.sol" source.

    Reproduction path:

    "create → claim → submit before deadline → pass original claim deadline → unrelated third party calls disputeBounty → Submitted → Open → claimant/PR metadata cleared → creator can no longer approve the submitted work"

    I documented the independent reproduction here:
    safal207/ContractGraph-QA#22

    One question to confirm the intended protocol semantics:

    Is a bounty that was successfully submitted before the claim deadline intended to remain reviewable by the creator after that deadline, or is the "Submitted" state intentionally allowed to expire using the original claim deadline?

    Thanks!

  2. philpof102-svg commented on Aug 14, 2026

    @philpof102-svg
    Author

    Thanks a lot for taking the time to reproduce this independently — really appreciated, and nice writeup on your side.

    (Apologies for the earlier version of this comment — I pasted your text back instead of my reply. Copy-paste fail on my end.)

    On your question: from my reading it's not intended. The code comment above disputeBounty says it exists for agents who missed the deadline — an agent who submitted in time hasn't missed anything, so the creator should still be able to review and approve after the claim deadline passes. That's exactly the bug: as written, any third party can wipe a valid submission once that deadline is over. The one-line fix in the issue restricts disputes to Claimed status; if the team wants an escape hatch for unresponsive creators, I'd argue it should be a separate longer-timeout path that preserves the PR metadata rather than erasing it.

    Curious to hear the maintainers' take on the intended semantics here.

  3. philpof102-svg commented on Aug 14, 2026

    @philpof102-svg
    Author

    Thanks a lot for taking the time to reproduce this independently really appreciated, and nice writeup on your side

    (Apologies for the earlier version of this comment I pasted your text back instead of my reply. Copy-paste fail on my end

    On your question: from my reading it's not intended The code comment above disputeBounty says it exists for agents who missed the deadline an agent who submitted in time hasn't missed anything, so the creator should still be able to review and approve after the claim deadline passes. That's exactly the bug: as written, any third party can wipe a valid submission once that deadline is over. The one-line fix in the issue restricts disputes to Claimed status; if the team wants an escape hatch for unresponsive creators, I'd argue it should be a separate longer-timeout path that preserves the PR metadata rather than erasing it

    Curious to hear the maintainers' take on the intended semantics here.

  4. mahirhir commented on Aug 27, 2026

    @mahirhir

    For protecting submitted agent labor against state erasure during disputes:

    1. Explicit Multi-State Dispute Transition:

      • Restrict disputeBounty to Status.Claimed where no work artifact was attached:
        function disputeBounty(uint256 bountyId) external {
            Bounty storage b = bounties[bountyId];
            // Only unclaimed/abandoned claims can be reset to Open
            if (b.status != Status.Claimed) {
                revert InvalidStatus(bountyId, Status.Claimed, b.status);
            }
            if (block.timestamp <= b.claimedAt + b.deadline) {
                revert DeadlineNotExceeded(bountyId);
            }
            b.status = Status.Open;
            b.claimantDid = "";
            b.claimantAddress = address(0);
            b.prId = "";
        }
    2. Non-Destructive Dispute Escalation for Submitted Work:

      • For Status.Submitted, introduce an explicit arbitration state (Status.Disputed) that preserves b.prId and b.claimantAddress in the event log, preventing permissionless frontrunning while generating a verifiable dispute receipt.

    We use this strict state-machine guard and non-destructive work preservation pattern in https://github.com/TraceFold/tracefold (architecture in docs/TRACEFOLD_TR.md §4.1) to prevent irreversible task state wipes.

  5. philpof102-svg commented on Aug 28, 2026

    @philpof102-svg
    Author

    Thanks — the Status.Disputed idea is the right shape, and it's better than what I proposed in the issue.

    My one-liner only prevents the erasure; it leaves the original problem standing, which is an unresponsive creator sitting on a valid submission forever. Your split fixes both: disputeBounty stays destructive but only for Claimed, where nothing was produced yet, and Submitted moves to a non-destructive arbitration state that preserves prId and claimantAddress. That's a state machine where the destructive edge can only ever touch a state with no work attached — which is the invariant I actually wanted and didn't manage to write.

    Two things I'd want settled before this is a patch:

    Who may call the escalation, and after how long. If Submitted → Disputed is permissionless with the same timeout, we've moved the griefing rather than removed it — a third party can still park a valid submission in arbitration the moment the deadline passes. My instinct is a separate, longer timeout and a restriction to the two parties, but that's a protocol decision for the maintainers, not mine.
    Who resolves Disputed, and what happens if nobody does. A state with no exit is a fund lock. The dispute receipt is worth having on its own, but it needs a terminal path.

    On the emitted receipt: worth stating explicitly whether the event is the only record or whether the storage fields survive too. Log-only preservation is enough for an off-chain observer and not enough for an on-chain approval path — the creator still needs prId in storage to approve afterwards.

    I haven't reviewed the TraceFold implementation, so I'm not commenting on it either way — the reasoning above stands on the snippet in your comment

  6. mahirhir commented on Aug 31, 2026

    @mahirhir

    On the storage question: log-only isn't enough, for the reason you gave. If the creator still needs prId to approve after arbitration, Disputed has to preserve the storage fields, and only a terminal transition may clear them; the event is the observer's record, not the protocol's. On the two open points I land where your instinct does: escalation restricted to the two parties with a separate, longer timeout, otherwise the griefing just moves; and Disputed needs a timeout-driven default exit, a state with no exit is a fund lock whichever way it defaults. The numbers are maintainer calls, but the invariant you named, destructive edges only touch states with no work attached, holds through all of it.

  7. philpof102-svg commented on Sep 1, 2026

    @philpof102-svg
    Author

    Yeah, agreed on all three

    You're right that log-only doesn't cover it. If the creator still has to approve prId
    after arbitration, Disputed has to keep the storage — only a terminal transition should
    clear it. The event is the observer's record, not the protocol's

    On escalation: restricting it to the two parties with its own longer timeout is what I'd
    do too. Open it wider and the griefing just moves somewhere else

    And yeah, Disputed needs a timeout default. A state you can only leave if someone chooses
    to act is a fund lock. Which way it defaults is your call — but it needs one

    Timeouts and who pays for arbitration are yours. The invariant holds either way

    Happy to open a PR once you've picked a shape, or leave it with you — whatever's easier

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions