diff --git a/config.yaml b/config.yaml index b2f1da2..1b21f88 100644 --- a/config.yaml +++ b/config.yaml @@ -18,26 +18,12 @@ event_types: frequency: one-time action_url: https://... logo: https://... - - name: get_poh - title: Get PoH credential - reward: 50 - description: Prove that you are human - short_description: Short description - frequency: one-time - expires_at: 2020-01-01T00:00:00Z - name: free_weekly title: Free weekly points reward: 100 frequency: weekly description: Get free points every week by visiting the platform and claiming your reward short_description: Short description - - name: daily_login - title: Daily login - reward: 5 - frequency: daily - description: Login every day - short_description: Short description - disabled: true - name: be_referred title: Referral welcome bonus reward: 5 @@ -45,12 +31,6 @@ event_types: description: Be referred by a friend and get a welcome bonus short_description: Short description no_auto_open: true - - name: referral_common - title: Refer new users - reward: 25 - frequency: one-time - description: Refer friends and get a reward for each friend who verifies the passport - short_description: Short description - name: referral_specific title: Refer user {:did} reward: 25 @@ -58,49 +38,21 @@ event_types: description: The user {:did} has verified the passport. Claim the reward! short_description: Short description no_auto_open: true - - name: planned - title: Planned event - reward: 25 - frequency: unlimited - description: Event that start at specified time - short_description: Short description - starts_at: 2020-01-01T00:00:00Z - - name: generate_proof_age - title: Generate proof age - reward: 25 - frequency: one-time - description: Event that become fulfilled when user create proof that prove age - short_description: Short description - - name: generate_proof_nationality - title: Generate proof nationality - reward: 50 - frequency: one-time - description: Event that become fulfilled when user create proof that prove nationality - short_description: Short description - - name: verify_proof_age - title: Verify proof age - reward: 25 - frequency: one-time - description: Event that become fulfilled when user verify someone else's proof age - short_description: Short description - - name: verify_proof_nationality - title: Verify proof nationality - reward: 50 - frequency: one-time - description: Event that become fulfilled when user verify someone else's proof nationality - short_description: Short description - - name: verified_proof_age - title: Have proof age verified - reward: 25 - frequency: one-time - description: Event that become fulfilled when another user verify you proof age (user that verify must have verified passport) - short_description: Short description - - name: verified_proof_nationality - title: Have proof nationality verified - reward: 50 - frequency: one-time - description: Event that become fulfilled when another user verify you proof nationality (user that verify must have verified passport) - short_description: Short description + +levels: + levels: + - lvl: 1 + threshold: 0 + referrals: 5 + withdrawal_allowed: false + - lvl: 2 + threshold: 20 + referrals: 5 + withdrawal_allowed: true + - lvl: 3 + threshold: 40 + referrals: 5 + withdrawal_allowed: true auth: addr: http://rarime-auth diff --git a/docs/spec/components/schemas/Balance.yaml b/docs/spec/components/schemas/Balance.yaml index a703b45..82da92d 100644 --- a/docs/spec/components/schemas/Balance.yaml +++ b/docs/spec/components/schemas/Balance.yaml @@ -8,20 +8,16 @@ allOf: type: object required: - amount - - is_verified - is_disabled - created_at - updated_at + - level properties: amount: type: integer format: int64 description: Amount of points example: 580 - is_verified: - type: boolean - description: Whether the user has scanned passport - example: true is_disabled: type: boolean description: | @@ -56,7 +52,8 @@ allOf: example: ["73k3bdYaFWM", "9csIL7dW65m"] items: type: string - is_withdrawal_allowed: - type: boolean - description: Whether the user can withdraw tokens. Returned only for the single user. - example: true + level: + type: integer + format: int + description: The level indicates user permissions and features + example: 2 diff --git a/docs/spec/components/schemas/Withdraw.yaml b/docs/spec/components/schemas/Withdraw.yaml index e720b7a..709fd15 100644 --- a/docs/spec/components/schemas/Withdraw.yaml +++ b/docs/spec/components/schemas/Withdraw.yaml @@ -10,6 +10,7 @@ allOf: required: - amount - address + - proof properties: amount: type: integer @@ -20,3 +21,7 @@ allOf: type: string description: Rarimo address to withdraw to. Can be any valid address. example: rarimo15hcd6tv7pe8hk2re7hu0zg0aphqdm2dtjrs0ds + proof: + type: object + format: json.RawMessage + description: JSON encoded ZK passport verification proof. diff --git a/internal/assets/migrations/001_initial.sql b/internal/assets/migrations/001_initial.sql index 49ad7b6..70b5feb 100644 --- a/internal/assets/migrations/001_initial.sql +++ b/internal/assets/migrations/001_initial.sql @@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS balances created_at integer NOT NULL default EXTRACT('EPOCH' FROM NOW()), updated_at integer NOT NULL default EXTRACT('EPOCH' FROM NOW()), referred_by text UNIQUE, + level INT NOT NULL ); CREATE INDEX IF NOT EXISTS balances_page_index ON balances (amount, updated_at) WHERE referred_by IS NOT NULL; diff --git a/internal/config/levels.go b/internal/config/levels.go new file mode 100644 index 0000000..f852398 --- /dev/null +++ b/internal/config/levels.go @@ -0,0 +1,77 @@ +package config + +import ( + "fmt" + "slices" + + "gitlab.com/distributed_lab/figure/v3" + "gitlab.com/distributed_lab/kit/kv" +) + +type Level struct { + Level int `fig:"lvl,required"` + Threshold int `fig:"threshold,required"` + Referrals int `fig:"referrals,required"` + WithdrawalAllowed bool `fig:"withdrawal_allowed"` +} + +type Levels map[int]Level + +func (c *config) Levels() Levels { + return c.levels.Do(func() interface{} { + var cfg struct { + Lvls []Level `fig:"levels,required"` + } + + err := figure.Out(&cfg). + From(kv.MustGetStringMap(c.getter, "levels")). + Please() + if err != nil { + panic(fmt.Errorf("failed to figure out levels config: %w", err)) + } + + if len(cfg.Lvls) == 0 { + panic(fmt.Errorf("no levels provided in config: %w", err)) + } + + res := make(Levels, len(cfg.Lvls)) + for _, v := range cfg.Lvls { + res[v.Level] = v + } + + return res + }).(Levels) +} + +// Calculate new lvl. New lvl always greater then current level +func (l Levels) LvlUp(currentLevel int, totalAmount int64) (refCoundToAdd int, newLevel int) { + lvls := make([]int, 0, len(l)) + for k, v := range l { + if k <= currentLevel { + continue + } + if int64(v.Threshold) > totalAmount { + break + } + + refCoundToAdd += v.Referrals + lvls = append(lvls, k) + } + + if len(lvls) == 0 { + return 0, currentLevel + } + + newLevel = slices.Max(lvls) + return +} + +// slices.Min will not panic because of previous logic +func (l Levels) MinLvl() int { + lvls := make([]int, 0, len(l)) + for k := range l { + lvls = append(lvls, k) + } + + return slices.Min(lvls) +} diff --git a/internal/config/main.go b/internal/config/main.go index 85b3678..533a381 100644 --- a/internal/config/main.go +++ b/internal/config/main.go @@ -20,6 +20,7 @@ type Config interface { evtypes.EventTypeser sbtcheck.SbtChecker + Levels() Levels Verifier() *zk.Verifier PointPrice() PointsPrice } @@ -33,6 +34,7 @@ type config struct { evtypes.EventTypeser sbtcheck.SbtChecker + levels comfig.Once verifier comfig.Once pointPrice comfig.Once getter kv.Getter diff --git a/internal/data/balances.go b/internal/data/balances.go index a6babef..8c619aa 100644 --- a/internal/data/balances.go +++ b/internal/data/balances.go @@ -13,6 +13,7 @@ type Balance struct { UpdatedAt int32 `db:"updated_at"` ReferredBy sql.NullString `db:"referred_by"` Rank *int `db:"rank"` + Level int `db:"level"` } type BalancesQ interface { @@ -20,6 +21,7 @@ type BalancesQ interface { Insert(Balance) error UpdateAmountBy(points int64) error SetReferredBy(referralCode string) error + SetLevel(level int) error Page(*pgdb.OffsetPageParams) BalancesQ Select() ([]Balance, error) diff --git a/internal/data/pg/balances.go b/internal/data/pg/balances.go index d8c699f..3b99b3f 100644 --- a/internal/data/pg/balances.go +++ b/internal/data/pg/balances.go @@ -35,6 +35,7 @@ func (q *balances) Insert(bal data.Balance) error { "nullifier": bal.Nullifier, "amount": bal.Amount, "referred_by": bal.ReferredBy, + "level": bal.Level, }) if err := q.db.Exec(stmt); err != nil { @@ -65,6 +66,17 @@ func (q *balances) SetReferredBy(referralCode string) error { return nil } +func (q *balances) SetLevel(level int) error { + stmt := q.updater. + Set("level", level) + + if err := q.db.Exec(stmt); err != nil { + return fmt.Errorf("set level: %w", err) + } + + return nil +} + func (q *balances) Page(page *pgdb.OffsetPageParams) data.BalancesQ { q.selector = page.ApplyTo(q.selector, "amount", "updated_at") return q diff --git a/internal/service/handlers/claim_event.go b/internal/service/handlers/claim_event.go index c11dac0..daed122 100644 --- a/internal/service/handlers/claim_event.go +++ b/internal/service/handlers/claim_event.go @@ -6,7 +6,6 @@ import ( "github.com/rarimo/decentralized-auth-svc/pkg/auth" "github.com/rarimo/rarime-points-svc/internal/data" - "github.com/rarimo/rarime-points-svc/internal/data/evtypes" "github.com/rarimo/rarime-points-svc/internal/service/requests" "github.com/rarimo/rarime-points-svc/resources" "gitlab.com/distributed_lab/ape" @@ -48,15 +47,6 @@ func ClaimEvent(w http.ResponseWriter, r *http.Request) { ape.RenderErr(w, problems.Forbidden()) return } - if event.Type == evtypes.TypePassportScan { - if event.PointsAmount == nil { - Log(r).WithError(err).Errorf("PointsAmount can't be nil for event %s", - event.Type) - ape.RenderErr(w, problems.InternalError()) - return - } - evType.Reward = *event.PointsAmount - } balance, err := BalancesQ(r).FilterByNullifier(event.Nullifier).FilterDisabled().Get() if err != nil { @@ -70,7 +60,7 @@ func ClaimEvent(w http.ResponseWriter, r *http.Request) { return } - event, err = claimEventWithPoints(*event, evType.Reward, r) + event, err = claimEventWithPoints(r, *event, evType.Reward, balance) if err != nil { Log(r).WithError(err).Errorf("Failed to claim event %s and accrue %d points to the balance %s", event.ID, evType.Reward, event.Nullifier) @@ -90,8 +80,25 @@ func ClaimEvent(w http.ResponseWriter, r *http.Request) { } // requires: event exist -func claimEventWithPoints(event data.Event, reward int64, r *http.Request) (claimed *data.Event, err error) { +func claimEventWithPoints(r *http.Request, event data.Event, reward int64, balance *data.Balance) (claimed *data.Event, err error) { err = EventsQ(r).Transaction(func() error { + refsCount, level := Levels(r).LvlUp(balance.Level, reward+balance.Amount) + if level != balance.Level { + count, err := ReferralsQ(r).FilterByNullifier(event.Nullifier).Count() + if err != nil { + return fmt.Errorf("failed to get referral count: %w", err) + } + + refToAdd := prepareReferralsToAdd(event.Nullifier, uint64(refsCount), count) + if err = ReferralsQ(r).Insert(refToAdd...); err != nil { + return fmt.Errorf("failed to insert referrals: %w", err) + } + + if err = BalancesQ(r).FilterByNullifier(event.Nullifier).SetLevel(level); err != nil { + return fmt.Errorf("failed to update level: %w", err) + } + } + updated, err := EventsQ(r).FilterByID(event.ID).Update(data.EventClaimed, nil, &reward) if err != nil { return fmt.Errorf("update event status: %w", err) diff --git a/internal/service/handlers/create_balance.go b/internal/service/handlers/create_balance.go index fc6152c..4976553 100644 --- a/internal/service/handlers/create_balance.go +++ b/internal/service/handlers/create_balance.go @@ -101,6 +101,7 @@ func createBalanceWithEvents(nullifier, refBy string, events []data.Event, r *ht err := BalancesQ(r).Insert(data.Balance{ Nullifier: nullifier, ReferredBy: sql.NullString{String: refBy, Valid: refBy != ""}, + Level: Levels(r).MinLvl(), }) if err != nil { @@ -121,6 +122,7 @@ func createBalanceWithEventsAndReferrals(nullifier, refBy string, events []data. err := BalancesQ(r).Insert(data.Balance{ Nullifier: nullifier, ReferredBy: sql.NullString{String: refBy, Valid: refBy != ""}, + Level: Levels(r).MinLvl(), }) if err != nil { diff --git a/internal/service/handlers/ctx.go b/internal/service/handlers/ctx.go index 83f2e09..d292c67 100644 --- a/internal/service/handlers/ctx.go +++ b/internal/service/handlers/ctx.go @@ -26,6 +26,7 @@ const ( broadcasterCtxKey pointPriceCtxKey verifierCtxKey + levelsCtxKey ) func CtxLog(entry *logan.Entry) func(context.Context) context.Context { @@ -127,3 +128,13 @@ func CtxVerifier(verifier *zk.Verifier) func(context.Context) context.Context { func Verifier(r *http.Request) *zk.Verifier { return r.Context().Value(verifierCtxKey).(*zk.Verifier) } + +func CtxLevels(levels config.Levels) func(context.Context) context.Context { + return func(ctx context.Context) context.Context { + return context.WithValue(ctx, levelsCtxKey, levels) + } +} + +func Levels(r *http.Request) config.Levels { + return r.Context().Value(levelsCtxKey).(config.Levels) +} diff --git a/internal/service/handlers/get_balance.go b/internal/service/handlers/get_balance.go index e866df3..683a7b5 100644 --- a/internal/service/handlers/get_balance.go +++ b/internal/service/handlers/get_balance.go @@ -66,6 +66,7 @@ func newBalanceModel(balance data.Balance) resources.Balance { CreatedAt: balance.CreatedAt, UpdatedAt: balance.UpdatedAt, Rank: balance.Rank, + Level: balance.Level, }, } } diff --git a/internal/service/handlers/verify_passport.go b/internal/service/handlers/verify_passport.go index f92a9b3..6356cde 100644 --- a/internal/service/handlers/verify_passport.go +++ b/internal/service/handlers/verify_passport.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "fmt" "math/big" "net/http" @@ -87,11 +88,42 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { return } - _, err = EventsQ(r). - FilterByID(event.ID). - Update(data.EventFulfilled, nil, nil) + evType = EventTypes(r).Get(evtypes.TypeReferralSpecific, evtypes.FilterInactive) + if evType == nil { + Log(r).Debug("Referral event type is disabled or expired, not accruing points to referrer") + } + + err = EventsQ(r).Transaction(func() (err error) { + if evType != nil { + // ReferredBy always valid because of the previous logic + referral, err := ReferralsQ(r).Get(balance.ReferredBy.String) + if err != nil { + return fmt.Errorf("failed to get referral by ID: %w", err) + } + + err = EventsQ(r).Insert(data.Event{ + Nullifier: referral.Nullifier, + Type: evType.Name, + Status: data.EventFulfilled, + Meta: data.Jsonb(fmt.Sprintf(`{"nullifier": "%s"}`, nullifier)), + }) + if err != nil { + return fmt.Errorf("add event for referrer: %w", err) + } + } + + _, err = EventsQ(r). + FilterByID(event.ID). + Update(data.EventFulfilled, nil, nil) + if err != nil { + return fmt.Errorf("failed to update passport scan event: %w", err) + } + + return nil + }) + if err != nil { - Log(r).WithError(err).Error("Failed to update passport scan event") + Log(r).WithError(err).Error("Failed to add referral event and update verify passport event") ape.RenderErr(w, problems.InternalError()) return } diff --git a/internal/service/handlers/withdraw.go b/internal/service/handlers/withdraw.go index a15c85b..f99de56 100644 --- a/internal/service/handlers/withdraw.go +++ b/internal/service/handlers/withdraw.go @@ -1,20 +1,28 @@ package handlers import ( + "encoding/json" "fmt" + "math/big" "net/http" cosmos "github.com/cosmos/cosmos-sdk/types" bank "github.com/cosmos/cosmos-sdk/x/bank/types" + "github.com/ethereum/go-ethereum/common/hexutil" validation "github.com/go-ozzo/ozzo-validation/v4" + zkptypes "github.com/iden3/go-rapidsnark/types" "github.com/rarimo/decentralized-auth-svc/pkg/auth" "github.com/rarimo/rarime-points-svc/internal/data" "github.com/rarimo/rarime-points-svc/internal/service/requests" "github.com/rarimo/rarime-points-svc/resources" + zk "github.com/rarimo/zkverifier-kit" "gitlab.com/distributed_lab/ape" "gitlab.com/distributed_lab/ape/problems" + "gitlab.com/distributed_lab/logan/v3/errors" ) +const usaAuthorithy = "8571562" + func Withdraw(w http.ResponseWriter, r *http.Request) { req, err := requests.NewWithdraw(r) if err != nil { @@ -33,12 +41,14 @@ func Withdraw(w http.ResponseWriter, r *http.Request) { return } - if !auth.Authenticates(UserClaims(r), auth.UserGrant(req.Data.ID)) { + nullifier := req.Data.ID + + if !auth.Authenticates(UserClaims(r), auth.UserGrant(nullifier)) { ape.RenderErr(w, problems.Unauthorized()) return } - balance, err := BalancesQ(r).FilterByNullifier(req.Data.ID).Get() + balance, err := BalancesQ(r).FilterByNullifier(nullifier).Get() if err != nil { log.WithError(err).Error("Failed to get balance by nullifier") ape.RenderErr(w, problems.InternalError()) @@ -50,20 +60,38 @@ func Withdraw(w http.ResponseWriter, r *http.Request) { return } - if err = isEligibleToWithdraw(balance, req.Data.Attributes.Amount); err != nil { + var proof zkptypes.ZKProof + if err := json.Unmarshal(req.Data.Attributes.Proof, &proof); err != nil { + ape.RenderErr(w, problems.BadRequest(err)...) + return + } + + // MustDecode will never panic, because of the previous logic + proof.PubSignals[zk.Nullifier] = new(big.Int).SetBytes(hexutil.MustDecode(nullifier)).String() + if err := Verifier(r).VerifyProof(proof, zk.WithProofSelectorValue("23073")); err != nil { + ape.RenderErr(w, problems.BadRequest(err)...) + return + } + + if proof.PubSignals[zk.Citizenship] == usaAuthorithy { + ape.RenderErr(w, problems.BadRequest(validation.Errors{"authority": errors.New("Incorrect authority")})...) + return + } + + if err = isEligibleToWithdraw(r, balance, req.Data.Attributes.Amount); err != nil { ape.RenderErr(w, problems.BadRequest(err)...) return } var withdrawal *data.Withdrawal err = EventsQ(r).Transaction(func() error { - err = BalancesQ(r).FilterByNullifier(req.Data.ID).UpdateAmountBy(-req.Data.Attributes.Amount) + err = BalancesQ(r).FilterByNullifier(nullifier).UpdateAmountBy(-req.Data.Attributes.Amount) if err != nil { return fmt.Errorf("decrease points amount: %w", err) } withdrawal, err = WithdrawalsQ(r).Insert(data.Withdrawal{ - Nullifier: req.Data.ID, + Nullifier: nullifier, Amount: req.Data.Attributes.Amount, Address: req.Data.Attributes.Address, }) @@ -84,7 +112,7 @@ func Withdraw(w http.ResponseWriter, r *http.Request) { } // balance should exist cause of previous logic - balance, err = BalancesQ(r).GetWithRank(req.Data.ID) + balance, err = BalancesQ(r).GetWithRank(nullifier) if err != nil { log.WithError(err).Error("Failed to get balance by nullifier with rank") ape.RenderErr(w, problems.InternalError()) @@ -112,7 +140,7 @@ func newWithdrawResponse(w data.Withdrawal, balance data.Balance) *resources.Wit return &resp } -func isEligibleToWithdraw(balance *data.Balance, amount int64) error { +func isEligibleToWithdraw(r *http.Request, balance *data.Balance, amount int64) error { mapValidationErr := func(field, format string, a ...any) validation.Errors { return validation.Errors{ field: fmt.Errorf(format, a...), @@ -124,6 +152,8 @@ func isEligibleToWithdraw(balance *data.Balance, amount int64) error { return mapValidationErr("is_disabled", "user must be referred to withdraw") case balance.Amount < amount: return mapValidationErr("data/attributes/amount", "insufficient balance: %d", balance.Amount) + case !Levels(r)[balance.Level].WithdrawalAllowed: + return mapValidationErr("withdrawal not allowed", "user must up level to have withdraw ability") } return nil diff --git a/internal/service/requests/withdraw.go b/internal/service/requests/withdraw.go index c0a57c4..181d1dc 100644 --- a/internal/service/requests/withdraw.go +++ b/internal/service/requests/withdraw.go @@ -3,6 +3,7 @@ package requests import ( "encoding/json" "net/http" + "strings" "github.com/go-chi/chi" validation "github.com/go-ozzo/ozzo-validation/v4" @@ -10,15 +11,18 @@ import ( ) func NewWithdraw(r *http.Request) (req resources.WithdrawRequest, err error) { - nullifier := chi.URLParam(r, "nullifier") - if err = json.NewDecoder(r.Body).Decode(&req); err != nil { err = newDecodeError("body", err) return } + req.Data.ID = strings.ToLower(req.Data.ID) + return req, validation.Errors{ - "data/id": validation.Validate(req.Data.ID, validation.Required, validation.In(nullifier)), + "data/id": validation.Validate(req.Data.ID, + validation.Required, + validation.In(strings.ToLower(chi.URLParam(r, "nullifier"))), + validation.Match(nullifierRegexp)), "data/type": validation.Validate(req.Data.Type, validation.Required, validation.In(resources.WITHDRAW)), "data/attributes/amount": validation.Validate(req.Data.Attributes.Amount, validation.Required, validation.Min(1)), "data/attributes/address": validation.Validate(req.Data.Attributes.Address, validation.Required), diff --git a/internal/service/router.go b/internal/service/router.go index ae90838..77fcabe 100644 --- a/internal/service/router.go +++ b/internal/service/router.go @@ -20,6 +20,7 @@ func Run(ctx context.Context, cfg config.Config) { handlers.CtxEventTypes(cfg.EventTypes()), handlers.CtxBroadcaster(cfg.Broadcaster()), handlers.CtxPointPrice(cfg.PointPrice()), + handlers.CtxLevels(cfg.Levels()), ), handlers.DBCloneMiddleware(cfg.DB()), ) diff --git a/resources/model_balance_attributes.go b/resources/model_balance_attributes.go index 7df05b4..6ad6b76 100644 --- a/resources/model_balance_attributes.go +++ b/resources/model_balance_attributes.go @@ -15,10 +15,8 @@ type BalanceAttributes struct { CreatedAt int32 `json:"created_at"` // Whether the user was not referred by anybody, but the balance with some events was reserved. It happens when the user fulfills some event before the balance creation. IsDisabled bool `json:"is_disabled"` - // Whether the user has scanned passport - IsVerified bool `json:"is_verified"` - // Whether the user can withdraw tokens. Returned only for the single user. - IsWithdrawalAllowed *bool `json:"is_withdrawal_allowed,omitempty"` + // The level indicates how many possibilities the user has + Level int `json:"level"` // Rank of the user in the full leaderboard. Returned only for the single user. Rank *int `json:"rank,omitempty"` // Unix timestamp of the last points accruing diff --git a/resources/model_withdraw_attributes.go b/resources/model_withdraw_attributes.go index 2f6bdb6..65a0cdb 100644 --- a/resources/model_withdraw_attributes.go +++ b/resources/model_withdraw_attributes.go @@ -4,9 +4,13 @@ package resources +import "encoding/json" + type WithdrawAttributes struct { // Rarimo address to withdraw to. Can be any valid address. Address string `json:"address"` // Amount of points to withdraw Amount int64 `json:"amount"` + // JSON encoded ZK passport verification proof. + Proof json.RawMessage `json:"proof"` }