Skip to content

Commit

Permalink
feat: referral (#459)
Browse files Browse the repository at this point in the history
* demo: referral

* update considerations

* remove unused

* global getter

* update getter

* time guard
  • Loading branch information
notJoon authored and moul committed Jan 20, 2025
1 parent 1429e2b commit a9cea3f
Show file tree
Hide file tree
Showing 10 changed files with 1,150 additions and 1 deletion.
109 changes: 109 additions & 0 deletions referral/doc.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Package referral implements a referral system on Gno. It allows
// any authorized caller to register, update, or remove referral
// information. A referral link is defined as a mapping from one
// address (the "user") to another address (the "referrer").
//
// ## Overview
//
// The referral package is composed of the following components:
//
// 1. **errors.gno**: Provides custom error types (ReferralError) with
// specific error codes and messages.
// 2. **utils.gno**: Contains utility functions for permission checks,
// especially isValidCaller, which ensures only specific, pre-authorized
// callers (e.g., governance or router addresses) can invoke the core
// functions.
// 3. **types.gno**: Defines core constants for event types, attributes,
// and the ReferralKeeper interface, which outlines the fundamental
// methods of the referral system (Register, Update, Remove, etc.).
// 4. **keeper.gno**: Implements the actual business logic behind the
// ReferralKeeper interface. It uses an AVL Tree (avl.Tree) to store
// referral data (address -> referrer). The keeper methods emit events
// when a new referral is registered, updated, or removed.
// 5. **referral.gno**: Exposes a public API (the Referral struct)
// that delegates to the keeper, providing external contracts or
// applications a straightforward way to interact with the system.
//
// ## Workflow
//
// Typical usage of this contract follows these steps:
//
// 1. A caller with valid permissions invokes Register, Update, or Remove
// through the Referral struct.
// 2. The Referral struct forwards the request to the internal keeper
// methods.
// 3. The keeper checks caller permission (via isValidCaller), validates
// addresses, and stores or removes data in the AVL Tree.
// 4. An event is emitted for off-chain or cross-module notifications.
//
// ## Integration with Other Contracts
//
// Other contracts can leverage the referral system in two major ways:
//
// 1. **Direct Calls**: If you wish to directly call this contract,
// instantiate the Referral object (via NewReferral) and invoke its
// methods, assuming you meet the authorized-caller criteria.
//
// 2. **Embedded or Extended**: If you have a complex module that includes
// referral features, import this package and embed a Referral instance
// in your own keeper. This way, you can handle additional validations
// or custom logic before delegating to the existing referral functions.
//
// ## Error Handling
//
// The package defines several error types through ReferralError:
// - `ErrInvalidAddress`: Returned when an address format is invalid
// - `ErrUnauthorized`: Returned when the caller lacks permission
// - `ErrNotFound`: Returned when attempting to get a non-existent referral
// - `ErrZeroAddress`: Returned when attempting operations with zero address
//
// ## Example: Integration with a Staking Contract
//
// Suppose you have a staking contract that wants to reward referrers
// when a new user stakes tokens:
//
// ```go
//
// import (
// "std"
// "gno.land/r/gnoswap/v1/referral"
// "gno.land/p/demo/mystaking" // example staking contract
// )
//
// func rewardReferrerOnStake(user std.Address, amount int) {
// // 1) Access the referral system
// r := referral.NewReferral()
//
// // 2) Get the user's referrer
// refAddr, err := r.GetReferral(user)
// if err != nil {
// // handle error or skip if not found
// return
// }
//
// // 3) Reward the referrer
// mystaking.AddReward(refAddr, calculateReward(amount))
// }
//
// ```
//
// In this simple example, the staking contract checks if the user has
// a referrer by calling `GetReferral`. If a referrer is found, it then
// calculates a reward based on the staked amount.
//
// ## Limitations and Constraints
//
// - A user can have only one referrer at a time
// - Once a referral is removed, it cannot be automatically restored
// - Only authorized contracts can modify referral relationships
// - Address validation is strict and requires proper Bech32 format
//
// # Notes
//
// - The contract strictly enforces caller restrictions via isValidCaller.
// Make sure to configure it to permit only the addresses or roles that
// should be able to register or update referrals.
// - Zero addresses are treated as a trigger for removing a referral record.
// - The system emits events (register_referral, update_referral, remove_referral)
// which can be consumed by other on-chain or off-chain services.
package referral
63 changes: 63 additions & 0 deletions referral/errors.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package referral

import (
"gno.land/p/demo/ufmt"
)

const (
ErrNone = iota
ErrInvalidAddress
ErrZeroAddress
ErrSelfReferral
ErrUnauthorized
ErrInvalidCaller
ErrCyclicReference
ErrTooManyRequests
ErrNotFound
)

var errorMessages = map[int]string{
ErrInvalidAddress: "invalid address format",
ErrZeroAddress: "zero address is not allowed",
ErrSelfReferral: "self referral is not allowed",
ErrUnauthorized: "unauthorized caller",
ErrInvalidCaller: "invalid caller",
ErrCyclicReference: "cyclic reference is not allowed",
ErrTooManyRequests: "too many requests: operations allowed once per 24 hours for each address",
ErrNotFound: "referral not found",
}

type ReferralError struct {
Code int
Message string
Err error
}

func (e *ReferralError) Error() string {
// TODO: format error message to follow previous error message format
if e.Err != nil {
return ufmt.Sprintf("code: %d, message: %s, error: %v", e.Code, e.Message, e.Err)
}
return ufmt.Sprintf("code: %d, message: %s", e.Code, e.Message)
}

func (e *ReferralError) Unwrap() error {
return e.Err
}

func NewError(code int, args ...interface{}) *ReferralError {
msg := errorMessages[code]
var err error

if len(args) > 0 {
if lastArg, ok := args[len(args)-1].(error); ok {
err = lastArg
}
}

return &ReferralError{
Code: code,
Message: msg,
Err: err,
}
}
41 changes: 41 additions & 0 deletions referral/global_keeper.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package referral

import "std"

// gReferralKeeper is the global instance of the referral keeper
var gReferralKeeper ReferralKeeper

func init() {
gReferralKeeper = NewKeeper()
}

// GetKeeper returns the global instance of the referral keeper
//
// Example:
//
// // In other packages:
// keeper := referral.GetKeeper()
// keeper.register(addr, refAddr)
func GetKeeper() ReferralKeeper {
return gReferralKeeper
}

func GetReferral(addr string) string {
referral, err := gReferralKeeper.get(std.Address(addr))
if err != nil {
panic(err)
}
return referral.String()
}

func HasReferral(addr string) bool {
referral, err := gReferralKeeper.get(std.Address(addr))
if err != nil {
return false
}
return referral != zeroAddress
}

func IsEmpty() bool {
return gReferralKeeper.isEmpty()
}
1 change: 1 addition & 0 deletions referral/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/r/gnoswap/v1/referral
177 changes: 177 additions & 0 deletions referral/keeper.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package referral

import (
"std"
"time"

"gno.land/p/demo/avl"
"gno.land/p/demo/ufmt"
)

const (
// MinTimeBetweenUpdates represents minimum duration between operations
MinTimeBetweenUpdates int64 = 24 * 60 * 60
)

// keeper implements the `ReferralKeeper` interface using an AVL tree for storage.
type keeper struct {
store *avl.Tree
lastOps map[string]int64
}

// check interface implementation at compile time
var _ ReferralKeeper = &keeper{}

// NewKeeper creates and returns a new instance of ReferralKeeper.
// The keeper is initialized with an empty AVL tree for storing referral relationships.
func NewKeeper() ReferralKeeper {
return &keeper{
store: avl.NewTree(),
lastOps: make(map[string]int64),
}
}

// register implements the `register` method of the `ReferralKeeper` interface.
// It sets a new referral relationship between the given address and referral address.
func (k *keeper) register(addr, refAddr std.Address) error {
return k.setReferral(addr, refAddr, EventTypeRegister)
}

// update implements the `update` method of the `ReferralKeeper` interface.
// It updates the referral address for a given address.
func (k *keeper) update(addr, newRefAddr std.Address) error {
return k.setReferral(addr, newRefAddr, EventTypeUpdate)
}

// setReferral handles the common logic for registering and updating referrals.
// It validates the addresses and caller, then stores the referral relationship.
//
// Note: The current implementation allows circular references, but since it only manages
// simple reference relationships, cycles are not a significant issue. However, when introducing
// a referral-based reward system in the future or adding business logic where cycles could cause problems,
// it will be necessary to implement validation checks.
//
// TODO: need to discuss what values to emit as event
func (k *keeper) setReferral(addr, refAddr std.Address, eventType string) error {
if err := isValidCaller(std.PrevRealm().Addr()); err != nil {
return err
}

if err := k.validateAddresses(addr, refAddr); err != nil {
return err
}

if err := k.checkRateLimit(addr.String()); err != nil {
// XXX: because of the weird type-related errors, this is the only
// part where we need to use `ufmt.Errorf` to build error message.
return ufmt.Errorf("too many requests")
}

if refAddr == zeroAddress {
std.Emit(
EventTypeRemove,
"removedAddress", addr.String(),
)
return k.remove(addr)
}

k.store.Set(addr.String(), refAddr.String())
std.Emit(
eventType,
"myAddress", addr.String(),
"refAddress", refAddr.String(),
)

return nil
}

// validateAddresses checks if the given addresses are valid for referral
func (k *keeper) validateAddresses(addr, refAddr std.Address) error {
if !addr.IsValid() || (refAddr != zeroAddress && !refAddr.IsValid()) {
return NewError(ErrInvalidAddress)
}
if addr == refAddr {
return NewError(ErrSelfReferral)
}
return nil
}

// remove implements the `remove` method of the `ReferralKeeper` interface.
// It validates the caller and address before removing the referral relationship.
//
// TODO: need to discuss what values to emit as event
func (k *keeper) remove(target std.Address) error {
if err := isValidCaller(std.PrevRealm().Addr()); err != nil {
return err
}

if !target.IsValid() {
return NewError(ErrInvalidAddress)
}

if err := k.checkRateLimit(target.String()); err != nil {
return err
}

k.store.Remove(target.String())

// TODO: update event
std.Emit(
EventTypeRemove,
"removedAddress", target.String(),
)

return nil
}

// has implements the `has` method of the `ReferralKeeper` interface.
// It checks if a referral relationship exists for a given address.
func (k *keeper) has(addr std.Address) bool {
_, exists := k.store.Get(addr.String())
return exists
}

// get implements the `get` method of the `ReferralKeeper` interface.
// It retrieves the referral address for a given address.
func (k *keeper) get(addr std.Address) (std.Address, error) {
if !addr.IsValid() {
return zeroAddress, NewError(ErrInvalidAddress)
}

val, ok := k.store.Get(addr.String())
if !ok {
return zeroAddress, NewError(ErrNotFound)
}

refAddr, ok := val.(string)
if !ok {
return zeroAddress, NewError(ErrInvalidAddress)
}

return std.Address(refAddr), nil
}

func (k *keeper) isEmpty() bool {
empty := true
k.store.Iterate("", "", func(key string, value interface{}) bool {
empty = false
return true // stop iteration on first item
})
return empty
}

// checkRateLimit verifies if enough time has passed since the last operation
func (k *keeper) checkRateLimit(addr string) error {
now := time.Now().Unix()

if lastOpTime, exists := k.lastOps[addr]; exists {
timeSinceLastOp := now - lastOpTime

if timeSinceLastOp < MinTimeBetweenUpdates {
return NewError(ErrTooManyRequests)
}
}

k.lastOps[addr] = now
return nil
}
Loading

0 comments on commit a9cea3f

Please sign in to comment.