Skip to content

Add VoidablePledge module with unit tests#89

Open
Suvadra-Barua wants to merge 9 commits intomainfrom
feat/proposal-p2
Open

Add VoidablePledge module with unit tests#89
Suvadra-Barua wants to merge 9 commits intomainfrom
feat/proposal-p2

Conversation

@Suvadra-Barua
Copy link
Copy Markdown
Collaborator

Problem

No mechanism existed to reverse a single pledge on-chain when an individual contribution was flagged as fraud or a dispute was lost. The COPM from a fraudulent pledge remained locked in the treasury, and the adjustment was handled entirely off-chain (excluded from fiat off-ramp calculation), creating a permanent on-chain/off-chain discrepancy. getRaisedAmount() on-chain would always be higher than real totals.

This was especially urgent for Stripe disputes, which can take up to 75 days to resolve — often after the creator has already withdrawn and the refund window has closed.

Solution

A composable VoidablePledge abstract module (src/utils/VoidablePledge.sol) that any treasury can opt into by inheritance. KeepWhatsRaised is the first treasury to integrate it.

Design decision — status flag, not forced burn: The NFT receipt is marked as voided via an on-chain flag rather than being burned. Forced burn via ERC721Burnable requires the token owner or an approved operator — in a fraud scenario the backer is adversarial or absent and will never grant approval. The flag approach avoids this dependency entirely while keeping the historical pledge visible on-chain but explicitly marked unusable.

Changes

src/utils/VoidablePledge.sol (new)

A self-contained abstract module providing:

  • Storage: void flag per tokenId, per-pledge fee split (protocol / platform), per-token voided amount accumulator
  • _recordPledgeFees(tokenId, protocolFee, platformFee) — called by the treasury during fee calculation to capture the per-pledge fee breakdown needed for future reversal
  • _prepareVoid(tokenId) → VoidAmounts — validates, marks the pledge voided, accumulates s_tokenVoidedAmounts, and returns all amounts for the treasury to reverse
  • whenPledgeNotVoided(tokenId) modifier for guarding claimRefund
  • isPledgeVoided(tokenId) and getVoidedAmountPerToken(token) view helpers
  • PledgeVoided event, typed errors VoidablePledgeAlreadyVoided / VoidablePledgeNotFound
  • Three abstract hook functions the implementing treasury must provide

src/treasuries/KeepWhatsRaised.sol (modified)

  • Inheritance (Added VoidablePledge)
  • _calculateNetAvailable: Calls _recordPledgeFees to capture per-pledge fee split at pledge time
  • getRefundedAmount: Subtracts s_tokenVoidedAmounts so voided pledges are not misreported as refunds
  • New getVoidedAmount(): Normalized view of total voided amounts across all tokens
  • New hook implementations: _getVoidablePledgeAmount, _getVoidablePledgeToken, _getVoidablePledgeTip
  • voidPledge(tokenId, reason): Platform admin-only; reverses all fee accruals (capped to prevent underflow if fees were already disbursed), reverses unclaimed tips, decrements raised and available amounts, transfers all recoverable tokens to platform admin
  • claimRefund: Guarded with whenPledgeNotVoided modifier

test/foundry/unit/VoidablePledge.t.sol (new)

36 unit tests covering all edge cases:

  • Access control (non-admin, campaign owner)
  • Validation (non-existent token, already voided, already refunded)
  • Full accounting reversal and token recovery
  • claimRefund blocked on voided pledges
  • Fee disbursement timing: full recovery before disburseFees, partial recovery after, zero recovery after both disburseFees + claimFund
  • Partial withdrawal underflow guard
  • Cancelled treasury support
  • Tip handling: unclaimed, already claimed via claimTip, and forwarded immediately (forwardTipsImmediately = true)
  • getRefundedAmount / getVoidedAmount accuracy
  • Multiple pledge isolation (voiding one does not affect siblings)

Suvadra-Barua and others added 9 commits April 2, 2026 21:24
Cover the three key behaviors when Config.forwardTipsImmediately is true:
- claimTip() reverts with KeepWhatsRaisedTipsAlreadyForwarded
- Permit2 pledge forwards tip to platform admin at pledge time
- Admin setFeeAndPledge splits pledge vs tip accounting correctly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 631015ff86

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1607 to +1610
uint256 protocolFeeReversed = _min(v.protocolFee, s_protocolFeePerToken[v.pledgeToken]);
uint256 platformFeeReversed = _min(v.platformFee, s_platformFeePerToken[v.pledgeToken]);
s_protocolFeePerToken[v.pledgeToken] -= protocolFeeReversed;
s_platformFeePerToken[v.pledgeToken] -= platformFeeReversed;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent voids from consuming newer pledges’ fee buckets

voidPledge reverses fees by taking min(storedFee, s_*FeePerToken[token]) from the aggregate per-token buckets, so a void for an older pledge can consume fees accrued by newer pledges after an earlier disburseFees(). Concretely: pledge A accrues fees, fees are disbursed (A’s fees leave), pledge B accrues new fees, then voiding A will subtract B’s fee bucket and transfer it to the platform admin, causing later disbursement to underpay protocol/platform for B. This breaks per-pledge fee accounting whenever fee disbursement and voiding are interleaved over time.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants