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
24 changes: 20 additions & 4 deletions script/DeploySubscription.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
61 changes: 61 additions & 0 deletions src/GrailsPricing.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
78 changes: 45 additions & 33 deletions src/GrailsSubscription.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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));
}
}
10 changes: 10 additions & 0 deletions src/IGrailsPricing.sol
Original file line number Diff line number Diff line change
@@ -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);
}
119 changes: 119 additions & 0 deletions test/GrailsPricing.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading