Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for recurring payments #85

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion contracts/addresses.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"Subscriptions": "0x482f58d3513E386036670404b35cB3F2DF67a750"
},
"421613": {
"Subscriptions": "0x29f49a438c747e7Dd1bfe7926b03783E47f9447B"
"Subscriptions": "0x1c4053A0CEBfA529134CB9ddaE3C3D0B144384aA"
}
}
117 changes: 117 additions & 0 deletions contracts/build/Subscriptions.abi
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
"internalType": "uint64",
"name": "_epochSeconds",
"type": "uint64"
},
{
"internalType": "address",
"name": "_recurringPayments",
"type": "address"
}
],
"stateMutability": "nonpayable",
Expand Down Expand Up @@ -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": [
Expand All @@ -67,6 +103,12 @@
"internalType": "uint64",
"name": "epochSeconds",
"type": "uint64"
},
{
"indexed": false,
"internalType": "address",
"name": "recurringPayments",
"type": "address"
}
],
"name": "Init",
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -467,6 +558,19 @@
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "recurringPayments",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
Expand Down Expand Up @@ -510,6 +614,19 @@
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_recurringPayments",
"type": "address"
}
],
"name": "setRecurringPayments",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
Expand Down
103 changes: 98 additions & 5 deletions contracts/contracts/Subscriptions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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`.
Expand Down
3 changes: 2 additions & 1 deletion contracts/tasks/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
)
})
Loading