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(examples): payrolls #3432

Open
wants to merge 36 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
86bec93
feat(examples): payrolls
n0izn0iz Dec 31, 2024
466927b
fix: print estimated monthly
n0izn0iz Dec 31, 2024
b56ca99
chore: priorize stop
n0izn0iz Dec 31, 2024
7c0ecdf
chore: add todo
n0izn0iz Dec 31, 2024
627d6a2
chore: add todo
n0izn0iz Dec 31, 2024
fb31beb
fix: misc
n0izn0iz Dec 31, 2024
966cadb
feat: link prefix
n0izn0iz Dec 31, 2024
1f67032
chore: add todo
n0izn0iz Dec 31, 2024
c82e8fd
fix: correctly resolve vault name
n0izn0iz Jan 2, 2025
4b3b852
feat: withdraw funds
n0izn0iz Jan 2, 2025
f450299
feat: pause/resume
n0izn0iz Jan 2, 2025
892014b
feat: home actions
n0izn0iz Jan 2, 2025
01cd8b6
fix: anchors + misc
n0izn0iz Jan 2, 2025
f2b10a9
feat: show createdAt
n0izn0iz Jan 2, 2025
a24985b
chore: update todo
n0izn0iz Jan 2, 2025
ae6f4e2
chore: reorg
n0izn0iz Jan 2, 2025
65a54f0
fix: saner claim
n0izn0iz Jan 2, 2025
234c027
feat: support any native coin or registered grc20
n0izn0iz Jan 3, 2025
738c1dd
feat: show breakup bonus in details
n0izn0iz Jan 3, 2025
8fa1b84
chore: refacto
n0izn0iz Jan 3, 2025
5fe0890
chore: remove unused func
n0izn0iz Jan 3, 2025
805c35c
test: add unit test
n0izn0iz Jan 3, 2025
6a7fbcd
test: add native test
n0izn0iz Jan 3, 2025
028c7fa
chore: doc
n0izn0iz Jan 3, 2025
578e545
chore: package doc
n0izn0iz Jan 3, 2025
7b52c38
chore: hide dev artifact
n0izn0iz Jan 3, 2025
e173775
fix: deposit grc20 button render
n0izn0iz Jan 3, 2025
280d226
chore: improve doc
n0izn0iz Jan 3, 2025
084ccac
fix: validate namespace
n0izn0iz Jan 3, 2025
20ed78e
chore: imrove doc
n0izn0iz Jan 3, 2025
7aa0990
chore: improve doc
n0izn0iz Jan 3, 2025
b611fe5
fix: stop exploit
n0izn0iz Jan 4, 2025
119154c
chore: refacto
n0izn0iz Jan 4, 2025
a980890
Merge branch 'master' into examples-payrolls
n0izn0iz Jan 6, 2025
6c0055f
Merge branch 'master' into examples-payrolls
n0izn0iz Jan 13, 2025
2a7eaf9
Merge branch 'master' into examples-payrolls
thehowl Mar 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions examples/gno.land/r/demo/payrolls/coins.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package payrolls

import (
"errors"
"std"
"strings"

"gno.land/r/demo/grc20reg"
)

// Coins transforms coins into payrolls coins, prefixing their denom them with their type. Examples: "ugnot" -> "/native/ugnot", "gno.land/r/demo/foo20" -> "/grc20/gno.land/r/demo/foo20"
func Coins(native std.Coins, grc20 std.Coins) (std.Coins, error) {
out := make(std.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 addCoins(a std.Coins, b std.Coins) std.Coins {
out := make(std.Coins, len(a))
copy(out, a)
for _, coin := range b {
out = addCoinAmount(out, coin)
}
return out
}

func subCoins(a std.Coins, b std.Coins) std.Coins {
out := make(std.Coins, len(a))
copy(out, a)
for _, coin := range b {
out = addCoinAmount(out, std.NewCoin(coin.Denom, -coin.Amount))
}
return out
}

func coinsHasPositive(coins std.Coins) bool {
for _, coin := range coins {
if coin.Amount > 0 {
return true
}
}
return false
}

func clampCoins(a std.Coins, max std.Coins) std.Coins {
out := std.Coins{}
for _, coin := range a {
maxAmount := max.AmountOf(coin.Denom)
if coin.Amount > maxAmount {
out = append(out, std.NewCoin(coin.Denom, maxAmount))
continue
}
out = append(out, coin)
}
return out
}

func addCoinAmount(coins std.Coins, value std.Coin) std.Coins {
for i, coin := range coins {
if coin.Denom != value.Denom {
continue
}

out := make(std.Coins, len(coins))
copy(out, coins)
out[i].Amount += value.Amount
return out
}

return append(coins, value)
}

func coinsEmpty(coins std.Coins) bool {
if len(coins) == 0 {
return true
}
for _, coin := range coins {
if coin.Amount != 0 {
return false
}
}
return true
}

func sendCoins(dst std.Address, coins std.Coins) {
if len(coins) == 0 {
return
}

natives := std.Coins{}
grc20s := std.Coins{}

for _, coin := range coins {
if coin.Amount == 0 {
continue
}
if coin.Amount < 0 {
panic(errors.New("negative send amount"))
}

var (
target *std.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("unknown coin kind"))
}
*target = addCoins(*target, std.NewCoins(std.NewCoin(denom, coin.Amount)))
}

if len(natives) != 0 {
banker := std.GetBanker(std.BankerTypeRealmSend)
banker.SendCoins(std.CurrentRealm().Addr(), dst, natives)
}

for _, coin := range grc20s {
err := grc20reg.MustGet(coin.Denom)().CallerTeller().Transfer(dst, uint64(coin.Amount))
if err != nil {
panic(err)
}
}
}
1 change: 1 addition & 0 deletions examples/gno.land/r/demo/payrolls/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/r/demo/payrolls
100 changes: 100 additions & 0 deletions examples/gno.land/r/demo/payrolls/models.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package payrolls

import (
"errors"
"std"
"time"

"gno.land/p/demo/seqid"
)

// XXX: should pass a read-only payroll object to those funcs

// DistribFn is a distribution function used to compute and amount of released coins at a particular point in time.
//
// It must return payrolls [Coins]
type DistribFn func(worked time.Duration) std.Coins

// BreakupFn is a function used to compute payroll termination bonuses.
//
// It must return payrolls [Coins]
type BreakupFn func(elapsed time.Duration, pauseDuration time.Duration, source CallSource) std.Coins

// DistribStep returns a distribution function that will release coins every `per` duration.
func DistribStep(coins std.Coins, per time.Duration) DistribFn {
return func(elapsed time.Duration) std.Coins {
blocksCount := elapsed / per
out := make(std.Coins, 0, len(coins))
for _, coin := range coins {
out = append(out, std.NewCoin(coin.Denom, coin.Amount*int64(blocksCount)))
}
return out
}
}

// DistribLinear returns a distribution function that will continuously release coins every seconds and amount to a rate of `coins` every `per` duration.
func DistribLinear(coins std.Coins, per time.Duration) DistribFn {
return func(elapsed time.Duration) std.Coins {
out := make(std.Coins, 0, len(coins))
for _, coin := range coins {
newAmount := (int64(elapsed.Seconds()) * coin.Amount) / int64(per.Seconds())
out = append(out, std.NewCoin(coin.Denom, newAmount))
}
return out
}
}

// DistribMonthlyStep returns a distribution function that will release `coins` every months
func DistribMonthlyStep(amountPerMonth std.Coins) DistribFn {
return DistribStep(amountPerMonth, time.Hour*24*30)
}

// DistribMonthlyStep returns a distribution function that will continuously release coins every seconds and amount to a rate of `coins` every months
func DistribMonthlyContinuous(amountPerMonth std.Coins) DistribFn {
return DistribLinear(amountPerMonth, time.Hour*24*30)
}

// CreateMonthlyContinuous creates a payroll with a continuous release at a fixed rate per months of a token identified by denom.
//
// denom must be a payrolls [Coins] denom
func CreateMonthlyContinuous(namespace string, label string, beneficiary std.Address, amountPerMonth int64, denom string) seqid.ID {
if amountPerMonth <= 0 || denom == "" {
panic(errors.New("invalid input"))
}
coinsPerMonth := std.NewCoins(std.NewCoin(denom, amountPerMonth))
return Create(namespace, label, beneficiary, DistribMonthlyContinuous(coinsPerMonth), nil)
}

// CreateCDI creates a payroll that tries to match the French CDI contract
//
// Denom must be a payrolls [Coins] denom
//
// See: https://travail-emploi.gouv.fr/le-contrat-de-travail-duree-indeterminee-cdi
func CreateCDI(namespace string, label string, beneficiary std.Address, amountPerMonth int64, denom string) seqid.ID {
if amountPerMonth <= 0 || denom == "" {
panic(errors.New("invalid input"))
}
coinsPerMonth := std.NewCoins(std.NewCoin(denom, amountPerMonth))
return Create(namespace, label, beneficiary, DistribMonthlyStep(coinsPerMonth), BreakupCDI(coinsPerMonth))
}

// BreakupCDI is a breakup function that will release an amount of coins
// according to a simplified "Rupture Conventionelle" (conventional breakup) model based on the French CDI employment contract
func BreakupCDI(coinsPerMonth std.Coins) BreakupFn {
const hoursPerYear = float64(8_670)
return func(elapsed time.Duration, pauseDuration time.Duration, source CallSource) std.Coins {
if source != CallSourceCreator {
return nil
}

// conventional breakup
out := make(std.Coins, 0, len(coinsPerMonth))
worked := elapsed - pauseDuration
numYears := worked.Hours() / hoursPerYear
for _, coin := range coinsPerMonth {
newAmount := int64(0.25 * float64(coin.Amount) * numYears)
out = append(out, std.NewCoin(coin.Denom, newAmount))
}
return out
}
}
136 changes: 136 additions & 0 deletions examples/gno.land/r/demo/payrolls/payroll.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package payrolls

import (
"errors"
"std"
"time"

"gno.land/p/demo/seqid"
)

type payroll struct {
id seqid.ID
creatorAddr std.Address
vaultID string
createdAt time.Time
beneficiary std.Address
totalWithdrawn std.Coins
distrib DistribFn
breakup BreakupFn
paused bool
pausedAt time.Time
pauseDuration time.Duration
label string
breakupCoins std.Coins
stopped bool
stoppedAt time.Time
}

type CallSource uint

const (
CallSourceCreator CallSource = iota
CallSourceBeneficiary
)

func (p *payroll) freeAt(when time.Time, estimate bool) std.Coins {
if p.stopped && p.stoppedAt.Before(when) {
when = p.stoppedAt
}

var elapsed time.Duration
if p.paused && !estimate {
elapsed = p.pausedAt.Sub(p.createdAt)
} else {
elapsed = when.Sub(p.createdAt)
}
elapsed -= p.pauseDuration
res := p.distrib(elapsed)

return res
}

func (p *payroll) available() std.Coins {
return subCoins(addCoins(p.freeAt(time.Now(), false), p.breakupCoins), p.totalWithdrawn)
}

func (p *payroll) estimateDay() std.Coins {
return subCoins(p.freeAt(time.Now().Add(time.Hour*24), true), p.freeAt(time.Now(), true))
}

func (p *payroll) claim(vaultAmount std.Coins) std.Coins {
available := p.available()
claimable := clampCoins(available, vaultAmount)
p.totalWithdrawn = addCoins(p.totalWithdrawn, claimable)
return claimable
}

func (p *payroll) pause(source std.Address) {
p.assertNotStopped()
p.assertBeneficiary(source)

if p.paused {
panic(errors.New("already paused"))
}
p.paused = true
p.pausedAt = time.Now()
}

func (p *payroll) resume(source std.Address) {
p.assertNotStopped()
p.assertBeneficiary(source)

if !p.paused {
panic(errors.New("not paused"))
}
p.resumeUnsafe()
}

func (p *payroll) resumeUnsafe() {
p.paused = false
p.pauseDuration += time.Now().Sub(p.pausedAt)
}

func (p *payroll) stop(source std.Address) {
p.assertNotStopped()

breakupSource := p.assertSource(source)

if p.paused {
p.resumeUnsafe()
}
p.breakupCoins = p.getBreakupCoins(breakupSource)
p.stopped = true
p.stoppedAt = time.Now()
}

func (p *payroll) getBreakupCoins(source CallSource) std.Coins {
if p.breakup == nil {
return nil
}

return p.breakup(time.Now().Sub(p.createdAt), p.pauseDuration, source)
}

func (p *payroll) assertSource(addr std.Address) CallSource {
switch addr {
case p.beneficiary:
return CallSourceBeneficiary
case p.creatorAddr:
return CallSourceCreator
default:
panic(errors.New("unknown source"))
}
}

func (p *payroll) assertNotStopped() {
if p.stopped {
panic(errors.New("stopped"))
}
}

func (p *payroll) assertBeneficiary(addr std.Address) {
if addr != p.beneficiary {
panic(errors.New("only beneficiary allowed"))
}
}
Loading
Loading