crowdfund.gno
6.19 Kb · 247 lines
1// Package crowdfund is an accounting-only port of the classic Solidity
2// Kickstarter-style crowdfunding contract to a gno.land realm.
3//
4// A creator Launches a campaign with a funding goal and a duration (in blocks).
5// Backers Pledge (and may Unpledge before the deadline). After the deadline the
6// creator can Claim if the goal was met, otherwise backers can Refund.
7//
8// No real coin moves: pledges are tracked as plain uint64 accounting, exactly
9// like the mapping(address => uint) balances of the Solidity original.
10package crowdfund
11
12import (
13 "strconv"
14
15 "chain"
16 "chain/runtime"
17 "chain/runtime/unsafe"
18
19 "gno.land/p/nt/avl/v0"
20)
21
22// Campaign holds the state of a single crowdfunding campaign.
23type Campaign struct {
24 ID int
25 Creator address
26 Goal uint64
27 Deadline int64 // block height after which the campaign is closed
28 Pledged uint64 // running total pledged
29 Claimed bool // creator already claimed the funds
30 pledges *avl.Tree // backer address (string) -> pledged uint64
31}
32
33var (
34 campaigns = avl.NewTree() // padded id -> *Campaign (ordered by id)
35 nextID = 1
36)
37
38// key zero-pads an id so avl iteration is in numeric order.
39func key(id int) string {
40 s := strconv.Itoa(id)
41 for len(s) < 12 {
42 s = "0" + s
43 }
44 return s
45}
46
47func mustGet(id int) *Campaign {
48 v := campaigns.Get(key(id))
49 if v == nil {
50 panic("campaign not found")
51 }
52 return v.(*Campaign)
53}
54
55// Launch creates a new campaign owned by the caller and returns its id.
56func Launch(cur realm, goal uint64, durationBlocks int) int {
57 if goal == 0 {
58 panic("goal must be > 0")
59 }
60 if durationBlocks <= 0 {
61 panic("duration must be > 0")
62 }
63 caller := unsafe.PreviousRealm().Address()
64 id := nextID
65 nextID++
66 c := &Campaign{
67 ID: id,
68 Creator: caller,
69 Goal: goal,
70 Deadline: runtime.ChainHeight() + int64(durationBlocks),
71 pledges: avl.NewTree(),
72 }
73 campaigns.Set(key(id), c)
74 chain.Emit("Launch",
75 "id", strconv.Itoa(id),
76 "creator", caller.String(),
77 "goal", strconv.FormatUint(goal, 10))
78 return id
79}
80
81// Pledge backs campaign id with amount (before the deadline).
82func Pledge(cur realm, id int, amount uint64) {
83 if amount == 0 {
84 panic("amount must be > 0")
85 }
86 c := mustGet(id)
87 if runtime.ChainHeight() > c.Deadline {
88 panic("campaign ended")
89 }
90 caller := unsafe.PreviousRealm().Address()
91 prev := uint64(0)
92 if v := c.pledges.Get(caller.String()); v != nil {
93 prev = v.(uint64)
94 }
95 c.pledges.Set(caller.String(), prev+amount)
96 c.Pledged += amount
97 chain.Emit("Pledge",
98 "id", strconv.Itoa(id),
99 "from", caller.String(),
100 "amount", strconv.FormatUint(amount, 10))
101}
102
103// Unpledge withdraws amount from the caller's pledge (before the deadline).
104func Unpledge(cur realm, id int, amount uint64) {
105 if amount == 0 {
106 panic("amount must be > 0")
107 }
108 c := mustGet(id)
109 if runtime.ChainHeight() > c.Deadline {
110 panic("campaign ended")
111 }
112 caller := unsafe.PreviousRealm().Address()
113 v := c.pledges.Get(caller.String())
114 if v == nil {
115 panic("nothing pledged")
116 }
117 have := v.(uint64)
118 if amount > have {
119 panic("amount exceeds pledge")
120 }
121 if remaining := have - amount; remaining == 0 {
122 c.pledges.Remove(caller.String())
123 } else {
124 c.pledges.Set(caller.String(), remaining)
125 }
126 c.Pledged -= amount
127 chain.Emit("Unpledge",
128 "id", strconv.Itoa(id),
129 "from", caller.String(),
130 "amount", strconv.FormatUint(amount, 10))
131}
132
133// Claim lets the creator take the funds if the goal was met after the deadline.
134func Claim(cur realm, id int) {
135 c := mustGet(id)
136 if runtime.ChainHeight() <= c.Deadline {
137 panic("campaign not ended")
138 }
139 caller := unsafe.PreviousRealm().Address()
140 if caller != c.Creator {
141 panic("only creator can claim")
142 }
143 if c.Pledged < c.Goal {
144 panic("goal not reached")
145 }
146 if c.Claimed {
147 panic("already claimed")
148 }
149 c.Claimed = true
150 chain.Emit("Claim",
151 "id", strconv.Itoa(id),
152 "amount", strconv.FormatUint(c.Pledged, 10))
153}
154
155// Refund returns the caller's pledge if the campaign failed after the deadline.
156func Refund(cur realm, id int) {
157 c := mustGet(id)
158 if runtime.ChainHeight() <= c.Deadline {
159 panic("campaign not ended")
160 }
161 if c.Pledged >= c.Goal {
162 panic("campaign succeeded, no refund")
163 }
164 caller := unsafe.PreviousRealm().Address()
165 v := c.pledges.Get(caller.String())
166 if v == nil {
167 panic("nothing to refund")
168 }
169 amount := v.(uint64)
170 c.pledges.Remove(caller.String())
171 c.Pledged -= amount
172 chain.Emit("Refund",
173 "id", strconv.Itoa(id),
174 "to", caller.String(),
175 "amount", strconv.FormatUint(amount, 10))
176}
177
178// --- pure/read-only helpers (also used by Render) ---
179
180// pct returns the funded percentage (0..100).
181func pct(pledged, goal uint64) int {
182 if goal == 0 {
183 return 0
184 }
185 p := int(pledged * 100 / goal)
186 if p > 100 {
187 p = 100
188 }
189 return p
190}
191
192// progressBar renders a fixed-width ▓░ bar for pledged/goal.
193func progressBar(pledged, goal uint64) string {
194 const width = 20
195 filled := 0
196 if goal > 0 {
197 filled = int(pledged * width / goal)
198 if filled > width {
199 filled = width
200 }
201 }
202 bar := ""
203 for i := 0; i < width; i++ {
204 if i < filled {
205 bar += "▓"
206 } else {
207 bar += "░"
208 }
209 }
210 return bar
211}
212
213// state describes a campaign relative to a given block height.
214func state(c *Campaign, height int64) string {
215 if height <= c.Deadline {
216 return "Active"
217 }
218 if c.Pledged >= c.Goal {
219 if c.Claimed {
220 return "Funded (claimed)"
221 }
222 return "Funded"
223 }
224 return "Failed"
225}
226
227// Render shows all campaigns with a progress bar, goal, pledged and state.
228func Render(path string) string {
229 if campaigns.Size() == 0 {
230 return "# Crowdfund\n\nNo campaigns yet. Call `Launch(goal, durationBlocks)` to start one.\n"
231 }
232 height := runtime.ChainHeight()
233 out := "# Crowdfund\n\n"
234 out += "Current block height: **" + strconv.FormatInt(height, 10) + "**\n\n"
235 campaigns.Iterate("", "", func(k string, v any) bool {
236 c := v.(*Campaign)
237 out += "## Campaign #" + strconv.Itoa(c.ID) + "\n\n"
238 out += "`" + progressBar(c.Pledged, c.Goal) + "` " + strconv.Itoa(pct(c.Pledged, c.Goal)) + "%\n\n"
239 out += "- State: **" + state(c, height) + "**\n"
240 out += "- Goal: " + strconv.FormatUint(c.Goal, 10) + "\n"
241 out += "- Pledged: " + strconv.FormatUint(c.Pledged, 10) + "\n"
242 out += "- Deadline: block " + strconv.FormatInt(c.Deadline, 10) + "\n"
243 out += "- Creator: `" + c.Creator.String() + "`\n\n"
244 return false
245 })
246 return out
247}