diff --git a/script/DeploySubscription.s.sol b/script/DeploySubscription.s.sol index 6ba8452..afa2aed 100644 --- a/script/DeploySubscription.s.sol +++ b/script/DeploySubscription.s.sol @@ -24,6 +24,8 @@ contract DeploySubscription is Script { uint256 tier1Rate = 3_858_024_691_358; // ~$30/month tier uint256 tier2Rate = 11_574_074_074_074; + // ~$50/month tier (placeholder — adjust to final gold price) + uint256 tier3Rate = 19_290_123_456_790; address deployer = vm.envAddress("DEPLOYER"); @@ -32,6 +34,7 @@ contract DeploySubscription is Script { GrailsPricing pricing = new GrailsPricing(AggregatorInterface(chainlinkOracle), deployer); pricing.setTierPrice(1, tier1Rate); pricing.setTierPrice(2, tier2Rate); + pricing.setTierPrice(3, tier3Rate); new GrailsSubscription(IGrailsPricing(address(pricing)), ENS(ens), deployer); diff --git a/src/GrailsSubscription.sol b/src/GrailsSubscription.sol index 9768d48..ac09d40 100644 --- a/src/GrailsSubscription.sol +++ b/src/GrailsSubscription.sol @@ -48,6 +48,16 @@ contract GrailsSubscription is Ownable2Step, ReverseClaimer { */ event Withdrawn(address indexed to, uint256 amount); + /** + * @notice Emitted when a user upgrades their subscription tier + * @param subscriber The address that upgraded + * @param oldTierId The previous tier + * @param newTierId The new (higher) tier + * @param expiry The new expiry timestamp after conversion + * @param amount The ETH amount paid for additional days (0 if pure conversion) + */ + event Upgraded(address indexed subscriber, uint256 indexed oldTierId, uint256 indexed newTierId, uint256 expiry, uint256 amount); + /** * @notice Thrown when subscribing for zero days */ @@ -73,6 +83,21 @@ contract GrailsSubscription is Ownable2Step, ReverseClaimer { */ error RefundFailed(); + /** + * @notice Thrown when upgrading with no active (non-expired) subscription + */ + error NoActiveSubscription(); + + /** + * @notice Thrown when the target tier rate is not strictly higher than the current tier rate + */ + error NotAnUpgrade(); + + /** + * @notice Thrown when the target tier is not configured (rate == 0) + */ + error TierNotConfigured(); + constructor(IGrailsPricing _pricing, ENS _ens, address _owner) Ownable(_owner) ReverseClaimer(_ens, _owner) { pricing = _pricing; } @@ -115,6 +140,70 @@ contract GrailsSubscription is Ownable2Step, ReverseClaimer { return pricing.price(tierId, durationDays * 1 days); } + /** + * @notice Upgrade an active subscription to a higher tier. + * Remaining time is proportionally converted based on attoUSD/sec rates. + * Optionally pay ETH for additional days on the new tier. + * @param newTierId The tier to upgrade to (must have a strictly higher rate). + * @param extraDays Additional days to purchase on the new tier (can be 0). + */ + function upgrade(uint256 newTierId, uint256 extraDays) external payable { + Subscription storage sub = subscriptions[msg.sender]; + + if (sub.expiry <= block.timestamp) revert NoActiveSubscription(); + + uint256 currentRate = pricing.tierPrices(sub.tierId); + uint256 newRate = pricing.tierPrices(newTierId); + + if (newRate == 0) revert TierNotConfigured(); + if (newRate <= currentRate) revert NotAnUpgrade(); + + uint256 remainingSeconds = sub.expiry - block.timestamp; + uint256 convertedSeconds = (remainingSeconds * currentRate) / newRate; + + uint256 extraSeconds = extraDays * 1 days; + if (extraDays > 0) { + uint256 requiredWei = pricing.price(newTierId, extraSeconds); + if (msg.value < requiredWei) revert InsufficientPayment(); + + uint256 excess = msg.value - requiredWei; + if (excess > 0) { + (bool sent,) = msg.sender.call{value: excess}(""); + if (!sent) revert RefundFailed(); + } + } else if (msg.value > 0) { + (bool sent,) = msg.sender.call{value: msg.value}(""); + if (!sent) revert RefundFailed(); + } + + uint256 oldTierId = sub.tierId; + uint256 newExpiry = block.timestamp + convertedSeconds + extraSeconds; + sub.expiry = newExpiry; + sub.tierId = newTierId; + + emit Upgraded(msg.sender, oldTierId, newTierId, newExpiry, msg.value); + } + + /** + * @notice Preview what expiry a user would get if they upgraded to a new tier (no extra days). + * @param subscriber The address to check. + * @param newTierId The target tier. + * @return newExpiry The projected new expiry timestamp (0 if not upgradeable). + * @return convertedSeconds The number of seconds that would remain on the new tier. + */ + function previewUpgrade(address subscriber, uint256 newTierId) external view returns (uint256 newExpiry, uint256 convertedSeconds) { + Subscription memory sub = subscriptions[subscriber]; + if (sub.expiry <= block.timestamp) return (0, 0); + + uint256 currentRate = pricing.tierPrices(sub.tierId); + uint256 newRate = pricing.tierPrices(newTierId); + if (newRate == 0 || newRate <= currentRate) return (0, 0); + + uint256 remainingSeconds = sub.expiry - block.timestamp; + convertedSeconds = (remainingSeconds * currentRate) / newRate; + newExpiry = block.timestamp + convertedSeconds; + } + /** * @notice Owner-only: withdraw collected funds. */ diff --git a/src/IGrailsPricing.sol b/src/IGrailsPricing.sol index 25ee2d1..8e29304 100644 --- a/src/IGrailsPricing.sol +++ b/src/IGrailsPricing.sol @@ -9,4 +9,11 @@ interface IGrailsPricing { * @return weiPrice The price in wei. */ function price(uint256 tierId, uint256 duration) external view returns (uint256 weiPrice); + + /** + * @notice Returns the raw attoUSD-per-second rate for a tier. + * @param tierId The subscription tier identifier. + * @return attoUSDPerSecond The tier's price rate (0 if not configured). + */ + function tierPrices(uint256 tierId) external view returns (uint256 attoUSDPerSecond); } diff --git a/test/BulkRegistration.gas.t.sol b/test/BulkRegistration.gas.t.sol index 54871c3..e2d86ce 100644 --- a/test/BulkRegistration.gas.t.sol +++ b/test/BulkRegistration.gas.t.sol @@ -64,8 +64,7 @@ contract BulkRegistrationGasTest is Test { uint256[] memory durations = _durations(count); // Commit and wait - bytes32[] memory commitments = - bulk.makeCommitments(names, owner, durations, SECRET, PUBLIC_RESOLVER, _emptyData(count), 0); + bytes32[] memory commitments = bulk.makeCommitments(names, owner, durations, SECRET, PUBLIC_RESOLVER, _emptyData(count), 0); bulk.multiCommit(commitments); vm.warp(block.timestamp + 61); diff --git a/test/GrailsSubscription.t.sol b/test/GrailsSubscription.t.sol index 75a8754..53633c3 100644 --- a/test/GrailsSubscription.t.sol +++ b/test/GrailsSubscription.t.sol @@ -73,6 +73,8 @@ contract GrailsSubscriptionTest is Test { uint256 constant TIER1_RATE = 3_858_024_691_358; // ~$30/month tier uint256 constant TIER2_RATE = 11_574_074_074_074; + // ~$50/month tier + uint256 constant TIER3_RATE = 19_290_123_456_790; address owner; address user = address(0xBEEF); @@ -94,6 +96,7 @@ contract GrailsSubscriptionTest is Test { gp = new GrailsPricing(AggregatorInterface(address(oracle)), owner); gp.setTierPrice(1, TIER1_RATE); gp.setTierPrice(2, TIER2_RATE); + gp.setTierPrice(3, TIER3_RATE); // Deploy subscription sub = new GrailsSubscription(IGrailsPricing(address(gp)), ENS(ENS_REGISTRY), owner); @@ -234,6 +237,288 @@ contract GrailsSubscriptionTest is Test { assertTrue(cost2 < cost1 * 4); } + // ----------------------------------------------------------------------- + // upgrade + // ----------------------------------------------------------------------- + + function test_upgrade_basicConversion() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + uint256 nowTs = block.timestamp; + + // 15 days remaining on tier 1, upgrade to tier 2 + uint256 remainingSec = 15 days; + uint256 convertedSec = (remainingSec * TIER1_RATE) / TIER2_RATE; + + vm.prank(user); + sub.upgrade(2, 0); + + (uint256 expiry, uint256 tierId) = sub.subscriptions(user); + assertEq(tierId, 2); + assertEq(expiry, nowTs + convertedSec); + } + + function test_upgrade_exactRatioMath() public { + // Tier 1 ~$10/mo, Tier 2 ~$30/mo → ratio ~3:1 + // 15 days remaining → ~5 days on tier 2 + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + uint256 nowTs = block.timestamp; + + vm.prank(user); + sub.upgrade(2, 0); + + uint256 expiry = sub.getSubscription(user); + uint256 convertedDays = (expiry - nowTs) / 1 days; + + // Should be approximately 5 days (exact value depends on rate precision) + assertApproxEqAbs(convertedDays, 5, 1); + } + + function test_upgrade_tierIdUpdated() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 10 days); + + vm.prank(user); + sub.upgrade(2, 0); + + (, uint256 tierId) = sub.subscriptions(user); + assertEq(tierId, 2); + } + + function test_upgrade_emitsEvent() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + uint256 nowTs = block.timestamp; + + uint256 remainingSec = 15 days; + uint256 convertedSec = (remainingSec * TIER1_RATE) / TIER2_RATE; + uint256 expectedExpiry = nowTs + convertedSec; + + vm.prank(user); + vm.expectEmit(true, true, true, true); + emit GrailsSubscription.Upgraded(user, 1, 2, expectedExpiry, 0); + sub.upgrade(2, 0); + } + + function test_upgrade_withExtraDays() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + uint256 nowTs = block.timestamp; + + uint256 remainingSec = 15 days; + uint256 convertedSec = (remainingSec * TIER1_RATE) / TIER2_RATE; + + uint256 extraCost = _expectedWei(2, 10); + vm.prank(user); + sub.upgrade{value: extraCost}(2, 10); + + (uint256 expiry,) = sub.subscriptions(user); + assertEq(expiry, nowTs + convertedSec + 10 days); + } + + function test_upgrade_withExtraDaysPayment() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + + uint256 extraCost = _expectedWei(2, 10); + uint256 balBefore = user.balance; + + vm.prank(user); + sub.upgrade{value: extraCost}(2, 10); + + assertEq(user.balance, balBefore - extraCost); + } + + function test_upgrade_withExtraDaysRefundsExcess() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + + uint256 extraCost = _expectedWei(2, 10); + uint256 overpay = extraCost * 5; + uint256 balBefore = user.balance; + + vm.prank(user); + sub.upgrade{value: overpay}(2, 10); + + assertEq(user.balance, balBefore - extraCost); + } + + function test_upgrade_revertsOnExpired() public { + uint256 cost = _expectedWei(1, 1); + vm.prank(user); + sub.subscribe{value: cost}(1, 1); + + vm.warp(block.timestamp + 2 days); + + vm.prank(user); + vm.expectRevert(GrailsSubscription.NoActiveSubscription.selector); + sub.upgrade(2, 0); + } + + function test_upgrade_revertsOnNoSubscription() public { + vm.prank(user); + vm.expectRevert(GrailsSubscription.NoActiveSubscription.selector); + sub.upgrade(2, 0); + } + + function test_upgrade_revertsOnSameTier() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.prank(user); + vm.expectRevert(GrailsSubscription.NotAnUpgrade.selector); + sub.upgrade(1, 0); + } + + function test_upgrade_revertsOnDowngrade() public { + uint256 cost = _expectedWei(2, 30); + vm.prank(user); + sub.subscribe{value: cost}(2, 30); + + vm.prank(user); + vm.expectRevert(GrailsSubscription.NotAnUpgrade.selector); + sub.upgrade(1, 0); + } + + function test_upgrade_revertsOnUnconfiguredTier() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.prank(user); + vm.expectRevert(GrailsSubscription.TierNotConfigured.selector); + sub.upgrade(99, 0); + } + + function test_upgrade_revertsOnInsufficientPayment() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + + uint256 extraCost = _expectedWei(2, 10); + vm.prank(user); + vm.expectRevert(GrailsSubscription.InsufficientPayment.selector); + sub.upgrade{value: extraCost - 1}(2, 10); + } + + function test_upgrade_pureConversionNoEth() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + + uint256 balBefore = user.balance; + vm.prank(user); + sub.upgrade(2, 0); + + assertEq(user.balance, balBefore); + } + + function test_upgrade_refundsAccidentalEth() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + + uint256 balBefore = user.balance; + vm.prank(user); + sub.upgrade{value: 1 ether}(2, 0); + + assertEq(user.balance, balBefore); + } + + function test_upgrade_tier1ToTier3() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + uint256 nowTs = block.timestamp; + + uint256 remainingSec = 15 days; + uint256 convertedSec = (remainingSec * TIER1_RATE) / TIER3_RATE; + + vm.prank(user); + sub.upgrade(3, 0); + + (uint256 expiry, uint256 tierId) = sub.subscriptions(user); + assertEq(tierId, 3); + assertEq(expiry, nowTs + convertedSec); + } + + // ----------------------------------------------------------------------- + // previewUpgrade + // ----------------------------------------------------------------------- + + function test_previewUpgrade_matchesActual() public { + uint256 cost = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost}(1, 30); + + vm.warp(block.timestamp + 15 days); + + (uint256 previewExpiry, uint256 previewConverted) = sub.previewUpgrade(user, 2); + + vm.prank(user); + sub.upgrade(2, 0); + + (uint256 actualExpiry,) = sub.subscriptions(user); + assertEq(previewExpiry, actualExpiry); + + uint256 remainingSec = 15 days; + uint256 expectedConverted = (remainingSec * TIER1_RATE) / TIER2_RATE; + assertEq(previewConverted, expectedConverted); + } + + function test_previewUpgrade_returnsZeroForExpired() public { + uint256 cost = _expectedWei(1, 1); + vm.prank(user); + sub.subscribe{value: cost}(1, 1); + + vm.warp(block.timestamp + 2 days); + + (uint256 previewExpiry, uint256 previewConverted) = sub.previewUpgrade(user, 2); + assertEq(previewExpiry, 0); + assertEq(previewConverted, 0); + } + + function test_previewUpgrade_returnsZeroForDowngrade() public { + uint256 cost = _expectedWei(2, 30); + vm.prank(user); + sub.subscribe{value: cost}(2, 30); + + (uint256 previewExpiry, uint256 previewConverted) = sub.previewUpgrade(user, 1); + assertEq(previewExpiry, 0); + assertEq(previewConverted, 0); + } + // ----------------------------------------------------------------------- // getPrice // -----------------------------------------------------------------------