diff --git a/script/DeploySubscription.s.sol b/script/DeploySubscription.s.sol index a2b3c2d..6ba8452 100644 --- a/script/DeploySubscription.s.sol +++ b/script/DeploySubscription.s.sol @@ -4,21 +4,37 @@ 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"; +import {GrailsPricing, AggregatorInterface} from "../src/GrailsPricing.sol"; +import {IGrailsPricing} from "../src/IGrailsPricing.sol"; contract DeploySubscription is Script { function run() external { address ens = 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e; - if (block.chainid != 1 && block.chainid != 11155111) { + address chainlinkOracle; + if (block.chainid == 1) { + chainlinkOracle = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; + } else if (block.chainid == 11155111) { + chainlinkOracle = 0x694AA1769357215DE4FAC081bf1f309aDC325306; + } else { revert("Unsupported chain"); } - // uint256 pricePerDay = vm.envUint("PRICE_PER_DAY"); - uint256 pricePerDay = 273972602739726; + // ~$10/month tier + uint256 tier1Rate = 3_858_024_691_358; + // ~$30/month tier + uint256 tier2Rate = 11_574_074_074_074; + address deployer = vm.envAddress("DEPLOYER"); vm.startBroadcast(deployer); - new GrailsSubscription(pricePerDay, ENS(ens), deployer); + + GrailsPricing pricing = new GrailsPricing(AggregatorInterface(chainlinkOracle), deployer); + pricing.setTierPrice(1, tier1Rate); + pricing.setTierPrice(2, tier2Rate); + + new GrailsSubscription(IGrailsPricing(address(pricing)), ENS(ens), deployer); + vm.stopBroadcast(); } } diff --git a/src/GrailsPricing.sol b/src/GrailsPricing.sol new file mode 100644 index 0000000..5b50d2e --- /dev/null +++ b/src/GrailsPricing.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import {IGrailsPricing} from "./IGrailsPricing.sol"; + +interface AggregatorInterface { + function latestAnswer() external view returns (int256); +} + +/** + * @custom:benediction DEVS BENEDICAT ET PROTEGAT CONTRACTVM MEVM + * @title GrailsPricing + * @author 0xthrpw + * @notice USD-based subscription pricing using a Chainlink ETH/USD oracle. + * Tier prices are stored as attoUSD-per-second (18-decimal USD). + * Follows the ENS StablePriceOracle pattern for USD→Wei conversion. + */ +contract GrailsPricing is IGrailsPricing, Ownable2Step { + AggregatorInterface public usdOracle; + + /// @notice Tier ID → attoUSD-per-second rate + mapping(uint256 => uint256) public tierPrices; + + error TierNotConfigured(); + + event TierPriceUpdated(uint256 indexed tierId, uint256 oldPrice, uint256 newPrice); + + constructor(AggregatorInterface _oracle, address _owner) Ownable(_owner) { + usdOracle = _oracle; + } + + /// @inheritdoc IGrailsPricing + function price(uint256 tierId, uint256 duration) external view returns (uint256 weiPrice) { + uint256 rate = tierPrices[tierId]; + if (rate == 0) revert TierNotConfigured(); + return attoUSDToWei(rate * duration); + } + + /// @notice Set or update a tier's USD rate. + /// @param tierId The tier identifier. + /// @param pricePerSecond The price in attoUSD per second (18-decimal USD). + function setTierPrice(uint256 tierId, uint256 pricePerSecond) external onlyOwner { + uint256 oldPrice = tierPrices[tierId]; + tierPrices[tierId] = pricePerSecond; + emit TierPriceUpdated(tierId, oldPrice, pricePerSecond); + } + + /// @notice Convert attoUSD to wei using the oracle's ETH/USD price. + /// @dev Identical to ENS StablePriceOracle (line 83-86). + function attoUSDToWei(uint256 amount) internal view returns (uint256) { + uint256 ethPrice = uint256(usdOracle.latestAnswer()); + return (amount * 1e8) / ethPrice; + } + + /// @notice Convert wei to attoUSD — view helper for frontends. + function weiToAttoUSD(uint256 amount) external view returns (uint256) { + uint256 ethPrice = uint256(usdOracle.latestAnswer()); + return (amount * ethPrice) / 1e8; + } +} diff --git a/src/GrailsSubscription.sol b/src/GrailsSubscription.sol index 5cd0dcb..39742e3 100644 --- a/src/GrailsSubscription.sol +++ b/src/GrailsSubscription.sol @@ -4,41 +4,42 @@ 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"; +import {IGrailsPricing} from "./IGrailsPricing.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. + * @notice Subscription contract for Grails with USD-priced tiers. + * Users send ETH to subscribe for a given number of days at a chosen tier. + * Pricing is delegated to an IGrailsPricing implementation (oracle-based). * No auto-renewal; call subscribe() again to extend. */ contract GrailsSubscription is Ownable2Step, ReverseClaimer { - /** - * @notice Wei charged per day of subscription - */ - uint256 public pricePerDay; + IGrailsPricing public pricing; struct Subscription { uint256 expiry; + uint256 tierId; } mapping(address => Subscription) public subscriptions; /** - * @notice Emitted when a user subscribes or extends their subscription + * @notice Emitted when a user subscribes or re-subscribes * @param subscriber The address that subscribed + * @param tierId The subscription tier selected * @param expiry The new expiry timestamp * @param amount The ETH amount paid */ - event Subscribed(address indexed subscriber, uint256 expiry, uint256 amount); + event Subscribed(address indexed subscriber, uint256 indexed tierId, 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 + * @notice Emitted when the pricing contract is swapped + * @param oldPricing The previous pricing contract address + * @param newPricing The new pricing contract address */ - event PriceUpdated(uint256 oldPrice, uint256 newPrice); + event PricingUpdated(address oldPricing, address newPricing); /** * @notice Emitted when the owner withdraws collected funds @@ -68,40 +69,52 @@ contract GrailsSubscription is Ownable2Step, ReverseClaimer { 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 + * @notice Thrown when the excess ETH refund to the subscriber fails */ - constructor(uint256 _pricePerDay, ENS _ens, address _owner) Ownable(_owner) ReverseClaimer(_ens, _owner) { - pricePerDay = _pricePerDay; + error RefundFailed(); + + constructor(IGrailsPricing _pricing, ENS _ens, address _owner) Ownable(_owner) ReverseClaimer(_ens, _owner) { + pricing = _pricing; } /** - * @notice Subscribe or extend subscription for `durationDays` days. - * @param durationDays Number of days to subscribe for (minimum 1). + * @notice Subscribe or re-subscribe for `durationDays` days at `tierId`. + * Always starts from block.timestamp (replaces any existing subscription). + * Excess ETH is refunded automatically. */ - function subscribe(uint256 durationDays) external payable { + function subscribe(uint256 tierId, 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); + uint256 requiredWei = pricing.price(tierId, durationDays * 1 days); + if (msg.value < requiredWei) revert InsufficientPayment(); + + uint256 newExpiry = block.timestamp + (durationDays * 1 days); + subscriptions[msg.sender] = Subscription(newExpiry, tierId); - subscriptions[msg.sender].expiry = newExpiry; + emit Subscribed(msg.sender, tierId, newExpiry, msg.value); - emit Subscribed(msg.sender, newExpiry, msg.value); + uint256 excess = msg.value - requiredWei; + if (excess > 0) { + (bool sent,) = msg.sender.call{value: excess}(""); + if (!sent) revert RefundFailed(); + } } /** * @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 Convenience view for frontends — returns wei cost for a tier and duration. + */ + function getPrice(uint256 tierId, uint256 durationDays) external view returns (uint256) { + return pricing.price(tierId, durationDays * 1 days); + } + /** * @notice Owner-only: withdraw collected funds. */ @@ -114,12 +127,11 @@ contract GrailsSubscription is Ownable2Step, ReverseClaimer { } /** - * @notice Owner-only: update the price per day. - * @param _pricePerDay The new price per day in wei. + * @notice Owner-only: swap the pricing contract. */ - function setPrice(uint256 _pricePerDay) external onlyOwner { - uint256 oldPrice = pricePerDay; - pricePerDay = _pricePerDay; - emit PriceUpdated(oldPrice, _pricePerDay); + function setPricing(IGrailsPricing _pricing) external onlyOwner { + address oldPricing = address(pricing); + pricing = _pricing; + emit PricingUpdated(oldPricing, address(_pricing)); } } diff --git a/src/IGrailsPricing.sol b/src/IGrailsPricing.sol new file mode 100644 index 0000000..8fc9703 --- /dev/null +++ b/src/IGrailsPricing.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IGrailsPricing { + /// @notice Returns the price in wei for a given tier and duration. + /// @param tierId The subscription tier identifier. + /// @param duration The subscription duration in seconds. + /// @return weiPrice The price in wei. + function price(uint256 tierId, uint256 duration) external view returns (uint256 weiPrice); +} diff --git a/test/GrailsPricing.t.sol b/test/GrailsPricing.t.sol new file mode 100644 index 0000000..dc19e37 --- /dev/null +++ b/test/GrailsPricing.t.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {GrailsPricing, AggregatorInterface} from "../src/GrailsPricing.sol"; +import {DummyOracle} from "./mocks/DummyOracle.sol"; + +contract GrailsPricingTest is Test { + GrailsPricing public gp; + DummyOracle public oracle; + + address owner; + address user = address(0xBEEF); + + // ETH/USD = $2000 (Chainlink returns 8-decimal price) + int256 constant ORACLE_PRICE = 2000_00000000; + + // Tier 1: ~$10/month → $10 / (30 * 86400) * 1e18 ≈ 3_858_024_691_358 attoUSD/sec + uint256 constant TIER1_RATE = 3_858_024_691_358; + + // Tier 2: ~$30/month + uint256 constant TIER2_RATE = 11_574_074_074_074; + + function setUp() public { + owner = address(this); + oracle = new DummyOracle(ORACLE_PRICE); + gp = new GrailsPricing(AggregatorInterface(address(oracle)), owner); + gp.setTierPrice(1, TIER1_RATE); + gp.setTierPrice(2, TIER2_RATE); + } + + // ----------------------------------------------------------------------- + // setTierPrice + // ----------------------------------------------------------------------- + + function test_setTierPrice_setsPrice() public view { + assertEq(gp.tierPrices(1), TIER1_RATE); + assertEq(gp.tierPrices(2), TIER2_RATE); + } + + function test_setTierPrice_emitsEvent() public { + vm.expectEmit(true, false, false, true); + emit GrailsPricing.TierPriceUpdated(3, 0, 1000); + gp.setTierPrice(3, 1000); + } + + function test_setTierPrice_updateExisting() public { + uint256 newRate = 5_000_000_000_000; + vm.expectEmit(true, false, false, true); + emit GrailsPricing.TierPriceUpdated(1, TIER1_RATE, newRate); + gp.setTierPrice(1, newRate); + assertEq(gp.tierPrices(1), newRate); + } + + function test_setTierPrice_removeTier() public { + gp.setTierPrice(1, 0); + assertEq(gp.tierPrices(1), 0); + } + + function test_setTierPrice_revertsForNonOwner() public { + vm.prank(user); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + gp.setTierPrice(1, 1000); + } + + // ----------------------------------------------------------------------- + // price + // ----------------------------------------------------------------------- + + function test_price_basicConversion() public view { + // 30 days at tier 1: TIER1_RATE * 30 * 86400 attoUSD, converted to wei + uint256 durationSec = 30 days; + uint256 attoUSDTotal = TIER1_RATE * durationSec; + uint256 expectedWei = (attoUSDTotal * 1e8) / uint256(ORACLE_PRICE); + + uint256 weiPrice = gp.price(1, durationSec); + assertEq(weiPrice, expectedWei); + } + + function test_price_tier2() public view { + uint256 durationSec = 30 days; + uint256 attoUSDTotal = TIER2_RATE * durationSec; + uint256 expectedWei = (attoUSDTotal * 1e8) / uint256(ORACLE_PRICE); + + assertEq(gp.price(2, durationSec), expectedWei); + } + + function test_price_revertsOnUnconfiguredTier() public { + vm.expectRevert(GrailsPricing.TierNotConfigured.selector); + gp.price(99, 30 days); + } + + function test_price_changesWithOracle() public { + uint256 durationSec = 30 days; + uint256 priceBefore = gp.price(1, durationSec); + + // Double ETH price → half the wei cost + oracle.set(ORACLE_PRICE * 2); + uint256 priceAfter = gp.price(1, durationSec); + + assertEq(priceAfter, priceBefore / 2); + } + + // ----------------------------------------------------------------------- + // weiToAttoUSD + // ----------------------------------------------------------------------- + + function test_weiToAttoUSD_roundTrip() public view { + uint256 durationSec = 30 days; + uint256 weiPrice = gp.price(1, durationSec); + uint256 attoUSD = gp.weiToAttoUSD(weiPrice); + + // Should approximately equal TIER1_RATE * durationSec (within rounding) + uint256 expected = TIER1_RATE * durationSec; + // Allow 1 attoUSD rounding error per wei + assertApproxEqAbs(attoUSD, expected, uint256(ORACLE_PRICE) / 1e8); + } +} diff --git a/test/GrailsSubscription.t.sol b/test/GrailsSubscription.t.sol index 017084a..75a8754 100644 --- a/test/GrailsSubscription.t.sol +++ b/test/GrailsSubscription.t.sol @@ -5,35 +5,56 @@ 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"; +import {GrailsPricing, AggregatorInterface} from "../src/GrailsPricing.sol"; +import {IGrailsPricing} from "../src/IGrailsPricing.sol"; +import {DummyOracle} from "./mocks/DummyOracle.sol"; // --------------------------------------------------------------------------- -// Fork tests — validates ReverseClaimer integration against mainnet ENS +// Fork tests — validates ReverseClaimer + oracle integration against mainnet // --------------------------------------------------------------------------- contract GrailsSubscriptionForkTest is Test { GrailsSubscription public sub; + GrailsPricing public gp; address constant ENS_REGISTRY = 0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e; - uint256 constant PRICE_PER_DAY = 0.001 ether; + address constant CHAINLINK_ETH_USD = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; + + // ~$10/month + uint256 constant TIER1_RATE = 3_858_024_691_358; + address owner = address(this); function setUp() public { vm.createSelectFork(vm.envString("MAINNET_RPC_URL")); - sub = new GrailsSubscription(PRICE_PER_DAY, ENS(ENS_REGISTRY), owner); + + gp = new GrailsPricing(AggregatorInterface(CHAINLINK_ETH_USD), owner); + gp.setTierPrice(1, TIER1_RATE); + + sub = new GrailsSubscription(IGrailsPricing(address(gp)), 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_setsPricing() public view { + assertEq(address(sub.pricing()), address(gp)); } function test_constructor_deploySucceeds() public view { - // If we got here, the ReverseClaimer constructor didn't revert assertTrue(address(sub) != address(0)); } + + function test_getPrice_returnsSensibleValue() public view { + // 30-day subscription at tier 1 should cost between 0.001 and 1 ETH + // (reasonable range for ~$10/month at typical ETH prices) + uint256 weiCost = sub.getPrice(1, 30); + assertTrue(weiCost > 0.001 ether, "Price too low"); + assertTrue(weiCost < 1 ether, "Price too high"); + } + + receive() external payable {} } // --------------------------------------------------------------------------- @@ -42,8 +63,17 @@ contract GrailsSubscriptionForkTest is Test { contract GrailsSubscriptionTest is Test { GrailsSubscription public sub; + GrailsPricing public gp; + DummyOracle public oracle; + + // ETH/USD = $2000 + int256 constant ORACLE_PRICE = 2000_00000000; + + // ~$10/month tier + uint256 constant TIER1_RATE = 3_858_024_691_358; + // ~$30/month tier + uint256 constant TIER2_RATE = 11_574_074_074_074; - uint256 constant PRICE_PER_DAY = 0.001 ether; address owner; address user = address(0xBEEF); @@ -55,96 +85,163 @@ contract GrailsSubscriptionTest is Test { function setUp() public { owner = address(this); - // Mock ENS.owner(ADDR_REVERSE_NODE) → REVERSE_REGISTRAR + // Mock ENS 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); + // Deploy oracle + pricing + oracle = new DummyOracle(ORACLE_PRICE); + gp = new GrailsPricing(AggregatorInterface(address(oracle)), owner); + gp.setTierPrice(1, TIER1_RATE); + gp.setTierPrice(2, TIER2_RATE); + + // Deploy subscription + sub = new GrailsSubscription(IGrailsPricing(address(gp)), ENS(ENS_REGISTRY), owner); vm.deal(user, 100 ether); } + /// @dev Helper: get the expected wei price for a tier and duration in days + function _expectedWei(uint256 tierId, uint256 durationDays) internal view returns (uint256) { + return gp.price(tierId, durationDays * 1 days); + } + // ----------------------------------------------------------------------- // subscribe // ----------------------------------------------------------------------- function test_subscribe_singleDay() public { + uint256 cost = _expectedWei(1, 1); vm.prank(user); - sub.subscribe{value: PRICE_PER_DAY}(1); + sub.subscribe{value: cost}(1, 1); uint256 expiry = sub.getSubscription(user); assertEq(expiry, block.timestamp + 1 days); } function test_subscribe_multipleDays() public { + uint256 cost = _expectedWei(1, 30); vm.prank(user); - sub.subscribe{value: PRICE_PER_DAY * 30}(30); + sub.subscribe{value: cost}(1, 30); uint256 expiry = sub.getSubscription(user); assertEq(expiry, block.timestamp + 30 days); } - function test_subscribe_extendsFromExpiry() public { + function test_subscribe_replacesExistingFromNow() public { + uint256 cost1 = _expectedWei(1, 10); vm.prank(user); - sub.subscribe{value: PRICE_PER_DAY * 10}(10); - uint256 firstExpiry = sub.getSubscription(user); + sub.subscribe{value: cost1}(1, 10); // Warp to midway — subscription still active vm.warp(block.timestamp + 5 days); + uint256 nowTs = block.timestamp; + uint256 cost2 = _expectedWei(1, 5); vm.prank(user); - sub.subscribe{value: PRICE_PER_DAY * 5}(5); - uint256 secondExpiry = sub.getSubscription(user); + sub.subscribe{value: cost2}(1, 5); - // Should extend from the first expiry, not from now - assertEq(secondExpiry, firstExpiry + 5 days); + // Should start from now, not extend from old expiry + assertEq(sub.getSubscription(user), nowTs + 5 days); } - function test_subscribe_extendsFromNowIfExpired() public { + function test_subscribe_replacesExpiredFromNow() public { + uint256 cost = _expectedWei(1, 1); vm.prank(user); - sub.subscribe{value: PRICE_PER_DAY}(1); + sub.subscribe{value: cost}(1, 1); - // Warp past expiry vm.warp(block.timestamp + 10 days); uint256 nowTs = block.timestamp; + uint256 cost2 = _expectedWei(1, 3); vm.prank(user); - sub.subscribe{value: PRICE_PER_DAY * 3}(3); + sub.subscribe{value: cost2}(1, 3); - uint256 expiry = sub.getSubscription(user); - assertEq(expiry, nowTs + 3 days); + assertEq(sub.getSubscription(user), nowTs + 3 days); } function test_subscribe_emitsEvent() public { + uint256 cost = _expectedWei(1, 1); 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); + vm.expectEmit(true, true, false, true); + emit GrailsSubscription.Subscribed(user, 1, block.timestamp + 1 days, cost); + sub.subscribe{value: cost}(1, 1); } function test_subscribe_revertsOnZeroDays() public { vm.prank(user); vm.expectRevert(GrailsSubscription.MinimumOneDayRequired.selector); - sub.subscribe{value: PRICE_PER_DAY}(0); + sub.subscribe{value: 1 ether}(1, 0); } function test_subscribe_revertsOnInsufficientPayment() public { + uint256 cost = _expectedWei(1, 1); vm.prank(user); vm.expectRevert(GrailsSubscription.InsufficientPayment.selector); - sub.subscribe{value: PRICE_PER_DAY - 1}(1); + sub.subscribe{value: cost - 1}(1, 1); } - function test_subscribe_acceptsOverpayment() public { - uint256 balBefore = address(sub).balance; + function test_subscribe_refundsExcess() public { + uint256 cost = _expectedWei(1, 1); + uint256 overpay = cost * 10; + uint256 balBefore = user.balance; vm.prank(user); - sub.subscribe{value: PRICE_PER_DAY * 10}(1); + sub.subscribe{value: overpay}(1, 1); - uint256 expiry = sub.getSubscription(user); - assertEq(expiry, block.timestamp + 1 days); - assertEq(address(sub).balance, balBefore + PRICE_PER_DAY * 10); + // User should get refunded: overpay - cost + assertEq(user.balance, balBefore - cost); + // Contract should only hold the exact cost + assertEq(address(sub).balance, cost); + } + + function test_subscribe_revertsOnUnconfiguredTier() public { + vm.prank(user); + vm.expectRevert(GrailsPricing.TierNotConfigured.selector); + sub.subscribe{value: 1 ether}(99, 1); + } + + // ----------------------------------------------------------------------- + // Tier switching + // ----------------------------------------------------------------------- + + function test_subscribe_switchTier() public { + uint256 cost1 = _expectedWei(1, 30); + vm.prank(user); + sub.subscribe{value: cost1}(1, 30); + + (, uint256 tier1) = sub.subscriptions(user); + assertEq(tier1, 1); + + vm.warp(block.timestamp + 5 days); + uint256 nowTs = block.timestamp; + + uint256 cost2 = _expectedWei(2, 30); + vm.prank(user); + sub.subscribe{value: cost2}(2, 30); + + (uint256 expiry, uint256 tier2) = sub.subscriptions(user); + assertEq(tier2, 2); + assertEq(expiry, nowTs + 30 days); + } + + function test_subscribe_differentTiersDifferentPrices() public { + uint256 cost1 = _expectedWei(1, 30); + uint256 cost2 = _expectedWei(2, 30); + + // Tier 2 is ~3x tier 1 + assertTrue(cost2 > cost1 * 2); + assertTrue(cost2 < cost1 * 4); + } + + // ----------------------------------------------------------------------- + // getPrice + // ----------------------------------------------------------------------- + + function test_getPrice_matchesPricingContract() public view { + uint256 fromSub = sub.getPrice(1, 30); + uint256 fromPricing = gp.price(1, 30 days); + assertEq(fromSub, fromPricing); } // ----------------------------------------------------------------------- @@ -156,8 +253,9 @@ contract GrailsSubscriptionTest is Test { } function test_getSubscription_returnsExpiryAfterSubscribe() public { + uint256 cost = _expectedWei(1, 7); vm.prank(user); - sub.subscribe{value: PRICE_PER_DAY * 7}(7); + sub.subscribe{value: cost}(1, 7); assertEq(sub.getSubscription(user), block.timestamp + 7 days); } @@ -166,8 +264,9 @@ contract GrailsSubscriptionTest is Test { // ----------------------------------------------------------------------- function test_withdraw_sendsBalanceToOwner() public { + uint256 cost = _expectedWei(1, 1); vm.prank(user); - sub.subscribe{value: 1 ether}(1); + sub.subscribe{value: cost}(1, 1); uint256 contractBal = address(sub).balance; uint256 ownerBefore = owner.balance; @@ -177,8 +276,9 @@ contract GrailsSubscriptionTest is Test { } function test_withdraw_emitsEvent() public { + uint256 cost = _expectedWei(1, 1); vm.prank(user); - sub.subscribe{value: 1 ether}(1); + sub.subscribe{value: cost}(1, 1); uint256 contractBal = address(sub).balance; vm.expectEmit(true, false, false, true); @@ -187,7 +287,6 @@ contract GrailsSubscriptionTest is Test { } function test_withdraw_revertsOnNoBalance() public { - // Drain any pre-existing balance from deployment if (address(sub).balance > 0) { sub.withdraw(); } @@ -196,8 +295,9 @@ contract GrailsSubscriptionTest is Test { } function test_withdraw_revertsForNonOwner() public { + uint256 cost = _expectedWei(1, 1); vm.prank(user); - sub.subscribe{value: 1 ether}(1); + sub.subscribe{value: cost}(1, 1); vm.prank(user); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); @@ -205,15 +305,15 @@ contract GrailsSubscriptionTest is Test { } 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)); + GrailsSubscription sub2 = new GrailsSubscription(IGrailsPricing(address(gp)), ENS(ENS_REGISTRY), address(noReceive)); + uint256 cost = _expectedWei(1, 1); vm.prank(user); - sub2.subscribe{value: 1 ether}(1); + sub2.subscribe{value: cost}(1, 1); vm.prank(address(noReceive)); vm.expectRevert(GrailsSubscription.WithdrawFailed.selector); @@ -221,26 +321,25 @@ contract GrailsSubscriptionTest is Test { } // ----------------------------------------------------------------------- - // setPrice + // setPricing // ----------------------------------------------------------------------- - function test_setPrice_updatesPrice() public { - uint256 newPrice = 0.002 ether; - sub.setPrice(newPrice); - assertEq(sub.pricePerDay(), newPrice); - } + function test_setPricing_swapsPricingContract() public { + DummyOracle oracle2 = new DummyOracle(3000_00000000); + GrailsPricing gp2 = new GrailsPricing(AggregatorInterface(address(oracle2)), owner); + gp2.setTierPrice(1, TIER1_RATE); - 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); + emit GrailsSubscription.PricingUpdated(address(gp), address(gp2)); + sub.setPricing(IGrailsPricing(address(gp2))); + + assertEq(address(sub.pricing()), address(gp2)); } - function test_setPrice_revertsForNonOwner() public { + function test_setPricing_revertsForNonOwner() public { vm.prank(user); vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); - sub.setPrice(0.002 ether); + sub.setPricing(IGrailsPricing(address(0x1))); } // ----------------------------------------------------------------------- diff --git a/test/mocks/DummyOracle.sol b/test/mocks/DummyOracle.sol new file mode 100644 index 0000000..9a851fe --- /dev/null +++ b/test/mocks/DummyOracle.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract DummyOracle { + int256 private _value; + + constructor(int256 value_) { + _value = value_; + } + + function latestAnswer() external view returns (int256) { + return _value; + } + + function set(int256 value_) external { + _value = value_; + } +}