diff --git a/examples/gno.land/r/samcrew/subscriptions/coins.gno b/examples/gno.land/r/samcrew/subscriptions/coins.gno new file mode 100644 index 00000000000..373c3c51ff4 --- /dev/null +++ b/examples/gno.land/r/samcrew/subscriptions/coins.gno @@ -0,0 +1,160 @@ +package subscriptions + +import ( + "chain" + "chain/banker" + "chain/runtime" + "errors" + "strings" + + "gno.land/r/demo/defi/grc20reg" +) + +// XXX: this part is 100% from payrolls realm from N0izN0iz <3 +// Payrolls realm: https://github.com/gnolang/gno/pull/3432 + +// prefixCoins transforms coins into subscriptions coins, prefixing their denom them with their type. Examples: "ugnot" -> "/native/ugnot", "gno.land/r/demo/foo20" -> "/grc20/gno.land/r/demo/foo20" +func prefixCoins(cur realm, native chain.Coins, grc20 chain.Coins) (chain.Coins, error) { + if len(native) == 0 && len(grc20) == 0 { + return chain.Coins{}, errors.New("no coins provided") + } + if coinsHasNegative(native) || coinsHasNegative(grc20) { + return chain.Coins{}, errors.New("negative coin amounts are not allowed") + } + out := make(chain.Coins, len(native)+len(grc20)) + for i, coin := range native { + out[i].Amount = coin.Amount + out[i].Denom = "/native/" + coin.Denom + } + offset := len(native) + for i, coin := range grc20 { + j := offset + i + out[j].Amount = coin.Amount + out[j].Denom = "/grc20/" + coin.Denom + } + return out, nil +} + +func coinsHasNegative(coins chain.Coins) bool { + for _, coin := range coins { + if coin.Amount < 0 { + return true + } + } + return false +} + +func coinsHasPositive(coins chain.Coins) bool { + for _, coin := range coins { + if coin.Amount > 0 { + return true + } + } + return false +} + +func addCoins(a chain.Coins, b chain.Coins) chain.Coins { + out := make(chain.Coins, len(a)) + copy(out, a) + for _, coin := range b { + out = addCoinAmount(out, coin) + } + return out +} + +func subCoins(a chain.Coins, b chain.Coins) chain.Coins { + out := make(chain.Coins, len(a)) + copy(out, a) + for _, coin := range b { + out = addCoinAmount(out, chain.NewCoin(coin.Denom, -coin.Amount)) + } + return out +} + +func multiplyCoins(coins chain.Coins, factor int64) chain.Coins { + out := make(chain.Coins, len(coins)) + for i, coin := range coins { + out[i] = chain.Coin{ + Denom: coin.Denom, + Amount: coin.Amount * factor, + } + } + return out +} + +func lessCoinsThan(a chain.Coins, b chain.Coins) bool { + for _, coinA := range a { + found := false + for _, coinB := range b { + if coinA.Denom == coinB.Denom { + if coinA.Amount < coinB.Amount { + return true + } + found = true + break + } + } + if !found && coinA.Amount > 0 { + return false + } + } + return false +} + +func addCoinAmount(coins chain.Coins, value chain.Coin) chain.Coins { + for i, coin := range coins { + if coin.Denom != value.Denom { + continue + } + + out := make(chain.Coins, len(coins)) + copy(out, coins) + out[i].Amount += value.Amount + return out + } + return append(coins, value) +} + +func sendCoins(dst address, coins chain.Coins) { + if len(coins) == 0 { + return + } + + natives := chain.Coins{} + grc20s := chain.Coins{} + + for _, coin := range coins { + if coin.Amount == 0 { + continue + } + if coin.Amount < 0 { + panic(errors.New("negative send amount")) + } + + var ( + target *chain.Coins + denom string + ) + + if strings.HasPrefix(coin.Denom, "/native/") { + target = &natives + denom = strings.TrimPrefix(coin.Denom, "/native/") + } else if strings.HasPrefix(coin.Denom, "/grc20/") { + target = &grc20s + denom = strings.TrimPrefix(coin.Denom, "/grc20/") + } else { + panic(errors.New("invalid coin denom prefix: " + coin.Denom)) + } + *target = addCoinAmount(*target, chain.NewCoin(denom, coin.Amount)) + } + + banker_ := banker.NewBanker(banker.BankerTypeRealmIssue) + from := runtime.CurrentRealm().Address() + if len(natives) != 0 { + banker_.SendCoins(from, dst, natives) + } + + for _, coin := range grc20s { + grc20reg.MustGet(coin.Denom).RealmTeller().Transfer(dst, coin.Amount) + } +} diff --git a/examples/gno.land/r/samcrew/subscriptions/coins_test.gno b/examples/gno.land/r/samcrew/subscriptions/coins_test.gno new file mode 100644 index 00000000000..957226b634a --- /dev/null +++ b/examples/gno.land/r/samcrew/subscriptions/coins_test.gno @@ -0,0 +1,243 @@ +package subscriptions + +import ( + "chain" + "chain/banker" + "testing" + + "gno.land/p/nt/testutils" +) + +func TestCoins(t *testing.T) { + native := chain.NewCoins( + chain.NewCoin("ugnot", 1000), + ) + grc20 := chain.NewCoins( + chain.NewCoin("gno.land/r/demo/foo20", 500), + ) + expected := chain.NewCoins( + chain.NewCoin("/native/ugnot", 1000), + chain.NewCoin("/grc20/gno.land/r/demo/foo20", 500), + ) + + result, err := prefixCoins(cross, native, grc20) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for i, coin := range expected { + if result[i].Amount != coin.Amount { + t.Fatalf("invalid amount of %s got %d, want %d", coin.Denom, result[i].Amount, coin.Amount) + } + if result[i].Denom != coin.Denom { + t.Fatalf("invalid denom: got %s, want %s", result[i].Denom, coin.Denom) + } + } +} + +func TestCoinsHasPositive(t *testing.T) { + tests := []struct { + coins chain.Coins + expected bool + }{ + {chain.NewCoins(chain.NewCoin("coinA", 0)), false}, + {chain.NewCoins(chain.NewCoin("coinA", -100)), false}, + {chain.NewCoins(chain.NewCoin("coinA", 50)), true}, + {chain.NewCoins(chain.NewCoin("coinA", 0), chain.NewCoin("coinB", 0)), false}, + {chain.NewCoins(chain.NewCoin("coinA", -10), chain.NewCoin("coinB", 20)), true}, + } + + for _, test := range tests { + result := coinsHasPositive(test.coins) + if result != test.expected { + t.Fatalf("coinsHasPositive(%v) = %v; want %v", test.coins, result, test.expected) + } + } +} + +func TestAddCoins(t *testing.T) { + a := chain.NewCoins( + chain.NewCoin("coinA", 100), + chain.NewCoin("coinB", 200), + ) + b := chain.NewCoins( + chain.NewCoin("coinB", 150), + chain.NewCoin("coinC", 300), + ) + expected := chain.NewCoins( + chain.NewCoin("coinA", 100), + chain.NewCoin("coinB", 350), + chain.NewCoin("coinC", 300), + ) + + result := addCoins(a, b) + if len(result) != len(expected) { + t.Fatalf("invalid result length: got %d, want %d", len(result), len(expected)) + } + for _, coin := range expected { + found := false + for _, resCoin := range result { + if resCoin.Denom == coin.Denom { + found = true + if resCoin.Amount != coin.Amount { + t.Fatalf("invalid amount for %s: got %d, want %d", coin.Denom, resCoin.Amount, coin.Amount) + } + break + } + } + if !found { + t.Fatalf("missing coin in result: %s", coin.Denom) + } + } +} + +func TestSubCoins(t *testing.T) { + a := chain.NewCoins( + chain.NewCoin("coinA", 300), + chain.NewCoin("coinB", 400), + ) + b := chain.NewCoins( + chain.NewCoin("coinB", 150), + chain.NewCoin("coinC", 100), + ) + expected := chain.NewCoins( + chain.NewCoin("coinA", 300), + chain.NewCoin("coinB", 250), + chain.NewCoin("coinC", -100), + ) + + result := subCoins(a, b) + if len(result) != len(expected) { + t.Fatalf("invalid result length: got %d, want %d", len(result), len(expected)) + } + for _, coin := range expected { + found := false + for _, resCoin := range result { + if resCoin.Denom == coin.Denom { + found = true + if resCoin.Amount != coin.Amount { + t.Fatalf("invalid amount for %s: got %d, want %d", coin.Denom, resCoin.Amount, coin.Amount) + } + break + } + } + if !found { + t.Fatalf("missing coin in result: %s", coin.Denom) + } + } +} + +func TestMultiplyCoins(t *testing.T) { + coins := chain.NewCoins( + chain.NewCoin("coinA", 100), + chain.NewCoin("coinB", 200), + ) + factor := int64(3) + expected := chain.NewCoins( + chain.NewCoin("coinA", 300), + chain.NewCoin("coinB", 600), + ) + + result := multiplyCoins(coins, factor) + if len(result) != len(expected) { + t.Fatalf("invalid result length: got %d, want %d", len(result), len(expected)) + } + for _, coin := range expected { + found := false + for _, resCoin := range result { + if resCoin.Denom == coin.Denom { + found = true + if resCoin.Amount != coin.Amount { + t.Fatalf("invalid amount for %s: got %d, want %d", coin.Denom, resCoin.Amount, coin.Amount) + } + break + } + } + if !found { + t.Fatalf("missing coin in result: %s", coin.Denom) + } + } +} + +func TestAddCoinAmount(t *testing.T) { + coins := chain.NewCoins( + chain.NewCoin("coinA", 100), + chain.NewCoin("coinB", 200), + ) + value := chain.NewCoin("coinB", 150) + expected := chain.NewCoins( + chain.NewCoin("coinA", 100), + chain.NewCoin("coinB", 350), + ) + + result := addCoinAmount(coins, value) + if len(result) != len(expected) { + t.Fatalf("invalid result length: got %d, want %d", len(result), len(expected)) + } + for _, coin := range expected { + found := false + for _, resCoin := range result { + if resCoin.Denom == coin.Denom { + found = true + if resCoin.Amount != coin.Amount { + t.Fatalf("invalid amount for %s: got %d, want %d", coin.Denom, resCoin.Amount, coin.Amount) + } + break + } + } + if !found { + t.Fatalf("missing coin in result: %s", coin.Denom) + } + } +} + +func TestSendCoins(t *testing.T) { + alice := testutils.TestAddress("alice") + testing.SetOriginCaller(alice) + testing.SetRealm(testing.NewUserRealm(alice)) + coinsToSend := chain.NewCoins( + chain.NewCoin("/native/gno.land/r/samcrew/subscriptions:a", 100), + chain.NewCoin("/native/gno.land/r/samcrew/subscriptions:b", 200), + ) + + // without native prefix + coinsToIssue := chain.NewCoins( + chain.NewCoin("gno.land/r/samcrew/subscriptions:a", 100), + chain.NewCoin("gno.land/r/samcrew/subscriptions:b", 200), + ) + + testing.IssueCoins(alice, coinsToIssue) + + bob := testutils.TestAddress("bob") + testing.SetOriginSend(coinsToSend) + + sendCoins(bob, coinsToSend) + + banker := banker.NewBanker(banker.BankerTypeRealmIssue) + aliceCoins := banker.GetCoins(alice) + bobCoins := banker.GetCoins(bob) + for _, coin := range coinsToIssue { + aliceAmount := int64(0) + bobAmount := int64(0) + for _, aCoin := range aliceCoins { + if aCoin.Denom == coin.Denom { + aliceAmount = aCoin.Amount + break + } + } + for _, bCoin := range bobCoins { + if bCoin.Denom == coin.Denom { + bobAmount = bCoin.Amount + break + } + } + + expectedAliceAmount := int64(0) + expectedBobAmount := coin.Amount + if aliceAmount != expectedAliceAmount { + t.Fatalf("invalid alice amount for %s: got %d, want %d", coin.Denom, aliceAmount, expectedAliceAmount) + } + if bobAmount != expectedBobAmount { + t.Fatalf("invalid bob amount for %s: got %d, want %d", coin.Denom, bobAmount, expectedBobAmount) + } + } +} diff --git a/examples/gno.land/r/samcrew/subscriptions/gnomod.toml b/examples/gno.land/r/samcrew/subscriptions/gnomod.toml new file mode 100644 index 00000000000..6d57bcc1768 --- /dev/null +++ b/examples/gno.land/r/samcrew/subscriptions/gnomod.toml @@ -0,0 +1,3 @@ +module = "gno.land/r/samcrew/subscriptions" +gno = "0.9" +private = true diff --git a/examples/gno.land/r/samcrew/subscriptions/public.gno b/examples/gno.land/r/samcrew/subscriptions/public.gno new file mode 100644 index 00000000000..556ca648327 --- /dev/null +++ b/examples/gno.land/r/samcrew/subscriptions/public.gno @@ -0,0 +1,196 @@ +package subscriptions + +import ( + "chain" + "chain/banker" + "chain/runtime" + "time" + + "gno.land/p/nt/avl" + "gno.land/p/nt/ownable" + "gno.land/p/nt/seqid" +) + +var services *avl.Tree // key: displayName, value: *service +var users *avl.Tree // key: user address, value: avl.Tree of subscribed service names and empty struct{} +var id seqid.ID + +// Subscribe allows a user to subscribe to a service by providing the service name +// - serviceName: the name of the service to subscribe to +// - fqName: the fully qualified name of the GRC20 token to use for payment (empty string for native coins) +// - amount: the amount of GRC20 tokens to deposit initially (0 for native coins) +func Subscribe(cur realm, serviceName string, fqName string, amount int64) { + creator := runtime.PreviousRealm().Address() + svc := mustGetServiceByName(serviceName) + if svc.subscribers.Has(creator.String()) { + panic("already subscribed") + } + natSend := banker.OriginSend() + grc20Send := chain.Coins{} + if amount != 0 { + grc20Send = chain.NewCoins(chain.NewCoin("/grc20/"+fqName, amount)) + } + sCoins, err := prefixCoins(cur, natSend, grc20Send) + if err != nil { + panic(err) + } + // XXX: support multiple initial GRC20 tokens + if !coinsHasPositive(sCoins) || lessCoinsThan(sCoins, svc.price) { + panic("initial deposit must be positive and at least the service price") + } + sub := &subscription{ + serviceID: svc.id, + vault: sCoins, + startedAt: time.Now(), + lastChargedAt: time.Now(), + initialPaid: false, + } + svc.subscribers.Set(creator.String(), sub) + + var userSubs *avl.Tree + userSubsRaw, ok := users.Get(creator.String()) + if !ok { + userSubs = avl.NewTree() + } else { + userSubs = userSubsRaw.(*avl.Tree) + } + userSubs.Set(serviceName, struct{}{}) + users.Set(creator.String(), userSubs) +} + +// Unsubscribe allows a user to unsubscribe from a service by providing the service name +// It refunds any remaining balance after deducting due amounts to the service owner +// - serviceName: the name of the service to unsubscribe from +func Unsubscribe(cur realm, serviceName string) { + creator := runtime.PreviousRealm().Address() + svc := mustGetServiceByName(serviceName) + if !svc.subscribers.Has(creator.String()) { + panic("not subscribed") + } + subValue, _ := svc.subscribers.Get(creator.String()) + sub := subValue.(*subscription) + due, _ := sub.dueAmount(true, svc.renewalPeriod, svc.price) + if coinsHasPositive(due) { + sendCoins(svc.owner.Owner(), due) + } + remaining := subCoins(sub.vault, due) + if coinsHasPositive(remaining) { + sendCoins(creator, remaining) + } + svc.subscribers.Remove(creator.String()) + + userSubsRaw, _ := users.Get(creator.String()) + userSubs := userSubsRaw.(*avl.Tree) + userSubs.Remove(serviceName) + if userSubs.Size() == 0 { + users.Remove(creator.String()) + } +} + +// Topup increases a user's subscription balance using native coins, GRC20 tokens, +// or both in the same transaction. Native funds are inferred from the tx. +// - serviceName: target service +// - fqName: GRC20 token denom (optional) +// - amount: amount of GRC20 tokens (ignored if fqName empty) +func Topup(cur realm, serviceName string, fqName string, amount int64) { + svc := mustGetServiceByName(serviceName) + if !svc.subscribers.Has(runtime.PreviousRealm().Address().String()) { + panic("not subscribed") + } + subRaw, _ := svc.subscribers.Get(runtime.PreviousRealm().Address().String()) + sub := subRaw.(*subscription) + _, active := sub.dueAmount(false, svc.renewalPeriod, svc.price) + native := banker.OriginSend() + if coinsHasPositive(native) { + topupNative(cur, serviceName) + } + if amount != 0 { + topupGRC20(cur, serviceName, fqName, amount) + } + // XXX: reset subscription if it was inactive and now has enough balance + if !active && !lessCoinsThan(sub.vault, svc.price) { + sub.startedAt = time.Now() + sub.lastChargedAt = time.Now() + sub.initialPaid = false + } +} + +// Withdraw allows a user to withdraw available balance from their subscription +// - serviceName: the name of the service to withdraw from +// - denom: the denomination of the coin to withdraw +// - amount: the amount of the coin to withdraw +func Withdraw(cur realm, serviceName string, denom string, amount int64) { + creator := runtime.PreviousRealm().Address() + svc := mustGetServiceByName(serviceName) + if !svc.subscribers.Has(creator.String()) { + panic("not subscribed") + } + subValue, _ := svc.subscribers.Get(creator.String()) + sub := subValue.(*subscription) + due, _ := sub.dueAmount(false, svc.renewalPeriod, svc.price) + available := subCoins(sub.vault, due) + withdraw := chain.NewCoins(chain.NewCoin(denom, amount)) + if lessCoinsThan(available, withdraw) { + panic("insufficient available balance to withdraw") + } + sub.vault = subCoins(sub.vault, withdraw) + sendCoins(creator, withdraw) +} + +// IsSubscribed checks if a user is currently subscribed to a service +// - serviceName: the name of the service to check +// - userAddr: the address of the user to check +// Returns true if the user is subscribed and active, false otherwise +func IsSubscribed(cur realm, serviceName string, userAddr string) bool { + svc := getServiceByName(serviceName) + if svc == nil { + return false + } + if !svc.subscribers.Has(userAddr) { + return false + } + subValue, _ := svc.subscribers.Get(userAddr) + sub := subValue.(*subscription) + return sub.isActive(svc.renewalPeriod, svc.price) +} + +// NewService creates a new subscription service with the given parameters +// - displayName: the name of the service +// - description: a brief description of the service +// - renewalPeriod: the duration between renewals +// - native: the price of the service in native coins +// - grc20: the price of the service in GRC20 tokens +func NewService(cur realm, displayName, description string, renewalPeriod time.Duration, native chain.Coins, grc20 chain.Coins) { + // XXX: prefix with creator realm to avoid name clashes? + if services.Has(displayName) { + panic("service with the same display name already exists") + } + creator := runtime.PreviousRealm().Address() + sCoins, err := prefixCoins(cur, native, grc20) + if err != nil { + panic(err) + } + svc := &service{ + id: id.Next(), + owner: ownable.NewWithAddress(creator), + displayName: displayName, + description: description, + renewalPeriod: renewalPeriod, + price: sCoins, + subscribers: avl.NewTree(), + } + services.Set(displayName, svc) +} + +// ServiceClaimVault allows the owner of a service to claim the funds in the service's vault +// - displayName: the name of the service +func ServiceClaimVault(cur realm, displayName string) { + svc := mustGetServiceByName(displayName) + svc.owner.AssertOwnedByPrevious() + balance := svc.balance(true) + if !coinsHasPositive(balance) { + panic("no funds to claim") + } + sendCoins(svc.owner.Owner(), balance) + +} diff --git a/examples/gno.land/r/samcrew/subscriptions/render.gno b/examples/gno.land/r/samcrew/subscriptions/render.gno new file mode 100644 index 00000000000..6a327fd9cfa --- /dev/null +++ b/examples/gno.land/r/samcrew/subscriptions/render.gno @@ -0,0 +1,175 @@ +package subscriptions + +import ( + "chain/runtime" + "strings" + "time" + + "gno.land/p/moul/md" + "gno.land/p/nt/avl" + "gno.land/p/nt/mux" + "gno.land/p/nt/ufmt" + "gno.land/p/sunspirit/table" +) + +func Render(path string) string { + router := mux.NewRouter() + + router.HandleFunc("", homeHandler) + router.HandleFunc("u/{addr}", userHandler) + router.HandleFunc("svc/{name}", serviceHandler) + + return router.Render(path) +} + +func homeHandler(res *mux.ResponseWriter, _ *mux.Request) { + out := md.H1("Subscriptions Service Module") + out += md.Paragraph("This module provides a subscriptions service where users can subscribe to services, manage their subscriptions, and handle payments using native coins or GRC20 tokens.") + out += md.H2("Services Table") + t, _ := table.New( + []string{"Service Name", "Description", "Renewal Period", "Price", "Subscribers"}, + [][]string{}, + ) + r := runtime.CurrentRealm() + linkpath := getLinkPath(r.PkgPath()) + services.Iterate("", "", func(key string, value interface{}) bool { + svc := value.(*service) + subscriberCount := ufmt.Sprintf("%d", svc.subscriberCount()) + t.AddRow([]string{ + md.Link(svc.displayName, linkpath+":svc/"+svc.displayName), + svc.description, + humanDuration(svc.renewalPeriod), + svc.price.String(), + string(subscriberCount), + }) + return false + }) + out += t.String() + res.Write(out) +} + +func userHandler(res *mux.ResponseWriter, req *mux.Request) { + userAddress := req.GetVar("addr") + out := md.H1("User Subscription Details") + out += md.Paragraph("Details for user: " + userAddress) + out += md.HorizontalRule() + t, _ := table.New( + []string{"Service Name", "Status", "Balance", "Started At"}, + [][]string{}, + ) + r := runtime.CurrentRealm() + linkpath := getLinkPath(r.PkgPath()) + users.Iterate("", "", func(key string, value interface{}) bool { + if key != userAddress { + return false + } + userSubs := value.(*avl.Tree) + userSubs.Iterate("", "", func(svcName string, _ interface{}) bool { + svc := mustGetServiceByName(svcName) + subRaw, _ := svc.subscribers.Get(userAddress) + sub := subRaw.(*subscription) + status := "Inactive (insufficient funds)" + if sub.isActive(svc.renewalPeriod, svc.price) { + status = "Active" + } + t.AddRow([]string{ + md.Link(svcName, linkpath+":svc/"+svcName), + status, + sub.balance(svc.renewalPeriod, svc.price).String(), + sub.startedAt.Format("2006-01-02"), + }) + return false + }) + return false + }) + out += t.String() + res.Write(out) +} + +func serviceHandler(res *mux.ResponseWriter, req *mux.Request) { + serviceName := req.GetVar("name") + svc := mustGetServiceByName(serviceName) + + out := md.H1("Service Details: " + serviceName) + out += md.Paragraph(svc.description) + out += md.Paragraph("Renewal Period: " + humanDuration(svc.renewalPeriod)) + out += md.Paragraph("Price: " + svc.price.String()) + out += md.Paragraph("Total Balance: " + svc.balance(false).String()) + out += md.Paragraph("Total Subscribers: " + ufmt.Sprintf("%d", svc.subscriberCount())) + out += md.HorizontalRule() + + t, _ := table.New( + []string{"Subscriber Address", "Status", "Balance", "Started At"}, + [][]string{}, + ) + r := runtime.CurrentRealm() + linkpath := getLinkPath(r.PkgPath()) + svc.subscribers.Iterate("", "", func(addr string, value interface{}) bool { + sub := value.(*subscription) + status := "Inactive (insufficient funds)" + if sub.isActive(svc.renewalPeriod, svc.price) { + status = "Active" + } + t.AddRow([]string{ + md.Link(addr, linkpath+":u/"+addr), + status, + sub.balance(svc.renewalPeriod, svc.price).String(), + sub.startedAt.Format("2006-01-02"), + }) + return false + }) + out += t.String() + res.Write(out) +} + +func getLinkPath(pkgPath string) string { + slashIdx := strings.IndexRune(pkgPath, '/') + if slashIdx != 1 { + return pkgPath[slashIdx:] + } + return "" +} + +func humanDuration(d time.Duration) string { + seconds := int64(d.Seconds()) + if seconds < 0 { + seconds = -seconds + } + + const ( + secPerMinute = 60 + secPerHour = 60 * secPerMinute + secPerDay = 24 * secPerHour + ) + + days := seconds / secPerDay + seconds %= secPerDay + + hours := seconds / secPerHour + seconds %= secPerHour + + minutes := seconds / secPerMinute + seconds %= secPerMinute + + result := "" + + add := func(v int64, unit string) { + if v > 0 { + if result != "" { + result += " " + } + result += ufmt.Sprintf("%d%s", v, unit) + } + } + + add(days, "d") + add(hours, "h") + add(minutes, "m") + add(seconds, "s") + + if result == "" { + return "0s" + } + + return result +} diff --git a/examples/gno.land/r/samcrew/subscriptions/service.gno b/examples/gno.land/r/samcrew/subscriptions/service.gno new file mode 100644 index 00000000000..5b17f95ab1c --- /dev/null +++ b/examples/gno.land/r/samcrew/subscriptions/service.gno @@ -0,0 +1,59 @@ +package subscriptions + +import ( + "chain" + "time" + + "gno.land/p/nt/avl" + "gno.land/p/nt/ownable" + "gno.land/p/nt/seqid" +) + +type service struct { + id seqid.ID + owner *ownable.Ownable + displayName string + description string + renewalPeriod time.Duration + price chain.Coins + subscribers *avl.Tree // key: address, value: *subscription +} + +func (svc *service) balance(withdraw bool) chain.Coins { + balance := chain.Coins{} + svc.subscribers.Iterate("", "", func(key string, value interface{}) bool { + sub := value.(*subscription) + due, _ := sub.dueAmount(withdraw, svc.renewalPeriod, svc.price) + balance = addCoins(balance, due) + return false + }) + return balance +} + +func (svc *service) subscriberCount() int { + count := 0 + svc.subscribers.Iterate("", "", func(key string, value interface{}) bool { + s := value.(*subscription) + if s.isActive(svc.renewalPeriod, svc.price) { + count++ + } + return false + }) + return count +} + +func mustGetServiceByName(displayName string) *service { + svc := getServiceByName(displayName) + if svc == nil { + panic("service not found") + } + return svc +} + +func getServiceByName(displayName string) *service { + value, exists := services.Get(displayName) + if !exists { + return nil + } + return value.(*service) +} diff --git a/examples/gno.land/r/samcrew/subscriptions/subscription.gno b/examples/gno.land/r/samcrew/subscriptions/subscription.gno new file mode 100644 index 00000000000..ef66c098b1e --- /dev/null +++ b/examples/gno.land/r/samcrew/subscriptions/subscription.gno @@ -0,0 +1,116 @@ +package subscriptions + +import ( + "chain" + "chain/banker" + "chain/runtime" + "errors" + "time" + + "gno.land/p/nt/avl" + "gno.land/p/nt/seqid" + "gno.land/r/demo/defi/grc20reg" +) + +func init() { + users = avl.NewTree() + services = avl.NewTree() +} + +type subscription struct { + serviceID seqid.ID + vault chain.Coins + startedAt time.Time + lastChargedAt time.Time + initialPaid bool +} + +func (sub *subscription) dueAmount(charge bool, renewalPeriod time.Duration, price chain.Coins) (chain.Coins, bool) { + elapsed := time.Now().Sub(sub.lastChargedAt) + periodsElapsed := int64(elapsed / renewalPeriod) + if !sub.initialPaid { + periodsElapsed += 1 + } + if periodsElapsed <= 0 { + return chain.Coins{}, true + } + + due := chain.Coins{} // dummy initial value + for i := int64(0); i < periodsElapsed; i++ { + due = addCoins(due, price) + if lessCoinsThan(sub.vault, due) { + return subCoins(due, price), false + } + } + + if charge { + sub.vault = subCoins(sub.vault, due) + sub.lastChargedAt = sub.lastChargedAt.Add(time.Duration(periodsElapsed) * renewalPeriod) + sub.initialPaid = true + } + + return due, true +} + +func (sub *subscription) isActive(renewalPeriod time.Duration, price chain.Coins) bool { + _, ok := sub.dueAmount(false, renewalPeriod, price) + return ok +} + +func (sub *subscription) balance(renewalPeriod time.Duration, price chain.Coins) chain.Coins { + dueAmount, _ := sub.dueAmount(false, renewalPeriod, price) + return subCoins(sub.vault, dueAmount) +} + +func topupNative(cur realm, serviceName string) { + creator := runtime.PreviousRealm().Address() + + runtime.AssertOriginCall() + + send := banker.OriginSend() + if !coinsHasPositive(send) { + panic("topup amount must be positive") + } + for i := range send { + send[i].Denom = "/native/" + send[i].Denom + } + + svc := mustGetServiceByName(serviceName) + if !svc.subscribers.Has(creator.String()) { + panic("not subscribed") + } + subValue, _ := svc.subscribers.Get(creator.String()) + sub := subValue.(*subscription) + sub.vault = addCoins(sub.vault, send) +} + +func topupGRC20(cur realm, serviceName string, fqName string, amount int64) { + token := grc20reg.MustGet(fqName) + if token == nil { + panic(errors.New("token not found: " + fqName)) + } + creator := runtime.PreviousRealm().Address() + send := chain.NewCoins(chain.NewCoin("/grc20/"+fqName, amount)) + + anyAmount := amount == -1 + if !anyAmount && amount <= 0 { + panic("topup amount must be positive or -1 for max") + } + + if anyAmount { + amount = int64(token.Allowance(creator, runtime.CurrentRealm().Address())) + } + + teller := token.RealmTeller() + if err := teller.TransferFrom(creator, runtime.CurrentRealm().Address(), amount); err != nil { + panic(err) + } + + svc := mustGetServiceByName(serviceName) + if !svc.subscribers.Has(creator.String()) { + panic("not subscribed") + } + subValue, _ := svc.subscribers.Get(creator.String()) + sub := subValue.(*subscription) + sub.vault = addCoins(sub.vault, send) +}