diff --git a/contracts/addresses.json b/contracts/addresses.json index 183a093..2e5ff57 100644 --- a/contracts/addresses.json +++ b/contracts/addresses.json @@ -3,6 +3,6 @@ "Subscriptions": "0x482f58d3513E386036670404b35cB3F2DF67a750" }, "421613": { - "Subscriptions": "0x29f49a438c747e7Dd1bfe7926b03783E47f9447B" + "Subscriptions": "0x1c4053A0CEBfA529134CB9ddaE3C3D0B144384aA" } } diff --git a/contracts/build/Subscriptions.abi b/contracts/build/Subscriptions.abi index 639fc5d..1f520fe 100644 --- a/contracts/build/Subscriptions.abi +++ b/contracts/build/Subscriptions.abi @@ -10,6 +10,11 @@ "internalType": "uint64", "name": "_epochSeconds", "type": "uint64" + }, + { + "internalType": "address", + "name": "_recurringPayments", + "type": "address" } ], "stateMutability": "nonpayable", @@ -53,6 +58,37 @@ "name": "AuthorizedSignerRemoved", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "oldEnd", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "newEnd", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "Extend", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -67,6 +103,12 @@ "internalType": "uint64", "name": "epochSeconds", "type": "uint64" + }, + { + "indexed": false, + "internalType": "address", + "name": "recurringPayments", + "type": "address" } ], "name": "Init", @@ -128,6 +170,19 @@ "name": "PendingSubscriptionCreated", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "recurringPayments", + "type": "address" + } + ], + "name": "RecurringPaymentsUpdated", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -228,6 +283,24 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "addTo", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -309,6 +382,24 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "create", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [], "name": "currentEpoch", @@ -467,6 +558,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "recurringPayments", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -510,6 +614,19 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "_recurringPayments", + "type": "address" + } + ], + "name": "setRecurringPayments", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { diff --git a/contracts/contracts/Subscriptions.sol b/contracts/contracts/Subscriptions.sol index acd95e7..70ffc82 100644 --- a/contracts/contracts/Subscriptions.sol +++ b/contracts/contracts/Subscriptions.sol @@ -45,9 +45,11 @@ contract Subscriptions is Ownable { mapping(address => mapping(address => bool)) public authorizedSigners; /// @notice Mapping of user to pending subscription. mapping(address => Subscription) public pendingSubscriptions; + /// @notice Address of the recurring payments contract. + address public recurringPayments; // -- Events -- - event Init(address token, uint64 epochSeconds); + event Init(address token, uint64 epochSeconds, address recurringPayments); event Subscribe( address indexed user, uint256 indexed epoch, @@ -56,6 +58,12 @@ contract Subscriptions is Ownable { uint128 rate ); event Unsubscribe(address indexed user, uint256 indexed epoch); + event Extend( + address indexed user, + uint64 oldEnd, + uint64 newEnd, + uint256 amount + ); event PendingSubscriptionCreated( address indexed user, uint256 indexed epoch, @@ -77,25 +85,43 @@ contract Subscriptions is Ownable { uint256 indexed startEpoch, uint256 indexed endEpoch ); + event RecurringPaymentsUpdated(address indexed recurringPayments); + + modifier onlyRecurringPayments() { + require( + msg.sender == recurringPayments, + 'caller is not the recurring payments contract' + ); + _; + } // -- Functions -- /// @param _token The ERC-20 token held by this contract /// @param _epochSeconds The Duration of each epoch in seconds. /// @dev Contract ownership must be transfered to the gateway after deployment. - constructor(address _token, uint64 _epochSeconds) { + constructor( + address _token, + uint64 _epochSeconds, + address _recurringPayments + ) { token = IERC20(_token); epochSeconds = _epochSeconds; uncollectedEpoch = block.timestamp / _epochSeconds; + _setRecurringPayments(_recurringPayments); - emit Init(_token, _epochSeconds); + emit Init(_token, _epochSeconds, _recurringPayments); } /// @notice Create a subscription for the sender. /// Will override an active subscription if one exists. + /// @dev Setting a start time in the past will clamp it to the current block timestamp. + /// This protects users from paying for a subscription during a period of time they were + /// not able to use it. /// @param start Start timestamp for the new subscription. /// @param end End timestamp for the new subscription. /// @param rate Rate for the new subscription. function subscribe(uint64 start, uint64 end, uint128 rate) public { + start = uint64(Math.max(start, block.timestamp)); _subscribe(msg.sender, start, end, rate); } @@ -143,6 +169,9 @@ contract Subscriptions is Ownable { /// @notice Creates a subscription template without requiring funds. Expected to be used with /// `fulfil`. + /// @dev Setting a start time in the past will clamp it to the current block timestamp when fulfilled. + /// This protects users from paying for a subscription during a period of time they were + /// not able to use it. /// @param start Start timestamp for the pending subscription. /// @param end End timestamp for the pending subscription. /// @param rate Rate for the pending subscription. @@ -181,7 +210,7 @@ contract Subscriptions is Ownable { ); // Create the subscription using the pending subscription details - _subscribe(_to, pendingSub.start, pendingSub.end, pendingSub.rate); + _subscribe(_to, subStart, pendingSub.end, pendingSub.rate); delete pendingSubscriptions[_to]; // Send any extra tokens back to the user @@ -218,6 +247,58 @@ contract Subscriptions is Ownable { emit AuthorizedSignerRemoved(user, _signer); } + /// @notice Create a subscription for a user. + /// Will override an active subscription if one exists. + /// @dev The function's name and signature, `create`, are used to comply with the `IPayment` + /// interface for recurring payments. + /// @dev Note that this function does not protect user against a start time in the past. + /// @param user Subscription owner. + /// @param data Encoded start, end and rate for the new subscription. + function create( + address user, + bytes calldata data + ) public onlyRecurringPayments { + (uint64 start, uint64 end, uint128 rate) = abi.decode( + data, + (uint64, uint64, uint128) + ); + _subscribe(user, start, end, rate); + } + + /// @notice Extends a subscription's end time. + /// The time the subscription will be extended by is calculated as `amount / rate`, where + /// `rate` is the existing subscription rate and `amount` is the new amount of tokens provided. + /// If the subscription was expired the extension will start from the current block timestamp. + /// @dev The function's name, `addTo`, is used to comply with the `IPayment` interface for recurring payments. + /// @param user Subscription owner. + /// @param amount Total amount to be added to the subscription. + function addTo(address user, uint256 amount) public { + require(amount > 0, 'amount must be positive'); + require(user != address(0), 'user is null'); + + Subscription memory sub = subscriptions[user]; + require(sub.start != 0, 'no subscription found'); + require(sub.rate != 0, 'cannot extend a zero rate subscription'); + require(amount % sub.rate == 0, "amount not multiple of rate"); + + uint64 newEnd = uint64(Math.max(sub.end, block.timestamp)) + + uint64(amount / sub.rate); + + _setEpochs(sub.start, sub.end, -int128(sub.rate)); + _setEpochs(sub.start, newEnd, int128(sub.rate)); + + subscriptions[user].end = newEnd; + + bool success = token.transferFrom(msg.sender, address(this), amount); + require(success, 'IERC20 token transfer failed'); + + emit Extend(user, sub.end, newEnd, amount); + } + + function setRecurringPayments(address _recurringPayments) public onlyOwner { + _setRecurringPayments(_recurringPayments); + } + /// @param _user Subscription owner. /// @param _signer Address authorized to sign messages on the owners behalf. /// @return isAuthorized True if the given signer is set as an authorized signer for the given @@ -304,8 +385,21 @@ contract Subscriptions is Ownable { return unlocked(sub.start, sub.end, sub.rate); } + /// @notice Sets the recurring payments contract address. + /// @param _recurringPayments Address of the recurring payments contract. + function _setRecurringPayments(address _recurringPayments) private { + require( + _recurringPayments != address(0), + 'recurringPayments cannot be zero address' + ); + recurringPayments = _recurringPayments; + emit RecurringPaymentsUpdated(_recurringPayments); + } + /// @notice Create a subscription for a user /// Will override an active subscription if one exists. + /// @dev Note that setting a start time in the past is allowed. If this behavior is not desired, + /// the caller can clamp the start time to the current block timestamp. /// @param user Owner for the new subscription. /// @param start Start timestamp for the new subscription. /// @param end End timestamp for the new subscription. @@ -318,7 +412,6 @@ contract Subscriptions is Ownable { ) private { require(user != address(0), 'user is null'); require(user != address(this), 'invalid user'); - start = uint64(Math.max(start, block.timestamp)); require(start < end, 'start must be less than end'); // This avoids unexpected behavior from truncation, especially in `locked` and `unlocked`. diff --git a/contracts/tasks/deploy.ts b/contracts/tasks/deploy.ts index 23bd32e..887cbf5 100644 --- a/contracts/tasks/deploy.ts +++ b/contracts/tasks/deploy.ts @@ -6,6 +6,7 @@ import { deploySubscriptions } from '../utils/deploy' task('deploy', 'Deploy the subscription contract (use L2 network!)') .addParam('token', 'Address of the ERC20 token') + .addParam('recurringPayments', 'Address of the recurring payments contract') .addOptionalParam('epochSeconds', 'Epoch length in seconds.', 3, types.int) .setAction(async (taskArgs, hre: HardhatRuntimeEnvironment) => { const accounts = await hre.ethers.getSigners() @@ -16,7 +17,7 @@ task('deploy', 'Deploy the subscription contract (use L2 network!)') console.log('Deploying subscriptions contract with the account:', accounts[0].address); await deploySubscriptions( - [taskArgs.token, taskArgs.epochSeconds], + [taskArgs.token, taskArgs.epochSeconds, taskArgs.recurringPayments], accounts[0] as unknown as Wallet, ) }) \ No newline at end of file diff --git a/contracts/test/contract.test.ts b/contracts/test/contract.test.ts index 55f0094..73f3faa 100644 --- a/contracts/test/contract.test.ts +++ b/contracts/test/contract.test.ts @@ -3,7 +3,7 @@ import {time} from '@nomicfoundation/hardhat-network-helpers'; import {expect} from 'chai'; import * as deployment from '../utils/deploy'; -import {getAccounts, Account, toGRT} from '../utils/helpers'; +import {getAccounts, Account, toGRT, provider} from '../utils/helpers'; import {Subscriptions} from '../types/contracts/Subscriptions'; import {StableToken} from '../types/contracts/test/StableMock.sol/StableToken'; @@ -26,6 +26,8 @@ describe('Subscriptions contract', () => { let subscriber1: Account; let subscriber2: Account; let subscriberNoFunds: Account; + let recurringPayments: Account; + let newRecurringPayments: Account; // Contracts let subscriptions: Subscriptions; @@ -36,8 +38,14 @@ describe('Subscriptions contract', () => { before(async function () { // eslint-disable-next-line @typescript-eslint/no-extra-semi - [deployer, subscriber1, subscriber2, subscriberNoFunds] = - await getAccounts(); + [ + deployer, + subscriber1, + subscriber2, + subscriberNoFunds, + recurringPayments, + newRecurringPayments, + ] = await getAccounts(); setAutoMine(true); }); @@ -49,7 +57,11 @@ describe('Subscriptions contract', () => { false ); subscriptions = await deployment.deploySubscriptions( - [stableToken.address, subscriptionsEpochSeconds], + [ + stableToken.address, + subscriptionsEpochSeconds, + recurringPayments.address, + ], deployer.signer, false ); @@ -61,6 +73,9 @@ describe('Subscriptions contract', () => { await stableToken .connect(deployer.signer) .transfer(subscriber2.address, oneMillion); + await stableToken + .connect(deployer.signer) + .transfer(recurringPayments.address, oneMillion); // Approve the subscription contract to transfer tokens from the user await stableToken @@ -69,6 +84,9 @@ describe('Subscriptions contract', () => { await stableToken .connect(subscriber2.signer) .approve(subscriptions.address, oneMillion); + await stableToken + .connect(recurringPayments.signer) + .approve(subscriptions.address, oneMillion); }); describe('constructor', function () { @@ -85,6 +103,41 @@ describe('Subscriptions contract', () => { subscriptionsEpochSeconds ); }); + + it('should set the recurring payments address', async function () { + expect(await subscriptions.recurringPayments()).to.eq( + recurringPayments.address + ); + }); + }); + + describe('setters', function () { + it('should set the recurring payments address', async function () { + const tx = subscriptions.setRecurringPayments( + newRecurringPayments.address + ); + + await expect(tx) + .to.emit(subscriptions, 'RecurringPaymentsUpdated') + .withArgs(newRecurringPayments.address); + expect(await subscriptions.recurringPayments()).to.eq( + newRecurringPayments.address + ); + }); + + it('should prevent unauthorized users from changing the recurring payments address', async function () { + const tx = subscriptions + .connect(subscriber1.signer) + .setRecurringPayments(newRecurringPayments.address); + await expect(tx).revertedWith('Ownable: caller is not the owner'); + }); + + it('should prevent setting the recurring payments address to zero address', async function () { + const tx = subscriptions.setRecurringPayments( + ethers.constants.AddressZero + ); + await expect(tx).revertedWith('recurringPayments cannot be zero address'); + }); }); describe('transferOwnership', function () { @@ -411,6 +464,7 @@ describe('Subscriptions contract', () => { ).revertedWith('end too large'); }); }); + describe('unsubscribe', function () { it('should allow user to cancel an active subscription', async function () { const now = await latestBlockTimestamp(); @@ -509,6 +563,194 @@ describe('Subscriptions contract', () => { }); }); + describe('create', function () { + it('should create a subscription for a user', async function () { + const now = await latestBlockTimestamp(); + const start = now.sub(10); + const end = now.add(510); + const rate = BigNumber.from(5); + const data = ethers.utils.defaultAbiCoder.encode( + ['uint64', 'uint64', 'uint128'], + [start, end, rate] + ); + + await create( + stableToken, + subscriptions, + recurringPayments, + subscriber1.address, + data + ); + }); + + it('should prevent unauthorized users from calling create', async function () { + const now = await latestBlockTimestamp(); + const start = now.sub(10); + const end = now.add(510); + const rate = BigNumber.from(5); + const data = ethers.utils.defaultAbiCoder.encode( + ['uint64', 'uint64', 'uint128'], + [start, end, rate] + ); + + const tx = subscriptions + .connect(subscriber1.signer) + .create(subscriber1.address, data); + await expect(tx).revertedWith( + 'caller is not the recurring payments contract' + ); + }); + }); + + describe('extend', function () { + it('should revert if the amount to extend is zero', async function () { + const tx = subscriptions.addTo( + ethers.constants.AddressZero, + BigNumber.from(0) + ); + await expect(tx).revertedWith('amount must be positive'); + }); + + it('should revert if user is the zero address', async function () { + const tx = subscriptions.addTo( + ethers.constants.AddressZero, + BigNumber.from(1000) + ); + await expect(tx).revertedWith('user is null'); + }); + + it('should revert when extending a subscription that does not exist', async function () { + const tx = subscriptions.addTo(subscriber2.address, BigNumber.from(10)); + await expect(tx).revertedWith('no subscription found'); + }); + + it('should allow extending an expired subscription', async function () { + const now = await latestBlockTimestamp(); + const start = now.add(500); + const end = now.add(1000); + const rate = BigNumber.from(5); + const amountToExtend = BigNumber.from(2000); // newEnd: end + 2000/5 = 1000 + 400 = 1400 + + const subscribeBlockNumber = await subscribe( + stableToken, + subscriptions, + subscriber1, + start, + end, + rate + ); + + // mine past the newEnd + await mineNBlocks(1500); + + await addToSubscription( + stableToken, + subscriptions, + recurringPayments, + subscriber1.address, + amountToExtend, + subscribeBlockNumber + ); + }); + + it('should not allow extending an active subscription if amount is not multiple of rate', async function () { + const now = await latestBlockTimestamp(); + const start = now; + const end = now.add(1000); + const rate = BigNumber.from(7); + const amountToExtend = BigNumber.from(2000); // newEnd: end + 2000/7 = 1000 + 286 = 1286 + + // mine past the start of the subscription + await mineNBlocks(150); + + const tx = subscriptions.addTo(subscriber1.address, amountToExtend); + await expect(tx).revertedWith('amount not multiple of rate'); + }); + + it('should allow extending an active subscription', async function () { + const now = await latestBlockTimestamp(); + const start = now; + const end = now.add(1000); + const rate = BigNumber.from(5); + const amountToExtend = BigNumber.from(2000); // newEnd: end + 2000/5 = 1000 + 400 = 1400 + + const subscribeBlockNumber = await subscribe( + stableToken, + subscriptions, + subscriber1, + start, + end, + rate + ); + + // mine past the start of the subscription + await mineNBlocks(150); + + await addToSubscription( + stableToken, + subscriptions, + recurringPayments, + subscriber1.address, + amountToExtend, + subscribeBlockNumber + ); + }); + + it('should allow extending an expired subscription', async function () { + const now = await latestBlockTimestamp(); + const start = now; + const end = now.add(1000); + const rate = BigNumber.from(5); + const amountToExtend = BigNumber.from(2000); // newEnd: end + 2000/5 = 1000 + 400 = 1400 + + const subscribeBlockNumber = await subscribe( + stableToken, + subscriptions, + subscriber1, + start, + end, + rate + ); + + // mine past the end of the subscription, but not past the new end + await mineNBlocks(1100); + + await addToSubscription( + stableToken, + subscriptions, + recurringPayments, + subscriber1.address, + amountToExtend, + subscribeBlockNumber + ); + }); + + it('should allow extending a one epoch subscription', async function () { + const now = await latestBlockTimestamp(); + const start = now; + const end = now.add(5); + const rate = BigNumber.from(5); + const amountToExtend = BigNumber.from(2000); + + const subscribeBlockNumber = await subscribe( + stableToken, + subscriptions, + subscriber1, + start, + end, + rate + ); + await addToSubscription( + stableToken, + subscriptions, + recurringPayments, + subscriber1.address, + amountToExtend, + subscribeBlockNumber + ); + }); + }); + describe.skip('collect', function () {}); describe('setPendingSubscription', function () { @@ -887,6 +1129,75 @@ async function unsubscribe( } } +async function create( + stableToken: StableToken, + subscriptions: Subscriptions, + signer: Account, + userAddress: string, + data: string +) { + const decoded = ethers.utils.defaultAbiCoder.decode( + ['uint64', 'uint64', 'uint128'], + data + ); + const start = decoded[0]; + const end = decoded[1]; + const rate = decoded[2]; + + const amount = rate.mul(end.sub(start)); + const epochSeconds = await subscriptions.epochSeconds(); + + // Before state + const beforeBlock = await latestBlockNumber(); + const beforeBalance = await stableToken.balanceOf(signer.address); + const beforeContractBalance = await stableToken.balanceOf( + subscriptions.address + ); + + // * Tx + const tx = subscriptions.connect(signer.signer).create(userAddress, data); + await tx; + const txTimestamp = await time.latest(); + const txEpoch = BigNumber.from(txTimestamp).div(epochSeconds).add(1); + + // * Check events + await expect(tx) + .to.emit(subscriptions, 'Subscribe') + .withArgs(userAddress, txEpoch, start, end, rate); + + // * Check balances + const afterBalance = await stableToken.balanceOf(signer.address); + const afterContractBalance = await stableToken.balanceOf( + subscriptions.address + ); + + // Actual amount deposited might be less than intended if subStart < block.number + const amountDeposited = beforeBalance.sub(afterBalance); + expect(amountDeposited).to.lte(amount); + expect(afterContractBalance).to.eq( + beforeContractBalance.add(amountDeposited) + ); + + // * Check state + const sub = await subscriptions.subscriptions(userAddress); + expect(sub.start).to.eq(start); + expect(sub.end).to.eq(end); + expect(sub.rate).to.eq(rate); + + const afterBlock = await latestBlockNumber(); + + await testEpochDetails( + subscriptions, + start, + end, + rate, + beforeBlock, + afterBlock + ); + + return (await tx).blockNumber!; +} + async function testEpochDetails( subscriptions: Subscriptions, start: BigNumber, @@ -1093,3 +1404,58 @@ async function fulfil( return (await tx).blockNumber!; } + +async function addToSubscription( + stableToken: StableToken, + subscriptions: Subscriptions, + signer: Account, + user: string, + amount: BigNumber, + subscribeBlockNumber: number | undefined +) { + // Before state + const beforeSub = await subscriptions.subscriptions(user); + const beforeBalance = await stableToken.balanceOf(signer.address); + const beforeContractBalance = await stableToken.balanceOf( + subscriptions.address + ); + + // * Tx + const tx = subscriptions.connect(signer.signer).addTo(user, amount); + const receipt = await (await tx).wait(); + const txTimestamp = ( + await subscriptions.provider.getBlock(receipt.blockNumber!) + ).timestamp; + + // * Check events + const newEnd = BigNumber.from( + Math.max(beforeSub.end.toNumber(), txTimestamp) + ).add(Math.ceil(amount.toNumber() / beforeSub.rate.toNumber())); + await expect(tx) + .to.emit(subscriptions, 'Extend') + .withArgs(user, beforeSub.end, newEnd, amount); + + // * Check balances + const afterBalance = await stableToken.balanceOf(signer.address); + const afterContractBalance = await stableToken.balanceOf( + subscriptions.address + ); + expect(afterBalance).to.eq(beforeBalance.sub(amount)); + expect(afterContractBalance).to.eq(beforeContractBalance.add(amount)); + + // * Check state + const afterSub = await subscriptions.subscriptions(user); + expect(afterSub.start).to.eq(beforeSub.start); + expect(afterSub.end).to.eq(newEnd); + expect(afterSub.rate).to.eq(beforeSub.rate); + + // Sub + extend -> Epoch changes should match those of a sub [start, newEnd) + await testEpochDetails( + subscriptions, + beforeSub.start, + newEnd, + beforeSub.rate, + BigNumber.from(subscribeBlockNumber! - 1), + BigNumber.from((await tx).blockNumber!) + ); +}