Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions script/DeploySubscription.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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);

Expand Down
89 changes: 89 additions & 0 deletions src/GrailsSubscription.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
}
Expand Down Expand Up @@ -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.
*/
Expand Down
7 changes: 7 additions & 0 deletions src/IGrailsPricing.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
3 changes: 1 addition & 2 deletions test/BulkRegistration.gas.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading