Skip to content

Commit

Permalink
Problem (fix #294): no spec for subscriptions module (#323)
Browse files Browse the repository at this point in the history
Solution:
- add one
- use crontab syntax to specify intervals
  • Loading branch information
yihuang authored Jan 22, 2021
1 parent 78ba8a9 commit caf9f3b
Show file tree
Hide file tree
Showing 7 changed files with 286 additions and 0 deletions.
26 changes: 26 additions & 0 deletions x/subscription/spec/01_concepts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!--
order: 1
-->

# Concepts

## Subscription

- Subscription plan owners create/stop plans on Chain, the plan prices are defined for specified token denominations, subscribers should do necessary token conversions in other ways, e.g. exchange.
- Users subscribe/unsubscribe to plan.
- Payments are collected automatically at the start of their specified intervals, the results of collections are written into block events.
- Create plan transaction will consume a gas fee to cover the computational cost of future automatic payment collections.
- If the automatic collection mechanism fails on some subscribers, Chain won't automatically retry later, the corresponding plan owner can either configure their plan to automatically cancel these subscribers or manually retry failed collections later.

### Intervals

Intervals are specified using [crontab syntax](https://crontab.guru/), for example:

- `* * * * *`: At *every minute*
- `0 8 * * *`: At *08**:**00*
- `0 8 1 * *`: At *08**:**00* *on day-of-month 1*
- `0 8 * * 1`: At *08**:**00* *on Monday*

Convenient shortcuts:

- `@monthly`: `0 0 * * *`
87 changes: 87 additions & 0 deletions x/subscription/spec/02_state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<!--
order: 2
-->

# State

The `x/subscription` stores the plans and subscriptions on chain:

```golang
type SubscriptionPlan struct {
id int, // auto-increasing unique identifier
title string,
description string,
owner Address, // beneficial owner of the plan
price Coins, // price to pay for each period, Coins contains both amount and denomination
subscription_duration int, // duration of subscriptions
cron_spec CronSpec, // Configure time intervals, parsed from crontab syntax
cron_tz Timezone, // timezone for cron_spec
}

type Subscription struct {
plan_id int,
subscriber Address,
create_time int, // the block time when subscription was created
// the timestamp of last successful collection,
// default to the current block time round-down against cron spec
// subscribers don't pay for the period it gets created in
last_collected_time int,
payment_failures int, // times of failed payment collection
}

type GenesisState struct {
subscription_plans [SubscriptionPlan];
subscriptions [Subscription];
}

func (s *Subscription) next_collection_time() int {
var plan = get_plan(s.plan_id)
return round_up_time(s.last_collected_time, plan.cron_spec, plan.cron_tz)
}

func (s *Subscription) expiration_time() int {
var plan = get_plan(s.plan_id)
return s.create_time + plan.subscription_duration
}

// Parsed crontab syntax
type CronSpec struct {
minute [CronValue]
hour [CronValue]
day [CronValue]
month [CronValue]
wday [CronValue]
}
CronValue = Any | Range(start, end, step) | Value(v)
```

## Round time

We define two functions to round timestamp to the boundary of periods:

```python
def round_down_time(timestamp, cron_spec, cron_tx):
# return the largest timestamp which matches cron_spec and less or equal than timestamp

def round_up_time(timestamp, cron_spec, cron_tx):
# return the smallest timestamp which matches cron_spec and greater than timestamp

def count_period(begin_time, end_time, cron_spec, cron_tz):
# return the number of periods between two timestamps
count = 0
while True:
begin_time = round_up_time(begin_time, cron_spec, cron_tz)
if begin_time >= end_time:
break
count += 1
return count
```

To keep payment collection idempotent (no duplicated collection in same collection period), we record the last round-down timestamp of collection:

```python
period_index = round_down_time(block_time, cron_spec)
if period_index > last_period_index:
# do the collection
last_period_index = period_index
```
56 changes: 56 additions & 0 deletions x/subscription/spec/03_messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!--
order: 3
-->

# Messages

## MsgCreateSubscriptionPlan

Plan owners create plans, the message signer is the plan owner.

```protobuf
message MsgCreateSubscriptionPlan {
string title;
string description;
Coins price, // amount + denomination, amount + denomination, ...
int32 subscription_duration,
repeated int32 collection_timestamps,
}
```

## MsgStopSubscriptionPlan

Sent by plan owners, also removes all corresponding subscriptions.

```protobuf
message MsgStopSubscriptionPlan {
int32 plan_id,
}
```

## MsgCreateSubscription

The message signer subscribe to the plan.

```protobuf
message MsgCreateSubscription {
int32 plan_id,
}
```

It'll consume some gases for each collection period:

```python
ConsumeGas( count_period(create_time, expiration_time, plan.cron_spec, plan.cron_tz) * GasPerCollection )
```

## MsgStopSubscription

Both the subscriber and the plan owner can stop a subscription.

```protobuf
message MsgStopSubscription {
int32 plan_id,
Address subscriber,
}
```
33 changes: 33 additions & 0 deletions x/subscription/spec/04_events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!--
order: 4
-->

# Events

The `x/subscription` module emits the following events:

## BeginBlocker

| Type | Attribute Key | Attribute Value |
| ----------------- | ------------- | --------------- |
| collect_payment | subscriber | {Address} |
| collect_payment | amount | {Coins} |
| collect_payment | plan_id | {int} |
| stop_subscription | plan_id | {int} |
| stop_subscription | subscriber | {Address} |


## MsgStopSubscription

| Type | Attribute Key | Attribute Value |
| ----------------- | ------------- | --------------- |
| stop_subscription | plan_id | {int} |
| stop_subscription | subscriber | {Address} |

## MsgCreateSubscription

| Type | Attribute Key | Attribute Value |
| ------------------- | ------------- | --------------- |
| create_subscription | plan_id | {int} |
| create_subscription | subscriber | {Address} |

13 changes: 13 additions & 0 deletions x/subscription/spec/05_params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!--
order: 5
-->

# Parameters

The subscription module contains the following parameters:

| Key | Type | Example |
| ------------------- | ---- | ------- |
| GasPerCollection | int | "10000" |
| SubscriptionEnabled | bool | true |
| FailureTolerance | int | 30 |
47 changes: 47 additions & 0 deletions x/subscription/spec/06_begin_block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!--
order: 6
-->

# BeginBlock

## Remove expired subscriptions

```python
for plan_id, subscriber, expiration_time in get_subscriptions_sorted_by_expiration_time():
if block_time >= expiration_time:
stop_subscription(plan_id, subscriber)
else:
break
```

## Collect payments

`x/subscription` module automatically collect payments from subscribers at begin block, and write collection results into events.

```python
for subscription in get_subscriptions_sorted_by_next_collection_time():
plan = get_plan(subscription.plan_id)
if block_time >= subscription.next_collection_time():
current_period = round_down_time(block_time, plan.cron_spec, plan.cron_tz)
if current_period > subscription.last_collected_time:
if transfer(subscription.subscriber, plan.owner, plan.price):
subscription.payment_failures = 0
emit Event(
'collect_payment',
plan_id=plan.id,
subscriber=subscription.subscriber,
amount=amount,
)
subscription.last_collected_time = current_period
else:
subscription.payment_failures += 1
if subscription.payment_failures > FailureTolerance:
stop_subscription(plan.id, subscription.subscriber)
emit Event(
'stop_subscription',
plan_id=plan.id,
subscriber=subscription.subscriber,
)
else:
break
```
24 changes: 24 additions & 0 deletions x/subscription/spec/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!--
order: 0
title: Subscription Overview
parent:
title: "subscription"
-->

# `x/subscription`

## Table of Contents

<!-- TOC -->

1. **[Concepts](01_concepts.md)**
2. **[State](02_state.md)**
3. **[Messages](03_messages.md)**
4. **[Events](04_events.md)**
5. **[Params](05_params.md)**
6. **[BeginBlock](06_begin_block.md)**

## Abstract

`x/subscription` is an implementation of a Cosmos SDK module that allows for billing customers for goods or services at
regular intervals.

0 comments on commit caf9f3b

Please sign in to comment.