diff --git a/test/fork/HookCompositionFork.t.sol b/test/fork/HookCompositionFork.t.sol new file mode 100644 index 0000000..057f5b7 --- /dev/null +++ b/test/fork/HookCompositionFork.t.sol @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "./EcosystemFork.t.sol"; + +/// @notice Hook composition fork tests verifying fee correctness, weight scaling, +/// fallback paths, and full-cycle invariants across the Juicebox V6 hook stack. +/// +/// Run with: forge test --match-contract HookCompositionForkTest -vvv +contract HookCompositionForkTest is EcosystemForkTest { + /// @notice Pay revnet with 721 tier (30% split) + LP split (20% reserved). + /// Cash out and verify fee project balance increases. + function test_composition_payWithTierSplit_feeAccrues() public { + _deployFeeProject(5000); + + // Deploy revnet with 721 + LP split, 70% cashout tax, 20% reserved. + (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) = + _buildTwoStageConfigWithLPSplit(7000, 2000, 2000); + REVDeploy721TiersHookConfig memory hookConfig = _build721Config(); + + (uint256 revnetId,) = REV_DEPLOYER.deployFor({ + revnetId: 0, + configuration: cfg, + terminalConfigurations: tc, + suckerDeploymentConfiguration: sdc, + tiered721HookConfiguration: hookConfig, + allowedPosts: new REVCroptopAllowedPost[](0) + }); + + // Pay to build surplus. + _payRevnet(revnetId, BORROWER, 10 ether); + _payRevnet(revnetId, PAYER, 5 ether); + + // Record fee project balance before cashout. + uint256 feeBalanceBefore = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + + // Cash out half of PAYER's tokens. + uint256 payerTokens = jbTokens().totalBalanceOf(PAYER, revnetId); + uint256 cashOutCount = payerTokens / 2; + + vm.prank(PAYER); + jbMultiTerminal().cashOutTokensOf({ + holder: PAYER, + projectId: revnetId, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(PAYER), + metadata: "" + }); + + // Fee project balance should have increased (2.5% fee on cashout). + uint256 feeBalanceAfter = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + assertGt(feeBalanceAfter, feeBalanceBefore, "fee project balance should increase after cashout"); + + // Fee accrued should be positive. + uint256 feeAccrued = feeBalanceAfter - feeBalanceBefore; + assertGt(feeAccrued, 0, "fee accrued should be positive"); + + // Payer should have fewer tokens. + assertEq( + jbTokens().totalBalanceOf(PAYER, revnetId), payerTokens - cashOutCount, "payer tokens should decrease" + ); + } + + /// @notice Mock fee terminal to revert on external pay(). Cash out tokens. + /// REVDeployer's try-catch returns hook fee to project. Terminal-level fee (internal path) still works. + /// Key assertion: cashout succeeds (no tx revert) despite fee terminal failure. + function test_composition_cashOut_feeTerminalReverts_fallback() public { + _deployFeeProject(5000); + + // Deploy revnet with 70% cashout tax. + (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) = + _buildTwoStageConfigWithLPSplit(7000, 2000, 2000); + + (uint256 revnetId,) = REV_DEPLOYER.deployFor({ + revnetId: 0, + configuration: cfg, + terminalConfigurations: tc, + suckerDeploymentConfiguration: sdc + }); + + // Pay to build surplus. + _payRevnet(revnetId, BORROWER, 10 ether); + _payRevnet(revnetId, PAYER, 5 ether); + + // Do a NORMAL cashout first to measure baseline fee accrual. + uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId); + vm.prank(BORROWER); + jbMultiTerminal().cashOutTokensOf({ + holder: BORROWER, + projectId: revnetId, + cashOutCount: borrowerTokens / 4, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(BORROWER), + metadata: "" + }); + uint256 feeAfterNormalCashout = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + assertGt(feeAfterNormalCashout, 0, "normal cashout should accrue fees"); + + // Now mock fee terminal's external pay() to revert for the fee project. + // This blocks REVDeployer.afterCashOutRecordedWith -> feeTerminal.pay(). + // The terminal's internal _processFee path is unaffected by external mocks. + vm.mockCallRevert( + address(jbMultiTerminal()), + abi.encodeWithSignature( + "pay(uint256,address,uint256,address,uint256,string,bytes)", FEE_PROJECT_ID + ), + "fee terminal reverted" + ); + + // Cash out should succeed despite fee terminal reverting. + uint256 payerTokens = jbTokens().totalBalanceOf(PAYER, revnetId); + uint256 cashOutCount = payerTokens / 2; + uint256 payerEthBefore = PAYER.balance; + uint256 projectBalanceBefore = _terminalBalance(revnetId, JBConstants.NATIVE_TOKEN); + + vm.prank(PAYER); + jbMultiTerminal().cashOutTokensOf({ + holder: PAYER, + projectId: revnetId, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(PAYER), + metadata: "" + }); + + // Main assertion: cashout succeeded (try-catch worked). + assertGt(PAYER.balance, payerEthBefore, "payer should receive ETH despite fee terminal revert"); + + // Fee project still received SOME fees (terminal-level fee via internal path). + uint256 feeAfterMockedCashout = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + assertGt(feeAfterMockedCashout, feeAfterNormalCashout, "terminal-level fee still reaches fee project"); + + // The REVDeployer's hook fee was returned to the project via addToBalanceOf. + // Project balance decreased by less than it would have if both fees succeeded. + uint256 projectBalanceAfter = _terminalBalance(revnetId, JBConstants.NATIVE_TOKEN); + assertLt(projectBalanceAfter, projectBalanceBefore, "project balance decreased from cashout"); + assertGt(projectBalanceAfter, 0, "project retains balance after cashout"); + + vm.clearMockedCalls(); + } + + /// @notice Pay 1 ETH with 30% tier split. Verify weight is scaled by projectAmount/totalAmount. + /// Payer tokens = (0.7 ETH worth) * scaled_weight. No token credit for split portion. + function test_composition_721SplitPercent_weightScaling() public { + _deployFeeProject(5000); + + (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) = + _buildTwoStageConfigWithLPSplit(7000, 2000, 2000); + REVDeploy721TiersHookConfig memory hookConfig = _build721Config(); + + (uint256 revnetId, IJB721TiersHook hook) = REV_DEPLOYER.deployFor({ + revnetId: 0, + configuration: cfg, + terminalConfigurations: tc, + suckerDeploymentConfiguration: sdc, + tiered721HookConfiguration: hookConfig, + allowedPosts: new REVCroptopAllowedPost[](0) + }); + + // First, pay WITHOUT tier metadata - payer gets full weight. + uint256 tokensWithoutTier = _payRevnet(revnetId, BORROWER, 1 ether); + + // Now pay WITH tier metadata - 30% split reduces weight. + address metadataTarget = hook.METADATA_ID_TARGET(); + bytes memory metadata = _buildPayMetadataWithTier(metadataTarget); + + vm.prank(PAYER); + uint256 tokensWithTier = jbMultiTerminal().pay{value: 1 ether}({ + projectId: revnetId, + token: JBConstants.NATIVE_TOKEN, + amount: 1 ether, + beneficiary: PAYER, + minReturnedTokens: 0, + memo: "", + metadata: metadata + }); + + // With 30% tier split, only 70% enters the project. + // Weight scaled: weight * 0.7 = 700e18 (from 1000e18). + // With 20% reserved: payer gets 80%. + // Expected: tokensWithTier ~= 560e18, tokensWithoutTier = 800e18. + // Ratio should be ~70%. + assertLt(tokensWithTier, tokensWithoutTier, "tier split should reduce payer tokens"); + + uint256 expectedRatio = 70; // 70% + uint256 actualRatio = (tokensWithTier * 100) / tokensWithoutTier; + assertApproxEqAbs(actualRatio, expectedRatio, 1, "weight scaling should be ~70% with 30% tier split"); + + // Payer should also own the NFT. + assertEq(IERC721(address(hook)).balanceOf(PAYER), 1, "payer should own 1 NFT"); + } + + /// @notice Full lifecycle invariant checks: pay -> distribute reserved -> cash out -> verify. + /// Cashout is done pre-pool to avoid buyback hook TWAP slippage issues on forked state. + /// Post-pool pay is verified separately. + function test_composition_fullCycle_invariants() public { + _deployFeeProject(5000); + + (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) = + _buildTwoStageConfigWithLPSplit(7000, 2000, 2000); + + (uint256 revnetId,) = REV_DEPLOYER.deployFor({ + revnetId: 0, + configuration: cfg, + terminalConfigurations: tc, + suckerDeploymentConfiguration: sdc + }); + + uint256 feeBalancePrev = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + + // 1. Pre-AMM pay. + _payRevnet(revnetId, PAYER, 5 ether); + _payRevnet(revnetId, BORROWER, 5 ether); + assertGt(_terminalBalance(revnetId, JBConstants.NATIVE_TOKEN), 0, "inv: terminal balance > 0 after pay"); + + // 2. Distribute reserved tokens -> LP split hook accumulates. + uint256 pending = jbController().pendingReservedTokenBalanceOf(revnetId); + if (pending > 0) { + jbController().sendReservedTokensToSplitsOf(revnetId); + } + uint256 accumulated = LP_SPLIT_HOOK.accumulatedProjectTokens(revnetId); + assertGt(accumulated, 0, "inv: LP split hook accumulated tokens"); + + // 3. Cash out BEFORE pool setup (uses bonding curve, no buyback hook TWAP interference). + uint256 payerTokens = jbTokens().totalBalanceOf(PAYER, revnetId); + uint256 cashOutCount = payerTokens / 4; + uint256 payerEthBefore = PAYER.balance; + + vm.prank(PAYER); + jbMultiTerminal().cashOutTokensOf({ + holder: PAYER, + projectId: revnetId, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(PAYER), + metadata: "" + }); + + assertGt(PAYER.balance, payerEthBefore, "inv: payer received ETH from cashout"); + + // Invariant: terminal balance >= 0. + assertGe(_terminalBalance(revnetId, JBConstants.NATIVE_TOKEN), 0, "inv: terminal balance >= 0"); + + // Invariant: token supply > 0 (not all tokens cashed out). + uint256 totalSupply = jbTokens().totalSupplyOf(revnetId); + assertGt(totalSupply, 0, "inv: total supply > 0"); + + // Invariant: fee project balance monotonically increased. + uint256 feeBalanceNow = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + assertGe(feeBalanceNow, feeBalancePrev, "inv: fee project balance monotonically increases"); + feeBalancePrev = feeBalanceNow; + + // 4. Second cashout to verify fee monotonicity again. + uint256 borrowerTokens = jbTokens().totalBalanceOf(BORROWER, revnetId); + if (borrowerTokens > 0) { + uint256 borrowerEthBefore = BORROWER.balance; + vm.prank(BORROWER); + jbMultiTerminal().cashOutTokensOf({ + holder: BORROWER, + projectId: revnetId, + cashOutCount: borrowerTokens / 2, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(BORROWER), + metadata: "" + }); + assertGt(BORROWER.balance, borrowerEthBefore, "inv: borrower received ETH"); + + feeBalanceNow = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + assertGe(feeBalanceNow, feeBalancePrev, "inv: fee balance still monotonic after 2nd cashout"); + feeBalancePrev = feeBalanceNow; + } + + // 5. Set up buyback pool and verify post-AMM pay works. + _setupBuybackPool(revnetId, 10_000 ether); + + address payer2 = makeAddr("payer2"); + vm.deal(payer2, 10 ether); + uint256 tokens = _payRevnet(revnetId, payer2, 1 ether); + assertGt(tokens, 0, "inv: post-AMM pay should return tokens"); + + // Invariant: LP split hook position exists (accumulated tokens > 0 from step 2). + assertGt( + LP_SPLIT_HOOK.accumulatedProjectTokens(revnetId), 0, "inv: LP split hook has accumulated tokens" + ); + + // Final invariant: fee balance only grew. + feeBalanceNow = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + assertGe(feeBalanceNow, feeBalancePrev, "inv: fee balance monotonic at end"); + } + + /// @notice Cashout from project with 0% cashOutTaxRate. + /// REVDeployer proxies directly to buyback hook (line 275-278). No fee. Fee project unchanged. + function test_composition_zeroTaxRate_skipsFee() public { + _deployFeeProject(5000); + + // Deploy revnet with 0% cashout tax (both stages). + (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) = + _buildTwoStageConfigWithLPSplit(0, 0, 2000); + + (uint256 revnetId,) = REV_DEPLOYER.deployFor({ + revnetId: 0, + configuration: cfg, + terminalConfigurations: tc, + suckerDeploymentConfiguration: sdc + }); + + // Pay to build surplus. + _payRevnet(revnetId, PAYER, 5 ether); + _payRevnet(revnetId, BORROWER, 5 ether); + + // Record fee project balance before cashout. + uint256 feeBalanceBefore = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + + // Cash out with 0% tax. + uint256 payerTokens = jbTokens().totalBalanceOf(PAYER, revnetId); + uint256 cashOutCount = payerTokens / 2; + uint256 payerEthBefore = PAYER.balance; + + vm.prank(PAYER); + jbMultiTerminal().cashOutTokensOf({ + holder: PAYER, + projectId: revnetId, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(PAYER), + metadata: "" + }); + + // Payer should receive ETH (full pro-rata with 0% tax). + assertGt(PAYER.balance, payerEthBefore, "should receive ETH from 0% tax cashout"); + + // Fee project should NOT have received fees (0% tax -> proxy to buyback, no fee spec). + uint256 feeBalanceAfter = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + assertEq(feeBalanceAfter, feeBalanceBefore, "fee project should not change with 0% tax"); + } +} diff --git a/test/fork/PayoutReentrancyFork.t.sol b/test/fork/PayoutReentrancyFork.t.sol new file mode 100644 index 0000000..afe44a2 --- /dev/null +++ b/test/fork/PayoutReentrancyFork.t.sol @@ -0,0 +1,384 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {EcosystemForkTest} from "./EcosystemFork.t.sol"; +import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol"; +import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol"; +import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol"; +import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol"; +import {JBFundAccessLimitGroup} from "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol"; +import {JBCurrencyAmount} from "@bananapus/core-v6/src/structs/JBCurrencyAmount.sol"; +import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol"; +import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol"; +import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol"; +import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol"; +import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol"; +import {JBTerminalConfig} from "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol"; +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IJBMultiTerminal} from "@bananapus/core-v6/src/interfaces/IJBMultiTerminal.sol"; + +/// @notice Split hook that re-enters `sendPayoutsOf` to attempt a double-payout. +/// The payout limit should already be consumed by `recordPayoutFor`, so the re-entry +/// must revert with `JBTerminalStore_InadequateControllerPayoutLimit`. +/// Because `executePayout` wraps the hook call in try-catch, the revert is caught and +/// the split's funds are returned to the project balance. The hook records whether re-entry +/// was attempted and whether it succeeded. +contract MaliciousSplitHook is IJBSplitHook { + IJBMultiTerminal public terminal; + uint256 public targetProjectId; + address public token; + uint256 public amount; + uint256 public currency; + + bool public reentering; + bool public reentryCalled; + bool public reentrySucceeded; + + constructor(IJBMultiTerminal _terminal, uint256 _projectId, address _token, uint256 _amount, uint256 _currency) { + terminal = _terminal; + targetProjectId = _projectId; + token = _token; + amount = _amount; + currency = _currency; + } + + receive() external payable {} + + function processSplitWith(JBSplitHookContext calldata) external payable override { + if (!reentering) { + reentering = true; + reentryCalled = true; + // Attempt re-entry into sendPayoutsOf. This should fail because the payout limit + // was already consumed by recordPayoutFor before splits execute. + try terminal.sendPayoutsOf({ + projectId: targetProjectId, + token: token, + amount: amount, + currency: currency, + minTokensPaidOut: 0 + }) { + // If we get here, re-entry succeeded (should NOT happen). + reentrySucceeded = true; + } catch { + // Expected: re-entry reverts due to payout limit already consumed. + reentrySucceeded = false; + } + } + } + + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return interfaceId == type(IJBSplitHook).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} + +/// @notice Split hook that re-enters via `addToBalanceOf` during payout processing. +/// Unlike `sendPayoutsOf`, `addToBalanceOf` only increases the project's recorded balance +/// and should succeed without issues. This tests that benign re-entry paths remain functional. +contract AddToBalanceSplitHook is IJBSplitHook { + IJBMultiTerminal public terminal; + uint256 public targetProjectId; + address public token; + + bool public addToBalanceCalled; + bool public addToBalanceSucceeded; + + constructor(IJBMultiTerminal _terminal, uint256 _projectId, address _token) { + terminal = _terminal; + targetProjectId = _projectId; + token = _token; + } + + receive() external payable {} + + function processSplitWith(JBSplitHookContext calldata /* context */) external payable override { + if (!addToBalanceCalled && msg.value > 0) { + addToBalanceCalled = true; + // Re-enter via addToBalanceOf, forwarding all received ETH back to the project. + try terminal.addToBalanceOf{value: msg.value}({ + projectId: targetProjectId, + token: token, + amount: msg.value, + shouldReturnHeldFees: false, + memo: "re-entry via addToBalanceOf", + metadata: "" + }) { + addToBalanceSucceeded = true; + } catch { + addToBalanceSucceeded = false; + } + } + } + + function supportsInterface(bytes4 interfaceId) external pure override returns (bool) { + return interfaceId == type(IJBSplitHook).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} + +/// @notice Tests that payout split hooks cannot exploit re-entry to double-spend payouts. +/// +/// The `sendPayoutsOf()` flow is: +/// 1. `JBTerminalStore.recordPayoutFor()` — records payout limit usage and decreases balance BEFORE any external calls +/// 2. `JBPayoutSplitGroupLib.sendPayoutsToSplitGroupOf()` — iterates splits, calling `executePayout()` for each +/// 3. `executePayout()` — transfers funds to split hook and calls `processSplitWith()` +/// +/// Since step 1 consumes the payout limit before step 3 executes, a re-entrant call to `sendPayoutsOf()` +/// from inside a split hook should fail because `usedPayoutLimitOf` already equals the limit. +/// +/// Run with: forge test --match-contract PayoutReentrancyForkTest -vvv +contract PayoutReentrancyForkTest is EcosystemForkTest { + uint32 constant NATIVE_CURRENCY = uint32(uint160(JBConstants.NATIVE_TOKEN)); + uint112 constant WEIGHT = 1000e18; // 1000 tokens per ETH + uint224 constant PAYOUT_LIMIT = 1 ether; + + address PROJECT_OWNER = makeAddr("projectOwner"); + + // ═══════════════════════════════════════════════════════════════════ + // Helpers + // ═══════════════════════════════════════════════════════════════════ + + /// @notice Deploy a JB project with a single payout split pointing to `splitHook`. + /// The project has a payout limit of `PAYOUT_LIMIT` (1 ETH) in native token. + function _deployProjectWithSplitHook(IJBSplitHook splitHook) internal returns (uint256 projectId) { + // Build accounting context. + JBAccountingContext[] memory acc = new JBAccountingContext[](1); + acc[0] = JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: NATIVE_CURRENCY}); + + // Terminal config. + JBTerminalConfig[] memory tc = new JBTerminalConfig[](1); + tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc}); + + // Build split: 100% to the split hook. + JBSplit[] memory splits = new JBSplit[](1); + splits[0] = JBSplit({ + percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT), + projectId: 0, + beneficiary: payable(address(0)), + preferAddToBalance: false, + lockedUntil: 0, + hook: splitHook + }); + + // Split group: keyed by token address (native token) for payouts. + JBSplitGroup[] memory splitGroups = new JBSplitGroup[](1); + splitGroups[0] = JBSplitGroup({groupId: uint256(uint160(JBConstants.NATIVE_TOKEN)), splits: splits}); + + // Payout limit: 1 ETH in native currency. + JBCurrencyAmount[] memory payoutLimits = new JBCurrencyAmount[](1); + payoutLimits[0] = JBCurrencyAmount({amount: PAYOUT_LIMIT, currency: NATIVE_CURRENCY}); + + JBFundAccessLimitGroup[] memory fundAccessLimitGroups = new JBFundAccessLimitGroup[](1); + fundAccessLimitGroups[0] = JBFundAccessLimitGroup({ + terminal: address(jbMultiTerminal()), + token: JBConstants.NATIVE_TOKEN, + payoutLimits: payoutLimits, + surplusAllowances: new JBCurrencyAmount[](0) + }); + + // Ruleset config: no duration (permanent), standard weight, no approval hook. + JBRulesetConfig[] memory rulesetConfigs = new JBRulesetConfig[](1); + rulesetConfigs[0] = JBRulesetConfig({ + mustStartAtOrAfter: uint48(block.timestamp), + duration: 0, + weight: WEIGHT, + weightCutPercent: 0, + approvalHook: IJBRulesetApprovalHook(address(0)), + metadata: JBRulesetMetadata({ + reservedPercent: 0, + cashOutTaxRate: 0, + baseCurrency: NATIVE_CURRENCY, + pausePay: false, + pauseCreditTransfers: false, + allowOwnerMinting: false, + allowSetCustomToken: false, + allowTerminalMigration: false, + allowSetTerminals: false, + allowSetController: false, + allowAddAccountingContext: false, + allowAddPriceFeed: false, + ownerMustSendPayouts: false, + holdFees: false, + useTotalSurplusForCashOuts: false, + useDataHookForPay: false, + useDataHookForCashOut: false, + dataHook: address(0), + metadata: 0 + }), + splitGroups: splitGroups, + fundAccessLimitGroups: fundAccessLimitGroups + }); + + // Launch the project. + projectId = jbController().launchProjectFor({ + owner: PROJECT_OWNER, + projectUri: "", + rulesetConfigurations: rulesetConfigs, + terminalConfigurations: tc, + memo: "" + }); + } + + // ═══════════════════════════════════════════════════════════════════ + // Tests + // ═══════════════════════════════════════════════════════════════════ + + /// @notice A malicious split hook attempts to re-enter `sendPayoutsOf()` during payout processing. + /// The re-entry should fail because `recordPayoutFor()` already consumed the payout limit. + /// The try-catch in `executePayout` catches the failure and returns the split's funds to the project balance. + /// Only one payout's worth of funds should leave the terminal. + function test_payoutReentrancy_splitHookCannotDoubleSpend() public { + // We need a project first to know the ID, then deploy the hook with that ID. + // Use a two-step approach: predict the next project ID, deploy the hook, then deploy the project. + + // Step 1: Predict the next project ID. + uint256 nextProjectId = jbProjects().count() + 1; + + // Step 2: Deploy the malicious split hook targeting this project. + MaliciousSplitHook maliciousHook = new MaliciousSplitHook( + jbMultiTerminal(), + nextProjectId, + JBConstants.NATIVE_TOKEN, + PAYOUT_LIMIT, // Try to re-enter with the same payout amount. + NATIVE_CURRENCY + ); + + // Step 3: Deploy the project with the malicious hook as the split recipient. + uint256 projectId = _deployProjectWithSplitHook(IJBSplitHook(address(maliciousHook))); + assertEq(projectId, nextProjectId, "project ID should match prediction"); + + // Step 4: Fund the project with 5 ETH (well above the 1 ETH payout limit). + vm.prank(PAYER); + jbMultiTerminal().pay{value: 5 ether}({ + projectId: projectId, + token: JBConstants.NATIVE_TOKEN, + amount: 5 ether, + beneficiary: PAYER, + minReturnedTokens: 0, + memo: "", + metadata: "" + }); + + uint256 balanceBefore = _terminalBalance(projectId, JBConstants.NATIVE_TOKEN); + assertEq(balanceBefore, 5 ether, "terminal should hold 5 ETH"); + + // Step 5: Trigger payouts. This should: + // 1. recordPayoutFor consumes the 1 ETH payout limit + // 2. executePayout sends ETH to malicious hook and calls processSplitWith + // 3. Hook tries to re-enter sendPayoutsOf -> recordPayoutFor reverts (limit consumed) + // 4. try-catch in the hook catches the revert + // 5. The first payout still completes (hook received the funds via try-catch in executePayout) + jbMultiTerminal().sendPayoutsOf({ + projectId: projectId, + token: JBConstants.NATIVE_TOKEN, + amount: PAYOUT_LIMIT, + currency: NATIVE_CURRENCY, + minTokensPaidOut: 0 + }); + + // Verify the hook attempted re-entry. + assertTrue(maliciousHook.reentryCalled(), "hook should have attempted re-entry"); + + // Verify re-entry did NOT succeed. + assertFalse(maliciousHook.reentrySucceeded(), "re-entry into sendPayoutsOf should have failed"); + + // Verify only one payout's worth of funds left the terminal. + uint256 balanceAfter = _terminalBalance(projectId, JBConstants.NATIVE_TOKEN); + + // The hook received PAYOUT_LIMIT minus the 2.5% fee = 0.975 ETH. + // The fee (0.025 ETH) was attempted to be processed but the fee project (ID 1) has no + // terminal set up in this test, so the fee processing reverts. The terminal catches this + // and returns the fee amount back to the project balance via recordAddedBalanceFor. + // Net balance decrease = PAYOUT_LIMIT - fee = 0.975 ETH. + uint256 feeAmount = (PAYOUT_LIMIT * 25) / 1000; // 2.5% fee + uint256 expectedDecrease = PAYOUT_LIMIT - feeAmount; + assertEq( + balanceBefore - balanceAfter, + expectedDecrease, + "terminal balance should decrease by payout minus returned fee" + ); + + // A second sendPayoutsOf should also fail since payout limit is consumed for this cycle. + // Since duration=0, same ruleset stays active, so payout limit persists. + vm.expectRevert(); + jbMultiTerminal().sendPayoutsOf({ + projectId: projectId, + token: JBConstants.NATIVE_TOKEN, + amount: PAYOUT_LIMIT, + currency: NATIVE_CURRENCY, + minTokensPaidOut: 0 + }); + } + + /// @notice A split hook re-enters via `addToBalanceOf()` during payout processing. + /// Unlike `sendPayoutsOf()`, `addToBalanceOf()` simply records additional balance for the project. + /// This should succeed and the terminal balance should reflect both the payout and the re-added funds. + function test_payoutReentrancy_addToBalance_succeeds() public { + // Step 1: Predict the next project ID. + uint256 nextProjectId = jbProjects().count() + 1; + + // Step 2: Deploy the addToBalance split hook. + AddToBalanceSplitHook addHook = + new AddToBalanceSplitHook(jbMultiTerminal(), nextProjectId, JBConstants.NATIVE_TOKEN); + + // Step 3: Deploy the project. + uint256 projectId = _deployProjectWithSplitHook(IJBSplitHook(address(addHook))); + assertEq(projectId, nextProjectId, "project ID should match prediction"); + + // Step 4: Fund the project. + vm.prank(PAYER); + jbMultiTerminal().pay{value: 5 ether}({ + projectId: projectId, + token: JBConstants.NATIVE_TOKEN, + amount: 5 ether, + beneficiary: PAYER, + minReturnedTokens: 0, + memo: "", + metadata: "" + }); + + uint256 balanceBefore = _terminalBalance(projectId, JBConstants.NATIVE_TOKEN); + assertEq(balanceBefore, 5 ether, "terminal should hold 5 ETH"); + + // Step 5: Trigger payouts. + // The hook will receive its split amount (after fee), then re-enter via addToBalanceOf + // to send the ETH back to the project. + jbMultiTerminal().sendPayoutsOf({ + projectId: projectId, + token: JBConstants.NATIVE_TOKEN, + amount: PAYOUT_LIMIT, + currency: NATIVE_CURRENCY, + minTokensPaidOut: 0 + }); + + // Verify the hook called addToBalanceOf. + assertTrue(addHook.addToBalanceCalled(), "hook should have called addToBalanceOf"); + assertTrue(addHook.addToBalanceSucceeded(), "addToBalanceOf re-entry should have succeeded"); + + // Verify balance consistency. + // The payout flow: + // 1. recordPayoutFor deducts PAYOUT_LIMIT from terminal balance -> 4 ETH + // 2. The split hook receives (PAYOUT_LIMIT - fee). Fee = 2.5% of 1 ETH = 0.025 ETH. Net = 0.975 ETH. + // 3. The hook sends that 0.975 ETH back via addToBalanceOf, increasing balance. + // 4. The fee (0.025 ETH) is paid to the fee project. + // Final balance: 4 ETH + 0.975 ETH = 4.975 ETH + // But there's also the leftover (0% goes to owner since 100% went to hook) and fee accounting. + uint256 balanceAfter = _terminalBalance(projectId, JBConstants.NATIVE_TOKEN); + + // The key invariant: balance should be greater than (balanceBefore - PAYOUT_LIMIT), + // because the hook returned the funds via addToBalanceOf. + assertGt( + balanceAfter, + balanceBefore - PAYOUT_LIMIT, + "balance should be higher than simple payout since hook returned funds" + ); + + // No double-payout occurred: we can verify the payout limit is consumed by trying again. + vm.expectRevert(); + jbMultiTerminal().sendPayoutsOf({ + projectId: projectId, + token: JBConstants.NATIVE_TOKEN, + amount: PAYOUT_LIMIT, + currency: NATIVE_CURRENCY, + minTokensPaidOut: 0 + }); + } +} diff --git a/test/fork/ReservedInflationFork.t.sol b/test/fork/ReservedInflationFork.t.sol new file mode 100644 index 0000000..eef5687 --- /dev/null +++ b/test/fork/ReservedInflationFork.t.sol @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {EcosystemForkTest} from "./EcosystemFork.t.sol"; +import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol"; +import {JBCashOuts} from "@bananapus/core-v6/src/libraries/JBCashOuts.sol"; +import {JBFees} from "@bananapus/core-v6/src/libraries/JBFees.sol"; +import {REVConfig} from "@rev-net/core-v6/src/structs/REVConfig.sol"; +import {REVDeploy721TiersHookConfig} from "@rev-net/core-v6/src/structs/REVDeploy721TiersHookConfig.sol"; +import {REVSuckerDeploymentConfig} from "@rev-net/core-v6/src/structs/REVSuckerDeploymentConfig.sol"; +import {REVCroptopAllowedPost} from "@rev-net/core-v6/src/structs/REVCroptopAllowedPost.sol"; +import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol"; +import {JBTerminalConfig} from "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol"; + +/// @notice Tests for H-4 CONFIRMED: Pending reserved tokens inflate `totalSupply`, reducing cashout value. +/// Verifies behavior in the context of REVDeployer's data hook composition chain (REVDeployer -> BuybackHook). +/// +/// Run with: forge test --match-contract ReservedInflationForkTest -vvv +contract ReservedInflationForkTest is EcosystemForkTest { + /// @notice Cash out with undistributed reserved tokens. + /// Demonstrates that pending reserved tokens inflate totalSupply in the bonding curve, + /// giving the payer less ETH than they would receive with 0% reserved. + function test_reservedInflation_cashOutWithUndistributed() public { + _deployFeeProject(5000); + + // Deploy revnet with 80% reserved (splitPercent = 8000). + (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) = + _buildTwoStageConfigWithLPSplit(7000, 2000, 8000); + + (uint256 revnetId,) = REV_DEPLOYER.deployFor({ + revnetId: 0, configuration: cfg, terminalConfigurations: tc, suckerDeploymentConfiguration: sdc + }); + + // Pay 100 ETH. Do NOT distribute reserved tokens. + uint256 payerTokens = _payRevnet(revnetId, PAYER, 100 ether); + + // Verify payer received 20% of issuance (80% reserved). + // 1000 tokens/ETH * 100 ETH = 100,000 tokens total. Payer gets 20% = 20,000 tokens. + assertEq(payerTokens, 20_000e18, "payer should receive 20% of issuance (80% reserved)"); + + // Check pending reserved tokens exist. + uint256 pending = jbController().pendingReservedTokenBalanceOf(revnetId); + assertGt(pending, 0, "should have pending reserved tokens"); + + // Verify totalSupply includes pending reserved tokens. + uint256 tokenSupply = jbTokens().totalSupplyOf(revnetId); + uint256 totalSupplyWithReserved = jbController().totalTokenSupplyWithReservedTokensOf(revnetId); + assertEq(totalSupplyWithReserved, tokenSupply + pending, "totalSupplyWithReserved = minted + pending"); + assertGt(totalSupplyWithReserved, tokenSupply, "totalSupplyWithReserved > minted supply"); + + // Cash out half the payer's tokens. + uint256 cashOutCount = payerTokens / 2; + uint256 payerEthBefore = PAYER.balance; + + vm.prank(PAYER); + uint256 reclaimAmount = jbMultiTerminal().cashOutTokensOf({ + holder: PAYER, + projectId: revnetId, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(PAYER), + metadata: "" + }); + + uint256 ethReceived = PAYER.balance - payerEthBefore; + assertGt(ethReceived, 0, "should receive ETH from cashout"); + + // Calculate what payer WOULD get if reserved were 0% (totalSupply = payerTokens only). + // With 0% reserved: totalSupply = 100,000 tokens, payer has 100,000 tokens, cashOutCount = 50,000. + // But since we have 80% reserved, totalSupply is inflated by pending reserved tokens. + // The bonding curve: base = surplus * cashOutCount / totalSupply + // With pending reserved tokens inflating totalSupply, base is smaller. + uint256 surplus = _terminalBalance(revnetId, JBConstants.NATIVE_TOKEN) + reclaimAmount; // pre-cashout surplus + + // Hypothetical reclaim with 0% reserved (totalSupply = payerTokens). + uint256 hypotheticalReclaimNoReserved = JBCashOuts.cashOutFrom({ + surplus: surplus, + cashOutCount: cashOutCount, + totalSupply: payerTokens, // no reserved inflation + cashOutTaxRate: 7000 // 70% tax + }); + + // Actual reclaim uses inflated totalSupply (includes pending reserved). + // The payer gets LESS because their share of totalSupply is diluted by pending reserved tokens. + assertLt( + reclaimAmount, + hypotheticalReclaimNoReserved, + "H-4: payer gets LESS due to pending reserved token inflation in totalSupply" + ); + + // Document the magnitude of the reduction. + uint256 reductionBps = ((hypotheticalReclaimNoReserved - reclaimAmount) * 10_000) / hypotheticalReclaimNoReserved; + assertGt(reductionBps, 0, "reclaim reduction should be measurable"); + + // With 80% reserved and 70% cashOutTaxRate, the inflation effect should be very significant. + // The payer holds only 20% of totalSupplyWithReserved, so dilution is severe. + emit log_named_uint("Reclaim with reserved inflation (wei)", reclaimAmount); + emit log_named_uint("Hypothetical reclaim without reserved (wei)", hypotheticalReclaimNoReserved); + emit log_named_uint("Reduction (basis points)", reductionBps); + } + + /// @notice Distribute reserved tokens first, then cash out. Compare with undistributed case. + /// The cashout value should be THE SAME whether or not reserved tokens are distributed first, + /// because `totalTokenSupplyWithReservedTokensOf` includes pending reserved tokens either way. + function test_reservedInflation_distributeFirst_comparesCashOut() public { + _deployFeeProject(5000); + + // Deploy two identical revnets to compare behavior. + // Revnet A: cash out WITHOUT distributing reserved tokens first. + (REVConfig memory cfgA, JBTerminalConfig[] memory tcA, REVSuckerDeploymentConfig memory sdcA) = + _buildTwoStageConfigWithLPSplit(7000, 2000, 8000); + + (uint256 revnetA,) = REV_DEPLOYER.deployFor({ + revnetId: 0, configuration: cfgA, terminalConfigurations: tcA, suckerDeploymentConfiguration: sdcA + }); + + // Revnet B: cash out AFTER distributing reserved tokens. + (REVConfig memory cfgB, JBTerminalConfig[] memory tcB, REVSuckerDeploymentConfig memory sdcB) = + _buildTwoStageConfigWithLPSplit(7000, 2000, 8000); + // Use a different salt for revnet B to avoid collision. + cfgB.description.salt = "ECO_SALT_B"; + + (uint256 revnetB,) = REV_DEPLOYER.deployFor({ + revnetId: 0, configuration: cfgB, terminalConfigurations: tcB, suckerDeploymentConfiguration: sdcB + }); + + // Pay 100 ETH to both revnets. + address payerA = makeAddr("payerA"); + address payerB = makeAddr("payerB"); + vm.deal(payerA, 200 ether); + vm.deal(payerB, 200 ether); + + uint256 tokensA = _payRevnet(revnetA, payerA, 100 ether); + uint256 tokensB = _payRevnet(revnetB, payerB, 100 ether); + + // Tokens received should be equal for identical configs. + assertEq(tokensA, tokensB, "tokens received should match for identical configs"); + + // Revnet B: distribute reserved tokens first. + uint256 pendingB = jbController().pendingReservedTokenBalanceOf(revnetB); + assertGt(pendingB, 0, "revnet B should have pending reserved"); + jbController().sendReservedTokensToSplitsOf(revnetB); + assertEq(jbController().pendingReservedTokenBalanceOf(revnetB), 0, "pending should be zero after distribution"); + + // Revnet A: do NOT distribute. + uint256 pendingA = jbController().pendingReservedTokenBalanceOf(revnetA); + assertGt(pendingA, 0, "revnet A should still have pending reserved"); + + // Verify totalSupplyWithReserved is the same for both revnets. + uint256 totalWithReservedA = jbController().totalTokenSupplyWithReservedTokensOf(revnetA); + uint256 totalWithReservedB = jbController().totalTokenSupplyWithReservedTokensOf(revnetB); + assertEq( + totalWithReservedA, + totalWithReservedB, + "totalSupplyWithReserved should be equal regardless of distribution" + ); + + // Cash out half tokens from both revnets. + uint256 cashOutCount = tokensA / 2; + + // Preview cashout for revnet A (undistributed). + (, uint256 reclaimA,,) = jbMultiTerminal().previewCashOutFrom({ + holder: payerA, + projectId: revnetA, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + beneficiary: payable(payerA), + metadata: "" + }); + + // Preview cashout for revnet B (distributed). + (, uint256 reclaimB,,) = jbMultiTerminal().previewCashOutFrom({ + holder: payerB, + projectId: revnetB, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + beneficiary: payable(payerB), + metadata: "" + }); + + // The reclaim amounts should be equal: distributing reserved tokens does not change the totalSupply + // used in the bonding curve because `totalTokenSupplyWithReservedTokensOf` includes pending regardless. + assertEq( + reclaimA, + reclaimB, + "reclaim should be equal whether or not reserved tokens are distributed first" + ); + + // Document the values. + emit log_named_uint("Reclaim A (undistributed reserved)", reclaimA); + emit log_named_uint("Reclaim B (distributed reserved)", reclaimB); + emit log_named_uint("Pending reserved A", pendingA); + emit log_named_uint("Total supply with reserved A", totalWithReservedA); + emit log_named_uint("Total supply with reserved B", totalWithReservedB); + } + + /// @notice Verify totalSupply consistency through the REVDeployer -> BuybackHook data hook chain. + /// When no buyback pool is set, the buyback hook passes through context.totalSupply unchanged. + /// Verify that this totalSupply matches jbTokens().totalSupplyOf() + pending reserved. + function test_reservedInflation_hookComposition_totalSupplyConsistency() public { + _deployFeeProject(5000); + + // Deploy revnet with 721 + buyback (no pool) + 50% reserved. + (REVConfig memory cfg, JBTerminalConfig[] memory tc, REVSuckerDeploymentConfig memory sdc) = + _buildTwoStageConfigWithLPSplit(7000, 2000, 5000); + REVDeploy721TiersHookConfig memory hookConfig = _build721Config(); + + (uint256 revnetId,) = REV_DEPLOYER.deployFor({ + revnetId: 0, + configuration: cfg, + terminalConfigurations: tc, + suckerDeploymentConfiguration: sdc, + tiered721HookConfiguration: hookConfig, + allowedPosts: new REVCroptopAllowedPost[](0) + }); + + // Pay a large amount to create significant pending reserved balance. + uint256 payerTokens = _payRevnet(revnetId, PAYER, 100 ether); + + // Verify 50% reserved: payer gets 50% of issuance. + assertEq(payerTokens, 50_000e18, "payer should receive 50% of issuance (50% reserved)"); + + // Check pending reserved tokens. + uint256 pending = jbController().pendingReservedTokenBalanceOf(revnetId); + assertGt(pending, 0, "should have pending reserved tokens"); + + // The totalSupply used in bonding curve = jbTokens().totalSupplyOf() + pending. + uint256 mintedSupply = jbTokens().totalSupplyOf(revnetId); + uint256 totalSupplyWithReserved = jbController().totalTokenSupplyWithReservedTokensOf(revnetId); + assertEq(totalSupplyWithReserved, mintedSupply + pending, "totalSupplyWithReserved = minted + pending"); + + // Preview cash out to capture the totalSupply used in the bonding curve calculation. + uint256 cashOutCount = payerTokens / 2; + + (, uint256 reclaimAmount,,) = jbMultiTerminal().previewCashOutFrom({ + holder: PAYER, + projectId: revnetId, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + beneficiary: payable(PAYER), + metadata: "" + }); + + // Manually compute what the bonding curve should return using totalSupplyWithReserved. + uint256 surplus = _terminalBalance(revnetId, JBConstants.NATIVE_TOKEN); + + // The REVDeployer splits the cashOutCount into fee and non-fee portions. + // feeCashOutCount = cashOutCount * FEE / MAX_FEE = cashOutCount * 25 / 1000 + uint256 feeCashOutCount = (cashOutCount * 25) / 1000; + uint256 nonFeeCashOutCount = cashOutCount - feeCashOutCount; + + // The REVDeployer computes postFeeReclaimedAmount using the bonding curve on the non-fee portion. + uint256 expectedPostFeeReclaim = JBCashOuts.cashOutFrom({ + surplus: surplus, + cashOutCount: nonFeeCashOutCount, + totalSupply: totalSupplyWithReserved, + cashOutTaxRate: 7000 + }); + + // Then the fee portion is computed from the remaining surplus. + uint256 expectedFeeAmount = JBCashOuts.cashOutFrom({ + surplus: surplus - expectedPostFeeReclaim, + cashOutCount: feeCashOutCount, + totalSupply: totalSupplyWithReserved - nonFeeCashOutCount, + cashOutTaxRate: 7000 + }); + + // The reclaimAmount returned by previewCashOutFrom is the bonding curve output + // computed by JBTerminalStore after the data hook chain returns. + // The data hook chain (REVDeployer -> BuybackHook) sets the values that the terminal store + // then uses for the final bonding curve computation. + // When no pool is set, the buyback hook returns context values unchanged. + // REVDeployer overrides cashOutCount to nonFeeCashOutCount and sets totalSupply. + // The terminal store then computes: reclaimAmount = cashOutFrom(surplus, nonFeeCashOutCount, totalSupply, taxRate) + assertEq( + reclaimAmount, + expectedPostFeeReclaim, + "reclaimAmount should match bonding curve using totalSupplyWithReserved" + ); + + // Now actually cash out and verify the ETH received accounts for the terminal's 2.5% fee. + // cashOutTokensOf returns reclaimAmount AFTER the terminal's built-in fee deduction. + // previewCashOutFrom returns the bonding curve output BEFORE the terminal fee. + // So: actualReclaim = previewReclaim - feeAmountFrom(previewReclaim, FEE) + uint256 payerEthBefore = PAYER.balance; + vm.prank(PAYER); + uint256 actualReclaim = jbMultiTerminal().cashOutTokensOf({ + holder: PAYER, + projectId: revnetId, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(PAYER), + metadata: "" + }); + + // The terminal applies a 2.5% fee on the reclaimAmount when cashOutTaxRate != 0. + uint256 terminalFee = JBFees.feeAmountFrom({amountBeforeFee: reclaimAmount, feePercent: 25}); + uint256 expectedActualReclaim = reclaimAmount - terminalFee; + assertEq(actualReclaim, expectedActualReclaim, "actual cashout = preview minus terminal 2.5% fee"); + + uint256 ethReceived = PAYER.balance - payerEthBefore; + assertGt(ethReceived, 0, "payer should receive ETH"); + + // Verify the totalSupply used was consistent: if it used only mintedSupply (without pending), + // the reclaim would be higher. Compute and assert. + uint256 hypotheticalReclaimNoPending = JBCashOuts.cashOutFrom({ + surplus: surplus, + cashOutCount: nonFeeCashOutCount, + totalSupply: mintedSupply, // without pending reserved tokens + cashOutTaxRate: 7000 + }); + + assertLt( + reclaimAmount, + hypotheticalReclaimNoPending, + "reclaim with pending reserved should be less than without (inflation reduces cashout value)" + ); + + // Document the consistency check. + emit log_named_uint("Minted supply (no pending)", mintedSupply); + emit log_named_uint("Pending reserved tokens", pending); + emit log_named_uint("Total supply with reserved", totalSupplyWithReserved); + emit log_named_uint("Reclaim amount (with reserved inflation)", reclaimAmount); + emit log_named_uint("Hypothetical reclaim (no inflation)", hypotheticalReclaimNoPending); + emit log_named_uint("Fee amount", expectedFeeAmount); + } +} diff --git a/test/fork/SuckerBuybackFork.t.sol b/test/fork/SuckerBuybackFork.t.sol new file mode 100644 index 0000000..bbd56a5 --- /dev/null +++ b/test/fork/SuckerBuybackFork.t.sol @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "./EcosystemFork.t.sol"; + +/// @notice Tests the sucker exemption path in REVDeployer.beforeCashOutRecordedWith when a buyback hook is active. +/// +/// Suckers get special treatment: zero tax, full pro-rata reclaim, no fees, no hook delegation. +/// These tests verify that the sucker path bypasses all of that — even when a buyback hook is deployed. +/// +/// Run with: forge test --match-contract SuckerBuybackForkTest -vvv +contract SuckerBuybackForkTest is EcosystemForkTest { + address MOCK_SUCKER = makeAddr("mockSucker"); + address NON_SUCKER = makeAddr("nonSucker"); + + /// @notice Deploy a single-stage revnet with buyback hook active and a meaningful cashOutTaxRate. + /// No pool setup — the buyback hook is registered but pre-AMM (no liquidity). + function _deployRevnetForSuckerTest(uint16 cashOutTaxRate) + internal + returns (uint256 revnetId) + { + JBAccountingContext[] memory acc = new JBAccountingContext[](1); + acc[0] = JBAccountingContext({ + token: JBConstants.NATIVE_TOKEN, + decimals: 18, + currency: uint32(uint160(JBConstants.NATIVE_TOKEN)) + }); + JBTerminalConfig[] memory tc = new JBTerminalConfig[](1); + tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: acc}); + + JBSplit[] memory splits = new JBSplit[](1); + splits[0] = JBSplit({ + preferAddToBalance: false, + percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT), + projectId: 0, + beneficiary: payable(multisig()), + lockedUntil: 0, + hook: IJBSplitHook(address(0)) + }); + + REVStageConfig[] memory stages = new REVStageConfig[](1); + stages[0] = REVStageConfig({ + startsAtOrAfter: uint40(block.timestamp), + autoIssuances: new REVAutoIssuance[](0), + splitPercent: 0, // No reserved tokens — simplifies token accounting. + splits: splits, + initialIssuance: INITIAL_ISSUANCE, + issuanceCutFrequency: 0, + issuanceCutPercent: 0, + cashOutTaxRate: cashOutTaxRate, + extraMetadata: 0 + }); + + REVConfig memory cfg = REVConfig({ + description: REVDescription("SuckerTest", "SKRT", "ipfs://sucker", "SUCKER_SALT"), + baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)), + splitOperator: multisig(), + stageConfigurations: stages + }); + + REVSuckerDeploymentConfig memory sdc = REVSuckerDeploymentConfig({ + deployerConfigurations: new JBSuckerDeployerConfig[](0), + salt: keccak256(abi.encodePacked("SUCKER_TEST")) + }); + + (revnetId,) = REV_DEPLOYER.deployFor({ + revnetId: 0, + configuration: cfg, + terminalConfigurations: tc, + suckerDeploymentConfiguration: sdc + }); + } + + /// @notice Mock the sucker registry so that `mockSucker` is recognized as a sucker for the given project. + function _registerMockSucker(uint256 revnetId) internal { + vm.mockCall( + address(SUCKER_REGISTRY), + abi.encodeWithSignature("isSuckerOf(uint256,address)", revnetId, MOCK_SUCKER), + abi.encode(true) + ); + } + + // ═══════════════════════════════════════════════════════════════════ + // Test 1: Sucker exemption — zero tax with buyback active + // ═══════════════════════════════════════════════════════════════════ + + /// @notice When a sucker cashes out, REVDeployer returns (0, cashOutCount, totalSupply, []). + /// The buyback hook is NOT consulted. No fees are charged. Full pro-rata ETH reclaim. + function test_suckerExemption_zeroTaxWithBuybackActive() public { + // Deploy fee project so the fee terminal exists. + _deployFeeProject(5000); + + // Deploy revnet with 70% cashOutTaxRate. + uint256 revnetId = _deployRevnetForSuckerTest(7000); + + // Register the mock sucker. + _registerMockSucker(revnetId); + + // Fund actors. + vm.deal(MOCK_SUCKER, 100 ether); + vm.deal(NON_SUCKER, 100 ether); + + // Pay into the revnet to create surplus and give the sucker tokens. + // First, have someone else pay to create surplus (so sucker is not the only holder). + _payRevnet(revnetId, NON_SUCKER, 10 ether); + + // Sucker pays to get tokens. + uint256 suckerTokens = _payRevnet(revnetId, MOCK_SUCKER, 10 ether); + assertGt(suckerTokens, 0, "sucker should receive tokens from payment"); + + // Record fee project balance BEFORE sucker cashout. + uint256 feeBalanceBefore = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + + // Record sucker ETH balance before. + uint256 suckerEthBefore = MOCK_SUCKER.balance; + + // Sucker cashes out all tokens. + vm.prank(MOCK_SUCKER); + uint256 reclaimAmount = jbMultiTerminal().cashOutTokensOf({ + holder: MOCK_SUCKER, + projectId: revnetId, + cashOutCount: suckerTokens, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(MOCK_SUCKER), + metadata: "" + }); + + // Sucker should receive ETH. + assertGt(reclaimAmount, 0, "sucker should reclaim ETH"); + assertGt(MOCK_SUCKER.balance, suckerEthBefore, "sucker ETH balance should increase"); + + // With 0% tax (sucker exemption) and sucker holding 50% of supply cashing out 50%: + // Pro-rata reclaim from the 20 ETH surplus = 10 ETH (minus any rounding). + // The bonding curve with 0% tax gives: surplus * cashOutCount / totalSupply = 20 * 50% = 10 ETH. + assertEq(reclaimAmount, 10 ether, "sucker should reclaim full pro-rata share (0% tax)"); + + // Fee project balance should NOT increase — sucker cashouts are fee-exempt. + uint256 feeBalanceAfter = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + assertEq(feeBalanceAfter, feeBalanceBefore, "fee project should NOT receive fees from sucker cashout"); + } + + // ═══════════════════════════════════════════════════════════════════ + // Test 2: Sucker vs non-sucker reclaim difference + // ═══════════════════════════════════════════════════════════════════ + + /// @notice A sucker reclaims more than a non-sucker cashing out the same token count. + /// The fee project balance increases ONLY on the non-sucker cashout. + function test_suckerVsNonSucker_reclaimDifference() public { + // Deploy fee project so the fee terminal exists. + _deployFeeProject(5000); + + // Deploy revnet with 70% cashOutTaxRate (high tax to make the difference obvious). + uint256 revnetId = _deployRevnetForSuckerTest(7000); + + // Register the mock sucker. + _registerMockSucker(revnetId); + + // Fund actors. + vm.deal(MOCK_SUCKER, 100 ether); + vm.deal(NON_SUCKER, 100 ether); + + // Both pay the same amount to get the same number of tokens. + uint256 suckerTokens = _payRevnet(revnetId, MOCK_SUCKER, 10 ether); + uint256 nonSuckerTokens = _payRevnet(revnetId, NON_SUCKER, 10 ether); + + // Both should have the same token count (same payment, same issuance rate). + assertEq(suckerTokens, nonSuckerTokens, "both should receive same token count"); + + // Cash out the same amount from each. + uint256 cashOutCount = suckerTokens / 2; // Cash out half their tokens. + + // Record fee project balance before any cashouts. + uint256 feeBalanceBefore = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + + // --- Non-sucker cashes out first --- + vm.prank(NON_SUCKER); + uint256 nonSuckerReclaim = jbMultiTerminal().cashOutTokensOf({ + holder: NON_SUCKER, + projectId: revnetId, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(NON_SUCKER), + metadata: "" + }); + + assertGt(nonSuckerReclaim, 0, "non-sucker should reclaim some ETH"); + + // Fee project balance should increase from non-sucker cashout. + uint256 feeBalanceAfterNonSucker = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + assertGt( + feeBalanceAfterNonSucker, + feeBalanceBefore, + "fee project balance should increase from non-sucker cashout" + ); + + // --- Sucker cashes out second --- + vm.prank(MOCK_SUCKER); + uint256 suckerReclaim = jbMultiTerminal().cashOutTokensOf({ + holder: MOCK_SUCKER, + projectId: revnetId, + cashOutCount: cashOutCount, + tokenToReclaim: JBConstants.NATIVE_TOKEN, + minTokensReclaimed: 0, + beneficiary: payable(MOCK_SUCKER), + metadata: "" + }); + + assertGt(suckerReclaim, 0, "sucker should reclaim ETH"); + + // Sucker should reclaim MORE than non-sucker (zero tax vs 70% tax + fees). + assertGt(suckerReclaim, nonSuckerReclaim, "sucker reclaim should exceed non-sucker reclaim"); + + // Fee project balance should NOT increase from sucker cashout. + uint256 feeBalanceAfterSucker = _terminalBalance(FEE_PROJECT_ID, JBConstants.NATIVE_TOKEN); + assertEq( + feeBalanceAfterSucker, + feeBalanceAfterNonSucker, + "fee project balance should NOT increase from sucker cashout" + ); + } +}