Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b2c09ec
WIP: claiming tip while setting pledge and tip
Suvadra-Barua Apr 2, 2026
072d359
Revert "WIP: claiming tip while setting pledge and tip"
Suvadra-Barua Apr 9, 2026
49f0b96
feat: add tip forwarding immidiately
Suvadra-Barua Apr 10, 2026
81df945
test(foundry): set forwardTipsImmediately in Defaults CONFIG helpers
Suvadra-Barua Apr 10, 2026
dc44e28
test(foundry): add forwardTipsImmediately unit tests
ccp-manash Apr 10, 2026
59ae200
Merge pull request #86 from oak-network/feat/p4-tip-forwarding-tests
ccp-manash Apr 10, 2026
2b25d9a
fix: double tip forwarding error
Suvadra-Barua Apr 10, 2026
e89e40b
test(foundry): refactor and expand tip forwarding unit tests
Suvadra-Barua Apr 10, 2026
a345965
feat: introduce voidable pledges and integrate into KWR treasury
adnanhq Apr 16, 2026
92ae3d4
refactor: cap void reversals, separate voided accounting from lifetim…
adnanhq Apr 16, 2026
afbffc5
refactor: simplify void pledge handling to stay within contract size …
adnanhq Apr 17, 2026
0d7a480
fix: prevent voidPledge from clawing back fees from later pledges
adnanhq Apr 17, 2026
25cca53
fix: use pledgeToken sentinel in voidPledge to support tip-only pledg…
adnanhq Apr 17, 2026
e27eaa9
chore: integrate KWR refund cancellation fix from main and refactor f…
adnanhq Apr 22, 2026
7abd8fb
Merge branch 'main' into feat/kwr-void-pledge
adnanhq Apr 22, 2026
fb4fc73
feat(KeepWhatsRaised): redirect all fees to platform admin on cancell…
Suvadra-Barua Apr 23, 2026
2137bb9
fix(KeepWhatsRaised): use effective cancellation time in disburseFees
adnanhq Apr 23, 2026
5682b11
chore(foundry): optimize for contract size (optimizer_runs=1)
adnanhq Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ out = "artifacts"
libs = ["lib"]
via_ir = true
optimizer = true
optimizer_runs = 200
optimizer_runs = 1
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore optimizer runs for production profile

Changing optimizer_runs from 200 to 1 in the default Foundry profile materially shifts optimization toward deployment cost and away from runtime execution, which can significantly increase gas usage for all user-facing contract calls and produce different bytecode than prior audited/deployed expectations. Unless this is intentionally scoped to a separate local/test profile, this default change is a performance regression for production builds.

Useful? React with 👍 / 👎.

solc_version = "0.8.22"

remappings = [
Expand Down
224 changes: 196 additions & 28 deletions src/treasuries/KeepWhatsRaised.sol
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
mapping(address => uint256) private s_platformFeePerToken; // Platform fees per token
mapping(address => uint256) private s_tipPerToken; // Tips per token
mapping(address => uint256) private s_availablePerToken; // Available amount per token
mapping(address => uint256) private s_tokenVoidedAmounts; // Cumulative voided pledge amount per token
// Tracks the latest pledge id minted by this treasury and the last such boundary observed
// when a token's shared fee buckets were disbursed. A void may only reverse fees for pledges
// minted after the most recent disbursement boundary for that token.
uint256 private s_lastMintedPledgeTokenId;
mapping(address => uint256) private s_lastFeeDisbursedPledgeTokenId;

// Counter for reward tiers
Counters.Counter private s_rewardCounter;
Expand Down Expand Up @@ -94,6 +100,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;
}

bool private s_isWithdrawalApproved;
Expand All @@ -103,6 +114,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 +230,25 @@ 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 a pledge is voided by the platform admin.
event PledgeVoided(uint256 indexed tokenId, uint256 amount);

/**
* @dev Emitted when an unauthorized action is attempted.
*/
Expand Down Expand Up @@ -353,14 +385,18 @@ 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 Reverts when voidPledge is called but the tokenId does not correspond to an active pledge (already refunded, voided, or never minted).
error KeepWhatsRaisedVoidPledgeNotFound();

/**
* @dev Ensures that withdrawals are currently enabled.
* Reverts with `KeepWhatsRaisedDisabled` if the withdrawal approval flag is not set.
*/
modifier withdrawalEnabled() {
if (!s_isWithdrawalApproved) {
revert KeepWhatsRaisedDisabled();
}
_withdrawalEnabled();
_;
}

Expand All @@ -370,20 +406,34 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
* The lock period is defined as the duration before the deadline during which configuration changes are not allowed.
*/
modifier onlyBeforeConfigLock() {
if (block.timestamp > s_campaignData.deadline - s_config.configLockPeriod) {
revert KeepWhatsRaisedConfigLocked();
}
_onlyBeforeConfigLock();
_;
}

/// @notice Restricts access to only the platform admin or the campaign owner.
/// @dev Checks if `_msgSender()` is either the platform admin (via `INFO.getPlatformAdminAddress`)
/// or the campaign owner (via `INFO.owner()`). Reverts with `KeepWhatsRaisedUnAuthorized` if not authorized.
modifier onlyPlatformAdminOrCampaignOwner() {
_onlyPlatformAdminOrCampaignOwner();
_;
}

function _withdrawalEnabled() internal view {
if (!s_isWithdrawalApproved) {
revert KeepWhatsRaisedDisabled();
}
}

function _onlyBeforeConfigLock() internal view {
if (block.timestamp > s_campaignData.deadline - s_config.configLockPeriod) {
revert KeepWhatsRaisedConfigLocked();
}
}

function _onlyPlatformAdminOrCampaignOwner() internal view {
if (_msgSender() != INFO.getPlatformAdminAddress(PLATFORM_HASH) && _msgSender() != INFO.owner()) {
revert KeepWhatsRaisedUnAuthorized();
}
_;
}

/**
Expand Down Expand Up @@ -459,7 +509,8 @@ 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];
uint256 refundedAmount = s_tokenLifetimeRaisedAmounts[token] - s_tokenRaisedAmounts[token]
- s_tokenVoidedAmounts[token];
if (refundedAmount > 0) {
amount += _normalizeAmount(token, refundedAmount);
}
Expand All @@ -468,6 +519,24 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
return amount;
}

/**
* @notice Retrieves the cumulative voided amount across all tokens, normalized to 18 decimals.
* @return amount Voided amount across all tokens, normalized to 18 decimals.
*/
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 voidedAmount = s_tokenVoidedAmounts[token];
if (voidedAmount > 0) {
amount += _normalizeAmount(token, voidedAmount);
}
}

return amount;
}

/**
* @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 +579,16 @@ 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 payment gateway fee for a given pledge ID.
* @param pledgeId The unique identifier of the pledge.
Expand Down Expand Up @@ -791,7 +870,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 @@ -1165,10 +1243,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
if (netRefundAmount == 0) revert KeepWhatsRaisedRefundAmountZero();
if (s_availablePerToken[pledgeToken] < netRefundAmount) revert KeepWhatsRaisedInsufficientAvailableForRefund(tokenId);

s_tokenToPledgedAmount[tokenId] = 0;
s_tokenRaisedAmounts[pledgeToken] -= amountToRefund;
_clearPledgeLedger(tokenId, pledgeToken, amountToRefund);
s_availablePerToken[pledgeToken] -= netRefundAmount;
s_tokenToPaymentFee[tokenId] = 0;

// Burn the NFT (requires treasury approval from owner)
INFO.burn(tokenId);
Expand All @@ -1178,16 +1254,17 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
}

/**
* @dev Disburses all accumulated fees to the appropriate fee collector or treasury.
* @dev Disburses all accumulated fees.
* - Normal (not cancelled): protocol fees → protocol admin, platform fees → platform admin.
* - Cancelled: all fees (protocol + platform) → platform admin, so the protocol is not
* paid on contributions that are being reversed via payment gateways.
* Callable before or after cancellation so that accrued fees are never trapped.
*
* Requirements:
* - Only callable when fees are available.
*/
function disburseFees() public override whenCampaignNotPaused whenNotPaused {
address[] memory acceptedTokens = INFO.getAcceptedTokens();
address protocolAdmin = INFO.getProtocolAdminAddress();
address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH);
bool isCancelled = _getEffectiveCancellationTime() > 0;

for (uint256 i = 0; i < acceptedTokens.length; i++) {
address token = acceptedTokens[i];
Expand All @@ -1197,16 +1274,20 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
if (protocolShare > 0 || platformShare > 0) {
s_protocolFeePerToken[token] = 0;
s_platformFeePerToken[token] = 0;

if (protocolShare > 0) {
IERC20(token).safeTransfer(protocolAdmin, protocolShare);
s_lastFeeDisbursedPledgeTokenId[token] = s_lastMintedPledgeTokenId;

if (isCancelled) {
IERC20(token).safeTransfer(platformAdmin, protocolShare + platformShare);
emit FeesDisbursed(token, 0, protocolShare + platformShare);
} else {
if (protocolShare > 0) {
IERC20(token).safeTransfer(protocolAdmin, protocolShare);
}
if (platformShare > 0) {
IERC20(token).safeTransfer(platformAdmin, platformShare);
}
emit FeesDisbursed(token, protocolShare, platformShare);
}

if (platformShare > 0) {
IERC20(token).safeTransfer(platformAdmin, platformShare);
}

emit FeesDisbursed(token, protocolShare, platformShare);
}
}
}
Expand All @@ -1219,6 +1300,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 (_getEffectiveCancellationTime() == 0 && block.timestamp <= getDeadline()) {
revert KeepWhatsRaisedNotClaimableAdmin();
}
Expand All @@ -1237,6 +1322,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 @@ -1287,6 +1373,61 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
_cancel(message);
}

/**
* @notice Voids a pledge without burning the receipt NFT.
* @dev Current raised amount is reduced and any recoverable balance still held by the treasury
* is sent to the platform admin. Recovered amounts are clamped so late voids still work
* after withdrawals, fee disbursement, tip claims, or claimFund.
*/
function voidPledge(uint256 tokenId) external onlyPlatformAdmin(PLATFORM_HASH) {
address pledgeToken = s_tokenIdToPledgeToken[tokenId];
if (pledgeToken == address(0)) {
revert KeepWhatsRaisedVoidPledgeNotFound();
}

uint256 pledgeAmount = s_tokenToPledgedAmount[tokenId];
uint256 tipAmount = s_tokenToTippedAmount[tokenId];
uint256 totalFee = s_tokenToPaymentFee[tokenId];

_clearPledgeLedger(tokenId, pledgeToken, pledgeAmount);
s_tokenVoidedAmounts[pledgeToken] += pledgeAmount;

uint256 netAvailable = pledgeAmount - totalFee;
uint256 availableReversed = netAvailable <= s_availablePerToken[pledgeToken]
? netAvailable
: s_availablePerToken[pledgeToken];
s_availablePerToken[pledgeToken] -= availableReversed;
Comment thread
adnanhq marked this conversation as resolved.

uint256 actualProtocolFeeReversed;
uint256 actualPlatformFeeReversed;
if (tokenId > s_lastFeeDisbursedPledgeTokenId[pledgeToken]) {
uint256 protocolFee = (pledgeAmount * INFO.getProtocolFeePercent()) / PERCENT_DIVIDER;
uint256 platformFee = totalFee - protocolFee;
actualProtocolFeeReversed = protocolFee <= s_protocolFeePerToken[pledgeToken]
? protocolFee
: s_protocolFeePerToken[pledgeToken];
actualPlatformFeeReversed = platformFee <= s_platformFeePerToken[pledgeToken]
? platformFee
: s_platformFeePerToken[pledgeToken];
s_protocolFeePerToken[pledgeToken] -= actualProtocolFeeReversed;
s_platformFeePerToken[pledgeToken] -= actualPlatformFeeReversed;
}

uint256 totalRecovered = availableReversed + actualProtocolFeeReversed + actualPlatformFeeReversed;
if (!s_tipClaimed) {
uint256 tipRecoveredFromContract = tipAmount <= s_tipPerToken[pledgeToken]
? tipAmount
: s_tipPerToken[pledgeToken];
s_tipPerToken[pledgeToken] -= tipRecoveredFromContract;
totalRecovered += tipRecoveredFromContract;
}
if (totalRecovered > 0) {
IERC20(pledgeToken).safeTransfer(INFO.getPlatformAdminAddress(PLATFORM_HASH), totalRecovered);
}

emit PledgeVoided(tokenId, pledgeAmount);
}

/**
* @inheritdoc BaseTreasury
*/
Expand Down Expand Up @@ -1338,7 +1479,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 +1530,21 @@ 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_lastMintedPledgeTokenId = tokenId;

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 @@ -1445,6 +1602,17 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa
return pledgeAmount - totalFee;
}

/**
* @dev Clears per-pledge ledger entries and decrements the raised amount for a token.
* Called by both claimRefund and voidPledge to share the common cleanup path.
*/
function _clearPledgeLedger(uint256 tokenId, address pledgeToken, uint256 pledgeAmount) private {
delete s_tokenToPledgedAmount[tokenId];
delete s_tokenToPaymentFee[tokenId];
delete s_tokenIdToPledgeToken[tokenId];
s_tokenRaisedAmounts[pledgeToken] -= pledgeAmount;
}

/**
* @dev Returns the effective cancellation time by consulting both the treasury's own
* cancellation state and the campaign's cancellation state. If both are cancelled,
Expand Down
Loading
Loading