diff --git a/script/DeploySubscription.s.sol b/script/DeploySubscription.s.sol new file mode 100644 index 0000000..a2b3c2d --- /dev/null +++ b/script/DeploySubscription.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.17 <0.9.0; + +import {Script} from "forge-std/Script.sol"; +import {ENS} from "ens-contracts/registry/ENS.sol"; +import {GrailsSubscription} from "../src/GrailsSubscription.sol"; + +contract DeploySubscription is Script { + function run() external { + address ens = 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e; + + if (block.chainid != 1 && block.chainid != 11155111) { + revert("Unsupported chain"); + } + + // uint256 pricePerDay = vm.envUint("PRICE_PER_DAY"); + uint256 pricePerDay = 273972602739726; + address deployer = vm.envAddress("DEPLOYER"); + + vm.startBroadcast(deployer); + new GrailsSubscription(pricePerDay, ENS(ens), deployer); + vm.stopBroadcast(); + } +} diff --git a/src/GrailsSubscription.sol b/src/GrailsSubscription.sol new file mode 100644 index 0000000..5cd0dcb --- /dev/null +++ b/src/GrailsSubscription.sol @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {ReverseClaimer} from "ens-contracts/reverseRegistrar/ReverseClaimer.sol"; +import {ENS} from "ens-contracts/registry/ENS.sol"; + +/** + * @custom:benediction DEVS BENEDICAT ET PROTEGAT CONTRACTVM MEVM + * @title GrailsSubscription + * @author 0xthrpw + * @notice Minimal subscription contract for Grails PRO tier. + * Users send ETH to subscribe for a given number of days. + * No auto-renewal; call subscribe() again to extend. + */ +contract GrailsSubscription is Ownable2Step, ReverseClaimer { + /** + * @notice Wei charged per day of subscription + */ + uint256 public pricePerDay; + + struct Subscription { + uint256 expiry; + } + + mapping(address => Subscription) public subscriptions; + + /** + * @notice Emitted when a user subscribes or extends their subscription + * @param subscriber The address that subscribed + * @param expiry The new expiry timestamp + * @param amount The ETH amount paid + */ + event Subscribed(address indexed subscriber, uint256 expiry, uint256 amount); + + /** + * @notice Emitted when the price per day is updated + * @param oldPrice The previous price per day in wei + * @param newPrice The new price per day in wei + */ + event PriceUpdated(uint256 oldPrice, uint256 newPrice); + + /** + * @notice Emitted when the owner withdraws collected funds + * @param to The address that received the funds + * @param amount The amount withdrawn in wei + */ + event Withdrawn(address indexed to, uint256 amount); + + /** + * @notice Thrown when subscribing for zero days + */ + error MinimumOneDayRequired(); + + /** + * @notice Thrown when msg.value is less than the required payment + */ + error InsufficientPayment(); + + /** + * @notice Thrown when withdrawing with zero contract balance + */ + error NoBalance(); + + /** + * @notice Thrown when the ETH transfer to the owner fails + */ + error WithdrawFailed(); + + /** + * @param _pricePerDay The initial price per day in wei + * @param _ens Address of the ENS registry (for reverse resolution) + * @param _owner Address to set as contract owner and reverse ENS claimant + */ + constructor(uint256 _pricePerDay, ENS _ens, address _owner) Ownable(_owner) ReverseClaimer(_ens, _owner) { + pricePerDay = _pricePerDay; + } + + /** + * @notice Subscribe or extend subscription for `durationDays` days. + * @param durationDays Number of days to subscribe for (minimum 1). + */ + function subscribe(uint256 durationDays) external payable { + if (durationDays < 1) revert MinimumOneDayRequired(); + if (msg.value < pricePerDay * durationDays) revert InsufficientPayment(); + + uint256 currentExpiry = subscriptions[msg.sender].expiry; + uint256 startFrom = block.timestamp > currentExpiry ? block.timestamp : currentExpiry; + uint256 newExpiry = startFrom + (durationDays * 1 days); + + subscriptions[msg.sender].expiry = newExpiry; + + emit Subscribed(msg.sender, newExpiry, msg.value); + } + + /** + * @notice Check subscription expiry for an address. + * @param subscriber The address to query. + * @return expiry The unix timestamp when the subscription expires (0 if never subscribed). + */ + function getSubscription(address subscriber) external view returns (uint256 expiry) { + return subscriptions[subscriber].expiry; + } + + /** + * @notice Owner-only: withdraw collected funds. + */ + function withdraw() external onlyOwner { + uint256 balance = address(this).balance; + if (balance == 0) revert NoBalance(); + (bool sent,) = owner().call{value: balance}(""); + if (!sent) revert WithdrawFailed(); + emit Withdrawn(owner(), balance); + } + + /** + * @notice Owner-only: update the price per day. + * @param _pricePerDay The new price per day in wei. + */ + function setPrice(uint256 _pricePerDay) external onlyOwner { + uint256 oldPrice = pricePerDay; + pricePerDay = _pricePerDay; + emit PriceUpdated(oldPrice, _pricePerDay); + } +} diff --git a/test/GrailsSubscription.t.sol b/test/GrailsSubscription.t.sol new file mode 100644 index 0000000..017084a --- /dev/null +++ b/test/GrailsSubscription.t.sol @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ENS} from "ens-contracts/registry/ENS.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {GrailsSubscription} from "../src/GrailsSubscription.sol"; + +// --------------------------------------------------------------------------- +// Fork tests — validates ReverseClaimer integration against mainnet ENS +// --------------------------------------------------------------------------- + +contract GrailsSubscriptionForkTest is Test { + GrailsSubscription public sub; + + address constant ENS_REGISTRY = 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e; + uint256 constant PRICE_PER_DAY = 0.001 ether; + address owner = address(this); + + function setUp() public { + vm.createSelectFork(vm.envString("MAINNET_RPC_URL")); + sub = new GrailsSubscription(PRICE_PER_DAY, ENS(ENS_REGISTRY), owner); + } + + function test_constructor_setsOwner() public view { + assertEq(sub.owner(), owner); + } + + function test_constructor_setsPricePerDay() public view { + assertEq(sub.pricePerDay(), PRICE_PER_DAY); + } + + function test_constructor_deploySucceeds() public view { + // If we got here, the ReverseClaimer constructor didn't revert + assertTrue(address(sub) != address(0)); + } +} + +// --------------------------------------------------------------------------- +// Unit tests — no fork required, ENS calls are mocked +// --------------------------------------------------------------------------- + +contract GrailsSubscriptionTest is Test { + GrailsSubscription public sub; + + uint256 constant PRICE_PER_DAY = 0.001 ether; + address owner; + address user = address(0xBEEF); + + // ENS mock addresses + address constant ENS_REGISTRY = 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e; + address constant REVERSE_REGISTRAR = address(0xA11CE); + bytes32 constant ADDR_REVERSE_NODE = 0x91d1777781884d03a6757a803996e38de2a42967fb37eeaca72729271025a9e2; + + function setUp() public { + owner = address(this); + + // Mock ENS.owner(ADDR_REVERSE_NODE) → REVERSE_REGISTRAR + vm.mockCall(ENS_REGISTRY, abi.encodeWithSignature("owner(bytes32)", ADDR_REVERSE_NODE), abi.encode(REVERSE_REGISTRAR)); + + // Mock IReverseRegistrar.claim(owner) → node + vm.mockCall(REVERSE_REGISTRAR, abi.encodeWithSignature("claim(address)", owner), abi.encode(bytes32(0))); + + sub = new GrailsSubscription(PRICE_PER_DAY, ENS(ENS_REGISTRY), owner); + + vm.deal(user, 100 ether); + } + + // ----------------------------------------------------------------------- + // subscribe + // ----------------------------------------------------------------------- + + function test_subscribe_singleDay() public { + vm.prank(user); + sub.subscribe{value: PRICE_PER_DAY}(1); + + uint256 expiry = sub.getSubscription(user); + assertEq(expiry, block.timestamp + 1 days); + } + + function test_subscribe_multipleDays() public { + vm.prank(user); + sub.subscribe{value: PRICE_PER_DAY * 30}(30); + + uint256 expiry = sub.getSubscription(user); + assertEq(expiry, block.timestamp + 30 days); + } + + function test_subscribe_extendsFromExpiry() public { + vm.prank(user); + sub.subscribe{value: PRICE_PER_DAY * 10}(10); + uint256 firstExpiry = sub.getSubscription(user); + + // Warp to midway — subscription still active + vm.warp(block.timestamp + 5 days); + + vm.prank(user); + sub.subscribe{value: PRICE_PER_DAY * 5}(5); + uint256 secondExpiry = sub.getSubscription(user); + + // Should extend from the first expiry, not from now + assertEq(secondExpiry, firstExpiry + 5 days); + } + + function test_subscribe_extendsFromNowIfExpired() public { + vm.prank(user); + sub.subscribe{value: PRICE_PER_DAY}(1); + + // Warp past expiry + vm.warp(block.timestamp + 10 days); + uint256 nowTs = block.timestamp; + + vm.prank(user); + sub.subscribe{value: PRICE_PER_DAY * 3}(3); + + uint256 expiry = sub.getSubscription(user); + assertEq(expiry, nowTs + 3 days); + } + + function test_subscribe_emitsEvent() public { + vm.prank(user); + vm.expectEmit(true, false, false, true); + emit GrailsSubscription.Subscribed(user, block.timestamp + 1 days, PRICE_PER_DAY); + sub.subscribe{value: PRICE_PER_DAY}(1); + } + + function test_subscribe_revertsOnZeroDays() public { + vm.prank(user); + vm.expectRevert(GrailsSubscription.MinimumOneDayRequired.selector); + sub.subscribe{value: PRICE_PER_DAY}(0); + } + + function test_subscribe_revertsOnInsufficientPayment() public { + vm.prank(user); + vm.expectRevert(GrailsSubscription.InsufficientPayment.selector); + sub.subscribe{value: PRICE_PER_DAY - 1}(1); + } + + function test_subscribe_acceptsOverpayment() public { + uint256 balBefore = address(sub).balance; + + vm.prank(user); + sub.subscribe{value: PRICE_PER_DAY * 10}(1); + + uint256 expiry = sub.getSubscription(user); + assertEq(expiry, block.timestamp + 1 days); + assertEq(address(sub).balance, balBefore + PRICE_PER_DAY * 10); + } + + // ----------------------------------------------------------------------- + // getSubscription + // ----------------------------------------------------------------------- + + function test_getSubscription_returnsZeroForNonSubscriber() public view { + assertEq(sub.getSubscription(address(0xDEAD)), 0); + } + + function test_getSubscription_returnsExpiryAfterSubscribe() public { + vm.prank(user); + sub.subscribe{value: PRICE_PER_DAY * 7}(7); + assertEq(sub.getSubscription(user), block.timestamp + 7 days); + } + + // ----------------------------------------------------------------------- + // withdraw + // ----------------------------------------------------------------------- + + function test_withdraw_sendsBalanceToOwner() public { + vm.prank(user); + sub.subscribe{value: 1 ether}(1); + + uint256 contractBal = address(sub).balance; + uint256 ownerBefore = owner.balance; + sub.withdraw(); + assertEq(owner.balance, ownerBefore + contractBal); + assertEq(address(sub).balance, 0); + } + + function test_withdraw_emitsEvent() public { + vm.prank(user); + sub.subscribe{value: 1 ether}(1); + + uint256 contractBal = address(sub).balance; + vm.expectEmit(true, false, false, true); + emit GrailsSubscription.Withdrawn(owner, contractBal); + sub.withdraw(); + } + + function test_withdraw_revertsOnNoBalance() public { + // Drain any pre-existing balance from deployment + if (address(sub).balance > 0) { + sub.withdraw(); + } + vm.expectRevert(GrailsSubscription.NoBalance.selector); + sub.withdraw(); + } + + function test_withdraw_revertsForNonOwner() public { + vm.prank(user); + sub.subscribe{value: 1 ether}(1); + + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + sub.withdraw(); + } + + function test_withdraw_revertsOnFailedTransfer() public { + // Deploy with a NoReceiveOwner as owner + NoReceiveOwner noReceive = new NoReceiveOwner(); + vm.mockCall(ENS_REGISTRY, abi.encodeWithSignature("owner(bytes32)", ADDR_REVERSE_NODE), abi.encode(REVERSE_REGISTRAR)); + vm.mockCall(REVERSE_REGISTRAR, abi.encodeWithSignature("claim(address)", address(noReceive)), abi.encode(bytes32(0))); + + GrailsSubscription sub2 = new GrailsSubscription(PRICE_PER_DAY, ENS(ENS_REGISTRY), address(noReceive)); + + vm.prank(user); + sub2.subscribe{value: 1 ether}(1); + + vm.prank(address(noReceive)); + vm.expectRevert(GrailsSubscription.WithdrawFailed.selector); + sub2.withdraw(); + } + + // ----------------------------------------------------------------------- + // setPrice + // ----------------------------------------------------------------------- + + function test_setPrice_updatesPrice() public { + uint256 newPrice = 0.002 ether; + sub.setPrice(newPrice); + assertEq(sub.pricePerDay(), newPrice); + } + + function test_setPrice_emitsEvent() public { + uint256 newPrice = 0.002 ether; + vm.expectEmit(false, false, false, true); + emit GrailsSubscription.PriceUpdated(PRICE_PER_DAY, newPrice); + sub.setPrice(newPrice); + } + + function test_setPrice_revertsForNonOwner() public { + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + sub.setPrice(0.002 ether); + } + + // ----------------------------------------------------------------------- + // Ownership (Ownable2Step) + // ----------------------------------------------------------------------- + + function test_transferOwnership_setsPending() public { + address newOwner = address(0xCAFE); + sub.transferOwnership(newOwner); + assertEq(sub.pendingOwner(), newOwner); + } + + function test_transferOwnership_doesNotChangeOwnerImmediately() public { + address newOwner = address(0xCAFE); + sub.transferOwnership(newOwner); + assertEq(sub.owner(), owner); + } + + function test_acceptOwnership_completesTransfer() public { + address newOwner = address(0xCAFE); + sub.transferOwnership(newOwner); + + vm.prank(newOwner); + sub.acceptOwnership(); + + assertEq(sub.owner(), newOwner); + assertEq(sub.pendingOwner(), address(0)); + } + + function test_acceptOwnership_revertsForNonPending() public { + address newOwner = address(0xCAFE); + sub.transferOwnership(newOwner); + + vm.prank(address(0xBAD)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(0xBAD))); + sub.acceptOwnership(); + } + + function test_transferOwnership_revertsForNonOwner() public { + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + sub.transferOwnership(address(0xCAFE)); + } + + function test_renounceOwnership() public { + sub.renounceOwnership(); + assertEq(sub.owner(), address(0)); + } + + // Allow receiving ETH (for withdraw tests) + receive() external payable {} +} + +/// @dev Helper contract with no receive/fallback — triggers WithdrawFailed +contract NoReceiveOwner { + // No receive() or fallback() — ETH transfers will fail + + }