diff --git a/examples/gno.land/p/demo/subscription/doc.gno b/examples/gno.land/p/demo/subscription/doc.gno new file mode 100644 index 00000000000..9cc102fcc9a --- /dev/null +++ b/examples/gno.land/p/demo/subscription/doc.gno @@ -0,0 +1,66 @@ +// Package subscription provides a flexible system for managing both recurring and +// lifetime subscriptions in Gno applications. It enables developers to handle +// payment-based access control for services or products. The library supports +// both subscriptions requiring periodic payments (recurring) and one-time payments +// (lifetime). Subscriptions are tracked using an AVL tree for efficient management +// of subscription statuses. +// +// Usage: +// +// Import the required sub-packages (`recurring` and/or `lifetime`) to manage specific +// subscription types. The methods provided allow users to subscribe, check subscription +// status, and manage payments. +// +// Recurring Subscription: +// +// Recurring subscriptions require periodic payments to maintain access. +// Users pay to extend their access for a specific duration. +// +// Example: +// +// // Create a recurring subscription requiring 100 ugnot every 30 days +// recSub := recurring.NewRecurringSubscription(time.Hour * 24 * 30, 100) +// +// // Process payment for the recurring subscription +// recSub.Subscribe() +// +// // Gift a recurring subscription to another user +// recSub.GiftSubscription(recipientAddress) +// +// // Check if a user has a valid subscription +// recSub.HasValidSubscription(addr) +// +// // Get the expiration date of the subscription +// recSub.GetExpiration(caller) +// +// // Update the subscription amount to 200 ugnot +// recSub.UpdateAmount(200) +// +// // Get the current subscription amount +// recSub.GetAmount() +// +// Lifetime Subscription: +// +// Lifetime subscriptions require a one-time payment for permanent access. +// Once paid, users have indefinite access without further payments. +// +// Example: +// +// // Create a lifetime subscription costing 500 ugnot +// lifeSub := lifetime.NewLifetimeSubscription(500) +// +// // Process payment for lifetime access +// lifeSub.Subscribe() +// +// // Gift a lifetime subscription to another user +// lifeSub.GiftSubscription(recipientAddress) +// +// // Check if a user has a valid subscription +// lifeSub.HasValidSubscription(addr) +// +// // Update the lifetime subscription amount to 1000 ugnot +// lifeSub.UpdateAmount(1000) +// +// // Get the current lifetime subscription amount +// lifeSub.GetAmount() +package subscription diff --git a/examples/gno.land/p/demo/subscription/gno.mod b/examples/gno.land/p/demo/subscription/gno.mod new file mode 100644 index 00000000000..ea60a4c628a --- /dev/null +++ b/examples/gno.land/p/demo/subscription/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/subscription diff --git a/examples/gno.land/p/demo/subscription/lifetime/errors.gno b/examples/gno.land/p/demo/subscription/lifetime/errors.gno new file mode 100644 index 00000000000..faeda4cd9fe --- /dev/null +++ b/examples/gno.land/p/demo/subscription/lifetime/errors.gno @@ -0,0 +1,10 @@ +package lifetime + +import "errors" + +var ( + ErrNoSub = errors.New("lifetime subscription: no active subscription found") + ErrAmt = errors.New("lifetime subscription: payment amount does not match the required subscription amount") + ErrAlreadySub = errors.New("lifetime subscription: this address already has an active lifetime subscription") + ErrNotAuthorized = errors.New("lifetime subscription: action not authorized") +) diff --git a/examples/gno.land/p/demo/subscription/lifetime/gno.mod b/examples/gno.land/p/demo/subscription/lifetime/gno.mod new file mode 100644 index 00000000000..0084aa714c5 --- /dev/null +++ b/examples/gno.land/p/demo/subscription/lifetime/gno.mod @@ -0,0 +1,8 @@ +module gno.land/p/demo/subscription/lifetime + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/ownable v0.0.0-latest + gno.land/p/demo/testutils v0.0.0-latest + gno.land/p/demo/uassert v0.0.0-latest +) diff --git a/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno b/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno new file mode 100644 index 00000000000..8a4c10b687b --- /dev/null +++ b/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno @@ -0,0 +1,81 @@ +package lifetime + +import ( + "std" + + "gno.land/p/demo/avl" + "gno.land/p/demo/ownable" +) + +// LifetimeSubscription represents a subscription that requires only a one-time payment. +// It grants permanent access to a service or product. +type LifetimeSubscription struct { + ownable.Ownable + amount int64 + subs *avl.Tree // std.Address -> bool +} + +// NewLifetimeSubscription creates and returns a new lifetime subscription. +func NewLifetimeSubscription(amount int64) *LifetimeSubscription { + return &LifetimeSubscription{ + Ownable: *ownable.New(), + amount: amount, + subs: avl.NewTree(), + } +} + +// processSubscription handles the subscription process for a given receiver. +func (ls *LifetimeSubscription) processSubscription(receiver std.Address) error { + amount := std.GetOrigSend() + + if amount.AmountOf("ugnot") != ls.amount { + return ErrAmt + } + + _, exists := ls.subs.Get(receiver.String()) + + if exists { + return ErrAlreadySub + } + + ls.subs.Set(receiver.String(), true) + + return nil +} + +// Subscribe processes the payment for a lifetime subscription. +func (ls *LifetimeSubscription) Subscribe() error { + caller := std.PrevRealm().Addr() + return ls.processSubscription(caller) +} + +// GiftSubscription allows the caller to pay for a lifetime subscription for another user. +func (ls *LifetimeSubscription) GiftSubscription(receiver std.Address) error { + return ls.processSubscription(receiver) +} + +// HasValidSubscription checks if the given address has an active lifetime subscription. +func (ls *LifetimeSubscription) HasValidSubscription(addr std.Address) error { + _, exists := ls.subs.Get(addr.String()) + + if !exists { + return ErrNoSub + } + + return nil +} + +// UpdateAmount allows the owner of the LifetimeSubscription contract to update the subscription price. +func (ls *LifetimeSubscription) UpdateAmount(newAmount int64) error { + if err := ls.CallerIsOwner(); err != nil { + return ErrNotAuthorized + } + + ls.amount = newAmount + return nil +} + +// GetAmount returns the current subscription price. +func (ls *LifetimeSubscription) GetAmount() int64 { + return ls.amount +} diff --git a/examples/gno.land/p/demo/subscription/lifetime/lifetime_test.gno b/examples/gno.land/p/demo/subscription/lifetime/lifetime_test.gno new file mode 100644 index 00000000000..efbae90c11c --- /dev/null +++ b/examples/gno.land/p/demo/subscription/lifetime/lifetime_test.gno @@ -0,0 +1,105 @@ +package lifetime + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" +) + +var ( + alice = testutils.TestAddress("alice") + bob = testutils.TestAddress("bob") + charlie = testutils.TestAddress("charlie") +) + +func TestLifetimeSubscription(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + ls := NewLifetimeSubscription(1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err := ls.Subscribe() + uassert.NoError(t, err, "Expected ProcessPayment to succeed") + + err = ls.HasValidSubscription(std.PrevRealm().Addr()) + uassert.NoError(t, err, "Expected Alice to have access") +} + +func TestLifetimeSubscriptionGift(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + ls := NewLifetimeSubscription(1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err := ls.GiftSubscription(bob) + uassert.NoError(t, err, "Expected ProcessPaymentGift to succeed for Bob") + + err = ls.HasValidSubscription(bob) + uassert.NoError(t, err, "Expected Bob to have access") + + err = ls.HasValidSubscription(charlie) + uassert.Error(t, err, "Expected Charlie to fail access check") +} + +func TestUpdateAmountAuthorization(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + ls := NewLifetimeSubscription(1000) + + err := ls.UpdateAmount(2000) + uassert.NoError(t, err, "Expected Alice to succeed in updating amount") + + std.TestSetOrigCaller(bob) + + err = ls.UpdateAmount(3000) + uassert.Error(t, err, "Expected Bob to fail when updating amount") +} + +func TestIncorrectPaymentAmount(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + ls := NewLifetimeSubscription(1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 500}}, nil) + err := ls.Subscribe() + uassert.Error(t, err, "Expected payment to fail with incorrect amount") +} + +func TestMultipleSubscriptionAttempts(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + ls := NewLifetimeSubscription(1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err := ls.Subscribe() + uassert.NoError(t, err, "Expected first subscription to succeed") + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err = ls.Subscribe() + uassert.Error(t, err, "Expected second subscription to fail as Alice is already subscribed") +} + +func TestGiftSubscriptionWithIncorrectAmount(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + ls := NewLifetimeSubscription(1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 500}}, nil) + err := ls.GiftSubscription(bob) + uassert.Error(t, err, "Expected gift subscription to fail with incorrect amount") + + err = ls.HasValidSubscription(bob) + uassert.Error(t, err, "Expected Bob to not have access after incorrect gift subscription") +} + +func TestUpdateAmountEffectiveness(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + ls := NewLifetimeSubscription(1000) + + err := ls.UpdateAmount(2000) + uassert.NoError(t, err, "Expected Alice to succeed in updating amount") + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err = ls.Subscribe() + uassert.Error(t, err, "Expected subscription to fail with old amount after update") + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 2000}}, nil) + err = ls.Subscribe() + uassert.NoError(t, err, "Expected subscription to succeed with new amount") +} diff --git a/examples/gno.land/p/demo/subscription/recurring/errors.gno b/examples/gno.land/p/demo/subscription/recurring/errors.gno new file mode 100644 index 00000000000..76a55e069bf --- /dev/null +++ b/examples/gno.land/p/demo/subscription/recurring/errors.gno @@ -0,0 +1,11 @@ +package recurring + +import "errors" + +var ( + ErrNoSub = errors.New("recurring subscription: no active subscription found") + ErrSubExpired = errors.New("recurring subscription: your subscription has expired") + ErrAmt = errors.New("recurring subscription: payment amount does not match the required subscription amount") + ErrAlreadySub = errors.New("recurring subscription: this address already has an active subscription") + ErrNotAuthorized = errors.New("recurring subscription: action not authorized") +) diff --git a/examples/gno.land/p/demo/subscription/recurring/gno.mod b/examples/gno.land/p/demo/subscription/recurring/gno.mod new file mode 100644 index 00000000000..d3cf8a044f8 --- /dev/null +++ b/examples/gno.land/p/demo/subscription/recurring/gno.mod @@ -0,0 +1,8 @@ +module gno.land/p/demo/subscription/recurring + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/ownable v0.0.0-latest + gno.land/p/demo/testutils v0.0.0-latest + gno.land/p/demo/uassert v0.0.0-latest +) diff --git a/examples/gno.land/p/demo/subscription/recurring/recurring.gno b/examples/gno.land/p/demo/subscription/recurring/recurring.gno new file mode 100644 index 00000000000..b5277bd716e --- /dev/null +++ b/examples/gno.land/p/demo/subscription/recurring/recurring.gno @@ -0,0 +1,104 @@ +package recurring + +import ( + "std" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/ownable" +) + +// RecurringSubscription represents a subscription that requires periodic payments. +// It includes the duration of the subscription and the amount required per period. +type RecurringSubscription struct { + ownable.Ownable + duration time.Duration + amount int64 + subs *avl.Tree // std.Address -> time.Time +} + +// NewRecurringSubscription creates and returns a new recurring subscription. +func NewRecurringSubscription(duration time.Duration, amount int64) *RecurringSubscription { + return &RecurringSubscription{ + Ownable: *ownable.New(), + duration: duration, + amount: amount, + subs: avl.NewTree(), + } +} + +// HasValidSubscription verifies if the caller has an active recurring subscription. +func (rs *RecurringSubscription) HasValidSubscription(addr std.Address) error { + expTime, exists := rs.subs.Get(addr.String()) + if !exists { + return ErrNoSub + } + + if time.Now().After(expTime.(time.Time)) { + return ErrSubExpired + } + + return nil +} + +// processSubscription processes the payment for a given receiver and renews or adds their subscription. +func (rs *RecurringSubscription) processSubscription(receiver std.Address) error { + amount := std.GetOrigSend() + + if amount.AmountOf("ugnot") != rs.amount { + return ErrAmt + } + + expTime, exists := rs.subs.Get(receiver.String()) + + // If the user is already a subscriber but his subscription has expired, authorize renewal + if exists { + expiration := expTime.(time.Time) + if time.Now().Before(expiration) { + return ErrAlreadySub + } + } + + // Renew or add subscription + newExpiration := time.Now().Add(rs.duration) + rs.subs.Set(receiver.String(), newExpiration) + + return nil +} + +// Subscribe handles the payment for the caller's subscription. +func (rs *RecurringSubscription) Subscribe() error { + caller := std.PrevRealm().Addr() + + return rs.processSubscription(caller) +} + +// GiftSubscription allows the user to pay for a subscription for another user (receiver). +func (rs *RecurringSubscription) GiftSubscription(receiver std.Address) error { + return rs.processSubscription(receiver) +} + +// GetExpiration returns the expiration date of the recurring subscription for a given caller. +func (rs *RecurringSubscription) GetExpiration(addr std.Address) (time.Time, error) { + expTime, exists := rs.subs.Get(addr.String()) + if !exists { + return time.Time{}, ErrNoSub + } + + return expTime.(time.Time), nil +} + +// UpdateAmount allows the owner of the subscription contract to change the required subscription amount. +func (rs *RecurringSubscription) UpdateAmount(newAmount int64) error { + if err := rs.CallerIsOwner(); err != nil { + return ErrNotAuthorized + } + + rs.amount = newAmount + return nil +} + +// GetAmount returns the current amount required for each subscription period. +func (rs *RecurringSubscription) GetAmount() int64 { + return rs.amount +} diff --git a/examples/gno.land/p/demo/subscription/recurring/recurring_test.gno b/examples/gno.land/p/demo/subscription/recurring/recurring_test.gno new file mode 100644 index 00000000000..e8bca15c0bf --- /dev/null +++ b/examples/gno.land/p/demo/subscription/recurring/recurring_test.gno @@ -0,0 +1,134 @@ +package recurring + +import ( + "std" + "testing" + "time" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" +) + +var ( + alice = testutils.TestAddress("alice") + bob = testutils.TestAddress("bob") + charlie = testutils.TestAddress("charlie") +) + +func TestRecurringSubscription(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + rs := NewRecurringSubscription(time.Hour*24, 1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err := rs.Subscribe() + uassert.NoError(t, err, "Expected ProcessPayment to succeed for Alice") + + err = rs.HasValidSubscription(std.PrevRealm().Addr()) + uassert.NoError(t, err, "Expected Alice to have access") + + expiration, err := rs.GetExpiration(std.PrevRealm().Addr()) + uassert.NoError(t, err, "Expected to get expiration for Alice") +} + +func TestRecurringSubscriptionGift(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + rs := NewRecurringSubscription(time.Hour*24, 1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err := rs.GiftSubscription(bob) + uassert.NoError(t, err, "Expected ProcessPaymentGift to succeed for Bob") + + err = rs.HasValidSubscription(bob) + uassert.NoError(t, err, "Expected Bob to have access") + + err = rs.HasValidSubscription(charlie) + uassert.Error(t, err, "Expected Charlie to fail access check") +} + +func TestRecurringSubscriptionExpiration(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + rs := NewRecurringSubscription(time.Hour, 1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err := rs.Subscribe() + uassert.NoError(t, err, "Expected ProcessPayment to succeed for Alice") + + err = rs.HasValidSubscription(std.PrevRealm().Addr()) + uassert.NoError(t, err, "Expected Alice to have access") + + expiration := time.Now().Add(-time.Hour * 2) + rs.subs.Set(std.PrevRealm().Addr().String(), expiration) + + err = rs.HasValidSubscription(std.PrevRealm().Addr()) + uassert.Error(t, err, "Expected Alice's subscription to be expired") +} + +func TestUpdateAmountAuthorization(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + rs := NewRecurringSubscription(time.Hour*24, 1000) + + err := rs.UpdateAmount(2000) + uassert.NoError(t, err, "Expected Alice to succeed in updating amount") + + std.TestSetOrigCaller(bob) + err = rs.UpdateAmount(3000) + uassert.Error(t, err, "Expected Bob to fail when updating amount") +} + +func TestGetAmount(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + rs := NewRecurringSubscription(time.Hour*24, 1000) + + amount := rs.GetAmount() + uassert.Equal(t, amount, int64(1000), "Expected the initial amount to be 1000 ugnot") + + err := rs.UpdateAmount(2000) + uassert.NoError(t, err, "Expected Alice to succeed in updating amount") + + amount = rs.GetAmount() + uassert.Equal(t, amount, int64(2000), "Expected the updated amount to be 2000 ugnot") +} + +func TestIncorrectPaymentAmount(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + rs := NewRecurringSubscription(time.Hour*24, 1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 500}}, nil) + err := rs.Subscribe() + uassert.Error(t, err, "Expected payment with incorrect amount to fail") +} + +func TestMultiplePaymentsForSameUser(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + rs := NewRecurringSubscription(time.Hour*24, 1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err := rs.Subscribe() + uassert.NoError(t, err, "Expected first ProcessPayment to succeed for Alice") + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err = rs.Subscribe() + uassert.Error(t, err, "Expected second ProcessPayment to fail for Alice due to existing subscription") +} + +func TestRecurringSubscriptionWithMultiplePayments(t *testing.T) { + std.TestSetRealm(std.NewUserRealm(alice)) + rs := NewRecurringSubscription(time.Hour, 1000) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err := rs.Subscribe() + uassert.NoError(t, err, "Expected first ProcessPayment to succeed for Alice") + + err = rs.HasValidSubscription(std.PrevRealm().Addr()) + uassert.NoError(t, err, "Expected Alice to have access after first payment") + + expiration := time.Now().Add(-time.Hour * 2) + rs.subs.Set(std.PrevRealm().Addr().String(), expiration) + + std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil) + err = rs.Subscribe() + uassert.NoError(t, err, "Expected second ProcessPayment to succeed for Alice") + + err = rs.HasValidSubscription(std.PrevRealm().Addr()) + uassert.NoError(t, err, "Expected Alice to have access after second payment") +} diff --git a/examples/gno.land/p/demo/subscription/subscription.gno b/examples/gno.land/p/demo/subscription/subscription.gno new file mode 100644 index 00000000000..cc52a2c0e2d --- /dev/null +++ b/examples/gno.land/p/demo/subscription/subscription.gno @@ -0,0 +1,12 @@ +package subscription + +import ( + "std" +) + +// Subscription interface defines standard methods that all subscription types must implement. +type Subscription interface { + HasValidSubscription(std.Address) error + Subscribe() error + UpdateAmount(newAmount int64) error +}