Skip to content

Commit aae9e5e

Browse files
yieldhuntingclaude
andcommitted
test: cover performance fee accrual-cadence dependence
Adds regression coverage for the interaction between the negative-basis clamp in _pendingFees() and the unconditional snapshot rewrite in _accrueFees(). Because a shortfall is discarded rather than carried forward, fees charged over a price path depend on how often accrual runs. Three cases: - ratchetsOnRoundTripPrice: four +2% round trips, collateral mark ends exactly where it started, yet 38.647e18 fee shares are minted against 5000e18 minter shares (0.767% of the vault) for zero realised return. - zeroOnSameRoundTripWhenAccruedOnce: identical path accrued once at the end charges exactly zero, isolating cadence as the only difference. - cadenceEffectIsSecondOrderWhenPriceOnlyRises: on a monotonic path, where nothing is clamped, the cadence effect collapses to ~1.3%, confirming the clamp rather than rounding or compounding is the cause. These document current behaviour and pass against it. Refs #223. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 96a18e3 commit aae9e5e

1 file changed

Lines changed: 103 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity ^0.8.20;
3+
4+
import {PositionManagerBaseTest} from "./PositionManagerBase.t.sol";
5+
6+
/// @title PositionManagerFeeRatchetTest
7+
/// @notice Demonstrates that the performance fee has no high-water mark, so the total fee
8+
/// charged over a price path depends on how often `_accrueFees()` happens to run
9+
/// rather than on realised performance.
10+
///
11+
/// The basis is clamped at zero on down-legs (`scaledLastDebt > currentDebt`) but the
12+
/// snapshot is still rewritten, so a loss is never carried forward. Each up-leg is
13+
/// therefore charged in full against a snapshot that was reset at the previous trough.
14+
contract PositionManagerFeeRatchetTest is PositionManagerBaseTest {
15+
uint24 constant PERF_FEE = 1000; // 10% — matches the deployed wJAAA / wUSCC / wFalconX tiers
16+
17+
/// @dev Dust deposit purely to reach `_accrueFees()`; deposit/withdraw/burn are the only
18+
/// entry points that accrue, and all are MINTER_ROLE gated.
19+
function _touch() internal {
20+
_mintCollateral(minter, 1);
21+
vm.prank(minter);
22+
positionManager.deposit(1, 0);
23+
}
24+
25+
function _openLeveredPosition() internal returns (uint256 startPrice) {
26+
vm.prank(owner);
27+
positionManager.setFeeData(feeRecipient, 0, PERF_FEE); // no management fee, isolate perf
28+
29+
_mintCollateral(minter, COLLATERAL_AMOUNT);
30+
vm.prank(minter);
31+
positionManager.deposit(COLLATERAL_AMOUNT, DEBT_AMOUNT);
32+
33+
startPrice = oracle.price();
34+
}
35+
36+
/// @notice Price ends exactly where it started, so realised performance is zero —
37+
/// but accruing at each local high mints performance fees anyway.
38+
function test_performanceFee_ratchetsOnRoundTripPrice() public {
39+
uint256 startPrice = _openLeveredPosition();
40+
uint256 highPrice = startPrice * 102 / 100; // +2% swing
41+
42+
for (uint256 i = 0; i < 4; i++) {
43+
oracle.setPrice(highPrice);
44+
_touch(); // accrue at the local high -> basis > 0, fee charged
45+
oracle.setPrice(startPrice);
46+
_touch(); // accrue back at the start -> basis < 0, clamped, snapshot still reset
47+
}
48+
49+
assertEq(oracle.price(), startPrice, "price must round-trip to its starting value");
50+
51+
uint256 feeShares = positionManager.balanceOf(feeRecipient);
52+
emit log_named_uint("fee shares minted on a zero-return path", feeShares);
53+
emit log_named_uint("minter shares", positionManager.balanceOf(minter));
54+
55+
assertGt(feeShares, 0, "performance fees charged despite zero net performance");
56+
}
57+
58+
/// @notice Control: identical price path, accrued once at the end. No fee is due.
59+
function test_performanceFee_zeroOnSameRoundTripWhenAccruedOnce() public {
60+
uint256 startPrice = _openLeveredPosition();
61+
uint256 highPrice = startPrice * 102 / 100;
62+
63+
for (uint256 i = 0; i < 4; i++) {
64+
oracle.setPrice(highPrice);
65+
oracle.setPrice(startPrice);
66+
}
67+
_touch(); // single accrual at the end
68+
69+
assertEq(oracle.price(), startPrice, "price must round-trip to its starting value");
70+
assertEq(
71+
positionManager.balanceOf(feeRecipient), 0, "no fee is due on a zero-return path accrued once"
72+
);
73+
}
74+
75+
/// @notice Monotonically rising price: every leg is a gain, so nothing is ever clamped.
76+
/// The residual cadence effect here is second-order (~1.3%), attributable to the
77+
/// deliberate `mulDivUp` bias on the basis plus share-dilution compounding between
78+
/// accruals. Contrast with the round-trip case above, where the ratio is unbounded
79+
/// (a strictly positive fee against a zero-fee control). This isolates the clamp —
80+
/// not rounding or compounding — as the cause of the ratchet.
81+
function test_performanceFee_cadenceEffectIsSecondOrderWhenPriceOnlyRises() public {
82+
uint256 startPrice = _openLeveredPosition();
83+
84+
for (uint256 i = 1; i <= 4; i++) {
85+
oracle.setPrice(startPrice * (100 + i) / 100);
86+
_touch();
87+
}
88+
uint256 steppedFee = positionManager.balanceOf(feeRecipient);
89+
90+
// Fresh run, same endpoint, single accrual.
91+
setUp();
92+
startPrice = _openLeveredPosition();
93+
oracle.setPrice(startPrice * 104 / 100);
94+
_touch();
95+
uint256 singleFee = positionManager.balanceOf(feeRecipient);
96+
97+
emit log_named_uint("stepped accrual fee", steppedFee);
98+
emit log_named_uint("single accrual fee ", singleFee);
99+
assertApproxEqRel(
100+
steppedFee, singleFee, 0.02e18, "monotonic-path cadence effect should stay second-order"
101+
);
102+
}
103+
}

0 commit comments

Comments
 (0)