Skip to content

Commit ce4cb14

Browse files
authored
feat: allow membership renewal at lower of stored or current price (#4510)
### Description Updated the membership renewal price calculation to ensure users benefit from price drops while maintaining their locked-in rates if prices increase. ### Changes - Modified `getMembershipRenewalPrice` to compare stored renewal price with current price - Changed logic to use the lower of stored renewal price or current price - Added comments explaining the pricing logic - Ensured the platform minimum fee is still enforced ### Checklist - [ ] Tests added where required - [ ] Documentation updated where applicable - [ ] Changes adhere to the repository's contribution guidelines
1 parent 20484b3 commit ce4cb14

File tree

2 files changed

+207
-3
lines changed

2 files changed

+207
-3
lines changed

packages/contracts/src/spaces/facets/membership/MembershipBase.sol

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,18 @@ abstract contract MembershipBase is IMembershipBase {
190190

191191
uint256 minFee = platform.getMembershipFee();
192192
uint256 renewalPrice = $.renewalPriceByTokenId[tokenId];
193+
uint256 currentPrice = _getMembershipPrice(totalSupply);
193194

194-
if (renewalPrice != 0) return FixedPointMathLib.max(renewalPrice, minFee);
195+
// If no stored renewal price, use current price
196+
if (renewalPrice == 0) {
197+
return FixedPointMathLib.max(currentPrice, minFee);
198+
}
195199

196-
uint256 price = _getMembershipPrice(totalSupply);
197-
return FixedPointMathLib.max(price, minFee);
200+
// Use the LOWER of stored renewal price or current price
201+
// This ensures users benefit from price drops (including free transitions)
202+
// while maintaining their locked-in rate if prices increase
203+
uint256 effectivePrice = FixedPointMathLib.min(renewalPrice, currentPrice);
204+
return FixedPointMathLib.max(effectivePrice, minFee);
198205
}
199206

200207
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/

packages/contracts/test/spaces/membership/unit/MembershipRenew.t.sol

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,201 @@ contract MembershipRenewTest is MembershipBaseSetup, IERC5643Base {
382382
assertGt(membership.expiresAt(tokenId), originalExpiration);
383383
assertEq(IERC20(riverAirdrop).balanceOf(alice), currentPoints + points);
384384
}
385+
386+
function test_renewMembershipAfterPriceDropToFree() external {
387+
// Setup: Create a paid town with initial price
388+
uint256 initialPrice = MEMBERSHIP_PRICE;
389+
uint256 platformMinFee = platformReqs.getMembershipFee();
390+
_setupMembershipPricing(1, initialPrice);
391+
392+
// Alice joins at the higher price
393+
vm.deal(alice, initialPrice);
394+
vm.prank(alice);
395+
membership.joinSpace{value: initialPrice}(alice);
396+
397+
uint256 tokenId = _getAliceTokenId();
398+
uint256 originalExpiration = membership.expiresAt(tokenId);
399+
400+
// Verify Alice paid the initial price
401+
uint256 lockedRenewalPrice = _getRenewalPrice(tokenId);
402+
assertEq(lockedRenewalPrice, initialPrice, "Initial renewal price should be locked");
403+
404+
// Town transitions to minimum price (effectively free except for protocol fee)
405+
vm.prank(founder);
406+
membership.setMembershipPrice(platformMinFee);
407+
408+
// Verify current price is now just the platform minimum fee
409+
uint256 newMembershipPrice = membership.getMembershipPrice();
410+
assertEq(newMembershipPrice, platformMinFee, "New price should be platform minimum");
411+
412+
// Warp to expiration
413+
vm.warp(originalExpiration);
414+
415+
// Get updated renewal price - should be the lower free price, not the locked higher price
416+
uint256 renewalPrice = _getRenewalPrice(tokenId);
417+
assertEq(
418+
renewalPrice,
419+
platformMinFee,
420+
"Renewal price should use lower current price, not locked price"
421+
);
422+
assertLt(renewalPrice, initialPrice, "Renewal price should be less than original");
423+
424+
// Track balances
425+
(address protocol, uint256 protocolBalanceBefore) = _getProtocolFeeData();
426+
uint256 spaceBalanceBefore = address(membership).balance;
427+
uint256 currentPoints = IERC20(riverAirdrop).balanceOf(alice);
428+
429+
// Alice renews at the new lower price
430+
_renewMembershipWithValue(alice, tokenId, renewalPrice);
431+
432+
// Verify protocol fee (all goes to protocol since price equals min fee)
433+
assertEq(
434+
protocol.balance - protocolBalanceBefore,
435+
platformMinFee,
436+
"Protocol should receive the minimum fee"
437+
);
438+
439+
// Verify space receives nothing (entire amount is protocol fee)
440+
assertEq(address(membership).balance, spaceBalanceBefore, "Space should receive nothing");
441+
442+
// Verify membership was renewed
443+
assertGt(membership.expiresAt(tokenId), originalExpiration, "Membership should be renewed");
444+
445+
// Verify points were awarded based on renewal price
446+
uint256 points = _getPoints(renewalPrice);
447+
assertEq(
448+
IERC20(riverAirdrop).balanceOf(alice),
449+
currentPoints + points,
450+
"Points should be awarded"
451+
);
452+
}
453+
454+
function test_renewMembershipAfterPriceDropToLower() external {
455+
// Setup: Create a paid town with high initial price
456+
uint256 initialPrice = MEMBERSHIP_PRICE;
457+
uint256 lowerPrice = MEMBERSHIP_PRICE / 2;
458+
_setupMembershipPricing(1, initialPrice);
459+
460+
// Alice joins at the higher price
461+
vm.deal(alice, initialPrice);
462+
vm.prank(alice);
463+
membership.joinSpace{value: initialPrice}(alice);
464+
465+
uint256 tokenId = _getAliceTokenId();
466+
uint256 originalExpiration = membership.expiresAt(tokenId);
467+
468+
// Verify Alice's renewal price is locked at initial price
469+
assertEq(_getRenewalPrice(tokenId), initialPrice, "Initial renewal price should be locked");
470+
471+
// Town reduces price to lower amount
472+
vm.prank(founder);
473+
membership.setMembershipPrice(lowerPrice);
474+
475+
// Verify new membership price
476+
assertEq(
477+
membership.getMembershipPrice(),
478+
lowerPrice,
479+
"New membership price should be lower"
480+
);
481+
482+
// Warp to expiration
483+
vm.warp(originalExpiration);
484+
485+
// Get renewal price - should be the lower current price
486+
uint256 renewalPrice = _getRenewalPrice(tokenId);
487+
assertEq(renewalPrice, lowerPrice, "Renewal should use lower current price");
488+
assertLt(renewalPrice, initialPrice, "Renewal price should be less than original");
489+
490+
// Track balances
491+
(address protocol, uint256 protocolBalanceBefore) = _getProtocolFeeData();
492+
uint256 spaceBalanceBefore = address(membership).balance;
493+
uint256 currentPoints = IERC20(riverAirdrop).balanceOf(alice);
494+
495+
// Alice renews at the lower price
496+
_renewMembershipWithValue(alice, tokenId, renewalPrice);
497+
498+
// Calculate expected fees
499+
uint256 protocolFee = _calculateProtocolFee(renewalPrice);
500+
uint256 points = _getPoints(renewalPrice);
501+
502+
// Verify balances
503+
assertEq(
504+
protocol.balance - protocolBalanceBefore,
505+
protocolFee,
506+
"Protocol should receive correct fee"
507+
);
508+
assertEq(
509+
address(membership).balance,
510+
spaceBalanceBefore + renewalPrice - protocolFee,
511+
"Space should receive net amount"
512+
);
513+
514+
// Verify membership was renewed
515+
assertGt(membership.expiresAt(tokenId), originalExpiration, "Membership should be renewed");
516+
517+
// Verify points
518+
assertEq(
519+
IERC20(riverAirdrop).balanceOf(alice),
520+
currentPoints + points,
521+
"Points should be awarded based on renewal price"
522+
);
523+
}
524+
525+
function test_renewMembershipAfterPriceIncrease() external {
526+
// Setup: Create a paid town with low initial price
527+
uint256 initialPrice = MEMBERSHIP_PRICE / 2;
528+
uint256 higherPrice = MEMBERSHIP_PRICE;
529+
_setupMembershipPricing(1, initialPrice);
530+
531+
// Alice joins at the lower price
532+
vm.deal(alice, initialPrice);
533+
vm.prank(alice);
534+
membership.joinSpace{value: initialPrice}(alice);
535+
536+
uint256 tokenId = _getAliceTokenId();
537+
uint256 originalExpiration = membership.expiresAt(tokenId);
538+
539+
// Verify Alice's renewal price is locked at initial lower price
540+
assertEq(_getRenewalPrice(tokenId), initialPrice, "Initial renewal price should be locked");
541+
542+
// Town increases price
543+
vm.prank(founder);
544+
membership.setMembershipPrice(higherPrice);
545+
546+
// Verify new membership price is higher
547+
assertEq(
548+
membership.getMembershipPrice(),
549+
higherPrice,
550+
"New membership price should be higher"
551+
);
552+
553+
// Warp to expiration
554+
vm.warp(originalExpiration);
555+
556+
// Get renewal price - should still be the locked lower price
557+
uint256 renewalPrice = _getRenewalPrice(tokenId);
558+
assertEq(
559+
renewalPrice,
560+
initialPrice,
561+
"Renewal should maintain locked lower price despite increase"
562+
);
563+
assertLt(renewalPrice, higherPrice, "Renewal price should be less than new higher price");
564+
565+
// Track balances
566+
uint256 currentPoints = IERC20(riverAirdrop).balanceOf(alice);
567+
568+
// Alice renews at her locked-in lower price
569+
_renewMembershipWithValue(alice, tokenId, renewalPrice);
570+
571+
// Verify membership was renewed
572+
assertGt(membership.expiresAt(tokenId), originalExpiration, "Membership should be renewed");
573+
574+
// Verify points based on the lower renewal price
575+
uint256 points = _getPoints(renewalPrice);
576+
assertEq(
577+
IERC20(riverAirdrop).balanceOf(alice),
578+
currentPoints + points,
579+
"Points should be awarded based on locked lower price"
580+
);
581+
}
385582
}

0 commit comments

Comments
 (0)