Daisy Contracts
Overview
Subscriptions
A subscription is composed of:
- subscriber: address from which tokens will be transferred from.
- token: address of the ERC 20 token that the subscription is denominated in.
- price: number of tokens to transfer in each payment.
- periodUnit: "DAY", "MONTH", or "YEAR".
- periods: number of period units between payments.
- maxExecutions: max number of payments that can be made before the subscriber must manually renew their subscription. If this value is 0, there is no execution limit.
- planIdHash: the hash of the id of the plan the subscription belongs to.
- credits: amount of tokens to substract from future payments. On each payment, these credits get consumed until they get to 0.
A subscription can be in one of four states:
- Active: Once it has been executed and the next payment is in the future. A subscription is first executed upon its creation.
- Active and Cancelled: When it is cancelled before reaching the next payment timestamp. It will remain active until the end of the period.
- Cancelled: when it has been cancelled and the last billed period has ended.
- Deleted: the subscription data will be deleted upon execution if the subscription can't be executed anymore.
Plans
A plan is just a way to group multiple subscriptions, and allows the publisher to cancel a group of subscriptions at once. A plan is identified in the contract by a bytes32. When a plan based subscription is created, it is necessary to specify the plan's identifier, and the creation only goes through if a valid authorization signature is provided.
The contract provides a function to delete a plan by providing the plan on chain id. All subscriptions with that plan get invalidated (they get deleted on the next execution).
Contracts
SubscriptionManager.sol
Handles the creation, execution, and cancellation of subscriptions.
It also provides methods for enumerating subscriptions and subscription by subscriber. This implementation was adapted from OpenZeppelin's EIP721Enumerable
implementation.
IndexedArrayLib.sol
Library that provides an indexed array of bytes32. It allows us to remove items from the array by specifying the item itself that we want to remove.
Delegated.sol
Allows child contracts to support delegated execution without changing their external APIs. See Delegation section.
Time
To support different time units (days, months and years) we use BokkyPooBah's Date Time Library.
Delegation
In order to support delegated execution through the use of EIP712 signatures, the SubscriptionManager
inherits from Delegated
. The key feature of the Delegated
contract is that child contracts don't need to polute their APIs with parameters related to delegated execution.
The Delegated
contract exposes the external delegate
function which receives the following parameters:
-
bytes data
: ABI encoded data to be used for the delegated call. -
bytes32 nonce
: Random value for replay protection. If the function to call is idempotent, this value is optional. -
uint256 signatureExpiresAt
: Timestamp in which the signature stops being valid. -
bytes signature
: The signature for the EIP712 type.
For example, for the setWallet(address _wallet)
function in the SubscriptionManager
, the EIP712 type is SetWallet(address wallet,bytes32 nonce,uint256 signatureExpiresAt)
. This function can only be called directly by the owner
account or by using the delegate
function with the following parameters (pseudo code):
// Data to call the setWallet function normally
const data = abiEncode(setWalletSelector, _wallet);
// Random value for replay protection
const nonce = randomBytes32();
// Signature expiration
const signatureExpiresAt = now + signatureDuration;
// Owner's signature
const signature = signTypedData({
account: owner,
primaryType: "SetWallet", // The EIP712 type
domain: {
verifyingContract: manager.address
},
message: {
wallet: _wallet,
nonce,
signatureExpiresAt,
}
});
// Call setWallet through the delegate function
await manager.delegate(
data,
nonce,
signatureExpiresAt,
signature,
{
from: anyone
}
);
delegate function and signature checks
The delegate
function performs the following steps:
- Reentrancy guard (
delegate
can only be called once in the same transaction). - Check that the signature hasn't expired (
now < signatureExpiresAt
). - Store the
nonce
,signatureExpiresAt
andsignature
parameters. - Call itself with the ABI encoded data (
address(this).call(data)
); - Revert if the call failed.
- Delete the stored parameters.
As the delegation data exists in the storage while the call is being executed, the called function in the child contract can use the internal _delegatedCheck(bytes32 typeHash, bytes data, bool usesNonce)
function to get the address of the caller. Internally, the _delegatedCheck
checks if msg.sender == this
and performs the signature validation using the provided type hash. If msg.sender != this
, it just returns the msg.sender
. It also stores the hashed data so it can't be used again for another delegated call.
Subscription Flow with Delegated Execution
The only function where delegated execution checks are done manually (doesn't use the Delegated
functionality) is the create
function, used to create subscriptions. The flow for creating a subscription works as follows:
- Subscriber calls the
approve
function in the token in which the subscription is denominated in to allow the manager contract to manipulate her funds. - Using EIP 712, the subscriber signs the creation of a subscription (using the EIP712 type
CreateSubscription
) and sends the data to theauthorizer
server. - The
authorizer
checks that the parameters are OK depending on its own rules, signs the data and sends the data and signatures to the relayer. - The relayer executes the creation by calling the method
create()
, passing the parameters and signatures. - At the beggining of each payment period, anyone can execute the subscription and bill the subscriber.
For subscription services created using Daisy the platform, Daisy will be the authorizer AND the relayer in this flow. We are also working on a system that will allow clients to be the authorizers by using webhooks (Daisy will request an authorizer signature for each subscription creation request).
Off-chain service flow
Checking if the publisher needs to provide the service
- Receive request from subscriber.
- Call
subscriptionManager.nextPaymentTimestamp(subscriptionId)
. - If
now < nextPaymentTimestamp
, answer the request.
Executing subscriptions
To retrieve all subscription ids from the contract (Note: if count is too large, it is possible to obtain the ids in batches):
- Call
subscriptionManager.subscriptionCount()
. - Call
subscriptionManager.subscriptionRange(0, count)
.
To execute a batch of subscriptions:
- Call
subscriptionManager.executeBatch(ids)
. - The following events can be present in the transaction receipt, which can be used by the service to update its state:
-
SubscriptionNotFound(subscriptionId)
: Subscription doesn't exist in the contract. -
SubscriptionNotReady(subscriptionId, nextPayment)
: Next payment timestamp hasn't been reached yet. -
SubscriptionExecuted(subscriptionId, nextPayment)
: The subscription was executed successfuly. -
SubscriptionDeleted(subscriptionId, reason)
: The subscription was deleted from the contract.
-
The reason
parameter in the SubscriptionDeleted
event can correspond to:
- 0 (CANCELLED): The subscription was cancelled before the execution.
- 1 (EXPIRED): The subscription reached its maxExecutions in the previous execution.
- 2 (INVALID_STATE): The state was invalid during the execution. This happens if the subscription's plan was deleted.
- 3 (NOT_ENOUGH_FUNDS): The subscriber address doesn't have or didn't approve enough funds to perform the execution.
If the SubscriptionExecuted
event is present, the following two events can also be present, which represent token allocations (see next section):
PaymentAllocated(subscriptionId, token, recipient, amount)
FeeAllocated(subscriptionId, token, recipient, amount)
Token Allocations and Fees
The owner of a SubscriptionManager
can optionally set a fee
and a feeRecipient
(which can be changed at any point in time). On each execution, the amount of tokens to be paid to the feeRecipient
is calculated as feeAmount = price * fee / MAX_FEE
, and the tokens to be paid to the publisher's wallet
becomes price - feeAmount
. MAX_FEE
is a constant set to 100 * 10^18
, and fee <= MAX_FEE
always.
The SubscriptionManager
uses a pull payment model: on each execution, tokens are always transferred from the subscriber to the manager itself, and the manager allocates tokens to the current feeRecipient
and wallet
by storing their payments in the availableFunds
mapping. When an allocation happens, the FeeAllocated
event or the PaymentAllocated
event will be emitted, specifying the subscriptionId
, token
, recipient
and amount
.
At any time, anyone can call the withdraw(address[] tokens, address[] recipients)
function to transfer all the available tokens to each recipient specified in the parameters.
Key Differences Between Daisy and the Current EIP 1337 Proposal
Subscriptions creation is not standardized
By not defining how the subscription must be created, we can support multiple different use cases (e.g. plan or tier based subscriptions, or subscriptions whose prices are determined by what feautures are turned on/off).
Subscription data is stored in the contract
This makes interoperability with other contracts/services easier, without needing to rely on a centralized data source. Subscription data is removed from the contract if it can't be executed anymore or if the user doesn't have enough funds.
Execution of a payment only requires specifying the subscription id
This allows for easier batching of executions and makes it easier for off-chain services to execute subscriptions.
Delegated execution is not part of the interface
In order to be compatible with other proposals for standardizing delegated execution, the core functions don't need to support delegated execution.
isValidSubscription()
split into isCancelled(subscriptionId)
and nextPaymentTimestamp(subscriptionId)
Instead of having an isValidSubscription()
function that returns a boolean, this implementation provides a nextPaymentTimestamp()
function that returns the unix timestamp at which the next payment can be executed. This returned value is more usable than a boolean. On the other hand, with isCancelled
one can get more granular information about a subscription, improving interoperability.
Testing
To run the tests, clone this repo and run yarn install
to install dependencies.
Run yarn test
to run all tests.
Run yarn coverage
to run tests and get code coverage.