points.gno
4.87 Kb · 160 lines
1// Package memba_points_v1 is a soulbound (non-transferable), no-banker on-chain reputation ledger
2// for the Memba ecosystem. Addresses accrue non-transferable "Memba Points" (MP) via authorized
3// awarders (the owner or whitelisted awarder realms); MP is READ by other surfaces for status
4// tiers, feature gating, and leaderboards.
5//
6// SAFETY: there is NO transfer, NO banker, and NO OriginSend anywhere — the entire fund-drain risk
7// class (the OriginSend/IsUserCall/F-1 family) simply does not apply. The ONLY trust boundary is
8// who may Award/Revoke (the owner ∪ whitelisted awarder realms); every mint/burn emits an event.
9// Non-transferability is by construction: there is no Transfer/Send/Approve, so points can never be
10// bought, sold, or wash-traded — MP is reputation, not money ($MEMBA is the money).
11package memba_points_v1
12
13import (
14 "chain"
15 "chain/runtime"
16
17 "gno.land/p/nt/avl/v0"
18 "gno.land/p/nt/ufmt/v0"
19)
20
21// AdminAddress is the samcrew-core deploy multisig (same as the other Memba realms). It inits
22// `owner`; hand it to the memba_dao executor post-deploy via the 2-step ownership transfer.
23const AdminAddress = "g1x7k4628w93a7wzdhqc06atzx0v50rnshweuxu0"
24
25const (
26 // MaxAward caps a single Award/Revoke amount — a fat-finger + int64-overflow guard.
27 MaxAward = int64(1_000_000_000)
28 maxInt64 = int64(9223372036854775807)
29)
30
31// Account is one address's soulbound points balance.
32type Account struct {
33 Points int64
34 UpdatedAt int64 // block height of last change
35}
36
37var (
38 owner address
39 pendingOwner address
40 paused bool
41 accounts = avl.NewTree() // addr string -> *Account
42 awarders = avl.NewTree() // awarder realm pkgpath -> bool
43 totalPoints int64 // Σ current points across all accounts (O(1))
44 holderCount int // number of accounts with a positive balance
45)
46
47func init() { owner = address(AdminAddress) }
48
49func assertOwner(cur realm) {
50 if !cur.IsCurrent() {
51 panic("spoofed realm")
52 }
53 if cur.Previous().Address() != owner {
54 panic("unauthorized: owner only")
55 }
56}
57
58func assertNotPaused() {
59 if paused {
60 panic("points awarding is paused")
61 }
62}
63
64func isAwarder(pkgpath string) bool {
65 _, ok := awarders.Get(pkgpath)
66 return ok
67}
68
69// assertAwardAuth is the single trust boundary: only the owner (a direct signer) or a whitelisted
70// awarder realm may mint/burn points. Anti-sybil lives in the awarder's own verified flow — this
71// realm only enforces that the source is trusted.
72func assertAwardAuth(cur realm) {
73 if !cur.IsCurrent() {
74 panic("spoofed realm")
75 }
76 pr := cur.Previous()
77 if pr.Address() != owner && !isAwarder(pr.PkgPath()) {
78 panic("unauthorized: owner or whitelisted awarder realm only")
79 }
80}
81
82// mustAccount returns the account for `a`, or a fresh zero-value one (not yet stored).
83func mustAccount(a address) *Account {
84 v, ok := accounts.Get(a.String())
85 if !ok {
86 return &Account{}
87 }
88 return v.(*Account)
89}
90
91// Award grants `amount` non-transferable points to `to`. Owner or a whitelisted awarder realm only.
92// Bounded amount + int64 overflow guard. Emits PointsAwarded (with the awarding source realm).
93func Award(cur realm, to address, amount int64, reason string) {
94 assertNotPaused()
95 assertAwardAuth(cur)
96 if to == "" {
97 panic("recipient must be non-empty")
98 }
99 if amount <= 0 || amount > MaxAward {
100 panic("amount out of range (1..MaxAward)")
101 }
102 a := mustAccount(to)
103 if a.Points > maxInt64-amount {
104 panic("points overflow")
105 }
106 if totalPoints > maxInt64-amount {
107 panic("total points overflow")
108 }
109 old := a.Points
110 a.Points += amount
111 a.UpdatedAt = runtime.ChainHeight()
112 accounts.Set(to.String(), a)
113 totalPoints += amount
114 if old == 0 {
115 holderCount++
116 }
117 rankApply(to.String(), old, a.Points)
118 chain.Emit("PointsAwarded", "to", to.String(), "amount", itoa64(amount),
119 "reason", reason, "source", cur.Previous().PkgPath())
120}
121
122// Revoke removes up to `amount` points from `from`, clamped at zero (never negative). Owner or a
123// whitelisted awarder realm only — moderation / clawback. A no-op on a zero balance. Emits
124// PointsRevoked with the amount actually removed.
125func Revoke(cur realm, from address, amount int64, reason string) {
126 assertNotPaused()
127 assertAwardAuth(cur)
128 if amount <= 0 || amount > MaxAward {
129 panic("amount out of range (1..MaxAward)")
130 }
131 a := mustAccount(from)
132 removed := amount
133 if removed > a.Points {
134 removed = a.Points
135 }
136 if removed == 0 {
137 return
138 }
139 old := a.Points
140 a.Points -= removed
141 a.UpdatedAt = runtime.ChainHeight()
142 accounts.Set(from.String(), a)
143 totalPoints -= removed
144 if a.Points == 0 {
145 holderCount--
146 }
147 rankApply(from.String(), old, a.Points)
148 chain.Emit("PointsRevoked", "from", from.String(), "amount", itoa64(removed), "reason", reason)
149}
150
151// GetPoints returns an address's current points (0 if unknown). Pure.
152func GetPoints(a string) int64 {
153 v, ok := accounts.Get(a)
154 if !ok {
155 return 0
156 }
157 return v.(*Account).Points
158}
159
160func itoa64(n int64) string { return ufmt.Sprintf("%d", n) }