Skip to content
216 changes: 210 additions & 6 deletions src/treasuries/KeepWhatsRaised.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ import {IReward} from "../interfaces/IReward.sol";
import {ICampaignData} from "../interfaces/ICampaignData.sol";
import {IPermit2, ISignatureTransfer, PermitData} from "../interfaces/IPermit2.sol";
import {TreasuryErrors} from "../errors/TreasuryErrors.sol";
import {VoidablePledge} from "../utils/VoidablePledge.sol";

/**
* @title KeepWhatsRaised
* @notice A contract that keeps all the funds raised, regardless of the success condition.
*/
contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignData {
contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignData, VoidablePledge {
using Counters for Counters.Counter;
using SafeERC20 for IERC20;

Expand Down Expand Up @@ -93,6 +94,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
uint256 configLockPeriod;
/// @dev True if the creator is Colombian, false otherwise.
bool isColombianCreator;
/// @dev If true, tips are forwarded immediately to the platform admin during pledge.
/// For setFeeAndPledge (admin path): tip is deducted from pledgeAmount (no transfer needed).
/// For user pledges (Permit2 path): tip is transferred directly to platformAdmin.
/// When enabled, claimTip() will revert as there are no tips to claim.
bool forwardTipsImmediately;
}

uint256 private s_cancellationTime;
Expand All @@ -103,6 +109,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
FeeKeys private s_feeKeys;
Config private s_config;
CampaignData private s_campaignData;
/// @dev Cumulative tips received by the platform admin per token (forwarded or claimed via claimTip)
mapping(address => uint256) private s_tipClaimedPerToken;

// ---------------------------------------------------------------------------
// Permit2 witness types for direct user pledge functions
Expand Down Expand Up @@ -217,6 +225,22 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
*/
event KeepWhatsRaisedPaymentGatewayFeeSet(bytes32 indexed pledgeId, uint256 fee);

/**
* @dev Emitted when a tip is forwarded immediately to the platform admin during a pledge.
* @param pledgeId The unique identifier of the pledge.
* @param backer The address of the backer who made the pledge.
* @param pledgeToken The token used for the tip.
* @param tipAmount The amount of tip forwarded.
* @param tokenId The ID of the NFT minted for the pledge.
*/
event TipForwarded(
bytes32 indexed pledgeId,
address indexed backer,
address indexed pledgeToken,
uint256 tipAmount,
uint256 tokenId
);

/**
* @dev Emitted when an unauthorized action is attempted.
*/
Expand Down Expand Up @@ -353,6 +377,9 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
*/
error KeepWhatsRaisedPledgeAlreadyProcessed(bytes32 pledgeId);

/// @dev Reverts when claimTip() is called but tips are configured to be forwarded immediately.
error KeepWhatsRaisedTipsAlreadyForwarded();

/**
* @dev Ensures that withdrawals are currently enabled.
* Reverts with `KeepWhatsRaisedDisabled` if the withdrawal approval flag is not set.
Expand Down Expand Up @@ -459,7 +486,10 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa

for (uint256 i = 0; i < acceptedTokens.length; i++) {
address token = acceptedTokens[i];
uint256 refundedAmount = s_tokenLifetimeRaisedAmounts[token] - s_tokenRaisedAmounts[token];
// Exclude voided amounts so they are not misreported as refunds.
uint256 refundedAmount = s_tokenLifetimeRaisedAmounts[token]
- s_tokenRaisedAmounts[token]
- s_tokenVoidedAmounts[token];
if (refundedAmount > 0) {
amount += _normalizeAmount(token, refundedAmount);
}
Expand All @@ -468,6 +498,23 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
return amount;
}

/**
* @notice Retrieves the total voided pledge amount across all tokens, normalized to 18 decimals.
* @dev Voided pledges are neither refunds nor active raises; this getter exposes the
* voided total separately for off-chain accounting and auditing.
* @return amount Total voided amount in 18-decimal normalized form.
*/
function getVoidedAmount() external view returns (uint256 amount) {
address[] memory acceptedTokens = INFO.getAcceptedTokens();
for (uint256 i = 0; i < acceptedTokens.length; i++) {
address token = acceptedTokens[i];
uint256 voided = s_tokenVoidedAmounts[token];
if (voided > 0) {
amount += _normalizeAmount(token, voided);
}
}
}

/**
* @notice Retrieves the currently available raised amount in the treasury.
* @return amount Available raised amount across all tokens, normalized to 18 decimals.
Expand Down Expand Up @@ -510,6 +557,33 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
return s_campaignData.goalAmount;
}

/**
* @notice Retrieves the cumulative tip amount received by the platform admin for a specific token.
* @dev Includes tips forwarded immediately during pledges and tips claimed via claimTip().
* @param token The token address to query.
* @return The total tip amount received for the specified token.
*/
function getTipClaimedPerToken(address token) external view returns (uint256) {
return s_tipClaimedPerToken[token];
}

/**
* @notice Retrieves the total tip amount received by the platform admin across all tokens,
* normalized to 18 decimals.
* @dev Includes tips forwarded immediately during pledges and tips claimed via claimTip().
* @return amount Total tip amount in 18-decimal normalized form.
*/
function getTotalTipClaimed() external view returns (uint256 amount) {
address[] memory acceptedTokens = INFO.getAcceptedTokens();
for (uint256 i = 0; i < acceptedTokens.length; i++) {
address token = acceptedTokens[i];
uint256 tokenAmount = s_tipClaimedPerToken[token];
if (tokenAmount > 0) {
amount += _normalizeAmount(token, tokenAmount);
}
}
}

/**
* @notice Retrieves the payment gateway fee for a given pledge ID.
* @param pledgeId The unique identifier of the pledge.
Expand Down Expand Up @@ -791,7 +865,6 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
whenCampaignNotCancelled
whenNotCancelled
{
//Set Payment Gateway Fee
setPaymentGatewayFee(pledgeId, fee);

PermitData memory emptyPermitData = PermitData({nonce: 0, deadline: 0, signature: ""});
Expand Down Expand Up @@ -1143,6 +1216,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
*/
function claimRefund(uint256 tokenId)
external
whenPledgeNotVoided(tokenId)
currentTimeIsGreater(getLaunchTime())
whenCampaignNotPaused
whenNotPaused
Expand Down Expand Up @@ -1219,6 +1293,10 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
* - Tip amount must be non-zero.
*/
function claimTip() external onlyPlatformAdmin(PLATFORM_HASH) whenCampaignNotPaused whenNotPaused {
if (s_config.forwardTipsImmediately) {
revert KeepWhatsRaisedTipsAlreadyForwarded();
}

if (s_cancellationTime == 0 && block.timestamp <= getDeadline()) {
revert KeepWhatsRaisedNotClaimableAdmin();
}
Expand All @@ -1237,6 +1315,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa

if (tip > 0) {
s_tipPerToken[token] = 0;
s_tipClaimedPerToken[token] += tip;
IERC20(token).safeTransfer(platformAdmin, tip);
emit TipClaimed(tip, platformAdmin);
}
Expand Down Expand Up @@ -1338,7 +1417,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
pledgeAmountInTokenDecimals = pledgeAmount;
}

uint256 totalAmount = pledgeAmountInTokenDecimals + tip;
address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH);
// When tip forwarding is enabled and the token source is the platform admin, the tip
// already resides in the admin's wallet — only the pledge amount is transferred.
bool tipFundedByAdmin = s_config.forwardTipsImmediately && tip > 0 && tokenSource == platformAdmin;
uint256 totalAmount = tipFundedByAdmin ? pledgeAmountInTokenDecimals : pledgeAmountInTokenDecimals + tip;
uint256 actualPledgeAmount;

if (usePermit2) {
Expand Down Expand Up @@ -1385,9 +1468,20 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
uint256 tokenId = INFO.mintNFTForPledge(backer, reward, pledgeToken, actualPledgeAmount, 0, tip);

s_tokenToPledgedAmount[tokenId] = actualPledgeAmount;
s_tokenToTippedAmount[tokenId] = tip;
s_tokenIdToPledgeToken[tokenId] = pledgeToken;
s_tipPerToken[pledgeToken] += tip;

s_tokenToTippedAmount[tokenId] = tip;

if (s_config.forwardTipsImmediately && tip > 0) {
s_tipClaimedPerToken[pledgeToken] += tip;
// Transfer tip only when it arrived in the treasury (non-admin token source).
if (!tipFundedByAdmin) {
IERC20(pledgeToken).safeTransfer(platformAdmin, tip);
}
emit TipForwarded(pledgeId, backer, pledgeToken, tip, tokenId);
} else {
s_tipPerToken[pledgeToken] += tip;
}
s_tokenRaisedAmounts[pledgeToken] += actualPledgeAmount;
s_tokenLifetimeRaisedAmounts[pledgeToken] += actualPledgeAmount;

Expand Down Expand Up @@ -1442,9 +1536,119 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa

s_tokenToPaymentFee[tokenId] = totalFee;

// Record per-pledge fee split for potential future void reversal.
_recordPledgeFees(tokenId, protocolFee, totalFee - protocolFee);

return pledgeAmount - totalFee;
}

// ── VoidablePledge hook implementations ──────────────────────────────────

/// @inheritdoc VoidablePledge
function _getVoidablePledgeAmount(uint256 tokenId) internal view override returns (uint256) {
return s_tokenToPledgedAmount[tokenId];
}

/// @inheritdoc VoidablePledge
function _getVoidablePledgeToken(uint256 tokenId) internal view override returns (address) {
return s_tokenIdToPledgeToken[tokenId];
}

/**
* @inheritdoc VoidablePledge
* @dev Returns the stored tip for the tokenId. For pledges where tips were forwarded
* immediately (`forwardTipsImmediately = true`), the tip is already gone from the
* treasury; `voidPledge` will detect this and skip tip reversal.
*/
function _getVoidablePledgeTip(uint256 tokenId) internal view override returns (uint256) {
return s_tokenToTippedAmount[tokenId];
}

// ── voidPledge ────────────────────────────────────────────────────────────

/**
* @notice Voids a single pledge, reversing its accounting and recovering funds.
*
* @dev Platform admin-only emergency function for fraud/dispute resolution.
* Designed to work at any point in the campaign lifecycle — before deadline,
* during or after the refund window, after cancellation, etc.
*
* What this function does:
* 1. Delegates validation and flag-setting to `VoidablePledge._prepareVoid`.
* 2. Reverses fee accruals in `s_protocolFeePerToken` / `s_platformFeePerToken`
* up to the amount still in each bucket (partial if fees were already disbursed).
* 3. Reverses un-claimed tip in `s_tipPerToken` (skipped if tips are forwarded
* immediately or `claimTip` was already called).
* 4. Decrements `s_tokenRaisedAmounts` by the full pledge amount.
* 5. Decrements `s_availablePerToken` by the net pledge amount, capped to avoid
* underflow when funds were partially withdrawn beforehand.
* 6. Clears all per-pledge storage.
* 7. Transfers all recoverable tokens to the platform admin.
* 8. Emits `PledgeVoided`.
*
* The NFT receipt is NOT burned — it is marked unusable via the `s_isVoided`
* flag in `VoidablePledge`. Forced burn is not possible without backer approval
* under ERC721Burnable semantics.
*
* @param tokenId The NFT token ID of the pledge to void.
* @param reason An arbitrary bytes32 reason code (e.g. keccak256("FRAUD")).
*/
function voidPledge(uint256 tokenId, bytes32 reason)
external
nonReentrant
onlyPlatformAdmin(PLATFORM_HASH)
{
// Validate, mark voided, accumulate s_tokenVoidedAmounts, return amounts.
VoidAmounts memory v = _prepareVoid(tokenId);

address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH);

// ── Reverse fee accruals (capped: fees may have been disbursed already) ──
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;
Comment on lines +1607 to +1610
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 👍 / 👎.


// ── Reverse un-claimed tip ────────────────────────────────────────────
// Skip if tips are forwarded immediately (already sent to admin during pledge)
// or if claimTip() was already called (s_tipClaimed = true).
uint256 tipReversed = 0;
if (!s_config.forwardTipsImmediately && !s_tipClaimed && v.tip > 0) {
tipReversed = _min(v.tip, s_tipPerToken[v.pledgeToken]);
s_tipPerToken[v.pledgeToken] -= tipReversed;
}

// ── Reverse raised amount ─────────────────────────────────────────────
s_tokenRaisedAmounts[v.pledgeToken] -= v.pledgeAmount;

// ── Reverse available amount (capped to prevent underflow) ────────────
// Net pledge = pledge minus all fees. Available may be lower than net if
// the creator already made partial withdrawals.
uint256 netPledgeAmount = v.pledgeAmount - v.totalFee;
uint256 availableReversed = _min(netPledgeAmount, s_availablePerToken[v.pledgeToken]);
s_availablePerToken[v.pledgeToken] -= availableReversed;

// ── Clear treasury-owned per-pledge storage ───────────────────────────
s_tokenToPledgedAmount[tokenId] = 0;
s_tokenToPaymentFee[tokenId] = 0;
s_tokenToTippedAmount[tokenId] = 0;

// ── Transfer all recoverable tokens to platform admin ─────────────────
uint256 totalRecoverable = availableReversed + protocolFeeReversed + platformFeeReversed + tipReversed;
if (totalRecoverable > 0) {
IERC20(v.pledgeToken).safeTransfer(platformAdmin, totalRecoverable);
}

emit PledgeVoided(tokenId, v.pledgeToken, totalRecoverable, reason);
}

/// @dev Returns the smaller of two uint256 values.
function _min(uint256 a, uint256 b) private pure returns (uint256) {
return a < b ? a : b;
}

// ── _checkRefundPeriodStatus ──────────────────────────────────────────────

/**
* @dev Checks the refund period status based on campaign state
* @param checkIfOver If true, returns whether refund period is over; if false, returns whether currently within refund period
Expand Down
Loading
Loading