crowdfund.gno
6.19 Kb · 231 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/moul/kit/store/v0"
20 "gno.land/p/nt/avl/v0"
21)
22
23// Campaign holds the state of a single crowdfunding campaign. It carries no ID
24// field: the id belongs to the store, which hands it back on lookup and
25// iteration.
26type Campaign struct {
27 Creator address
28 Goal uint64
29 Deadline int64 // block height after which the campaign is closed
30 Pledged uint64 // running total pledged
31 Claimed bool // creator already claimed the funds
32 pledges *avl.Tree // backer address (string) -> pledged uint64
33}
34
35// campaigns assigns the campaign ids. v0 kept its own nextID plus a key() that
36// zero-padded to width 12, which stopped ordering Render past 10^12, and its
37// own mustGet whose panic did not say which campaign was missing.
38var campaigns = store.Named("campaign")
39
40func mustGet(id int) *Campaign {
41 return campaigns.MustGet(store.ID(id)).(*Campaign)
42}
43
44// Launch creates a new campaign owned by the caller and returns its id.
45func Launch(cur realm, goal uint64, durationBlocks int) int {
46 if goal == 0 {
47 panic("goal must be > 0")
48 }
49 if durationBlocks <= 0 {
50 panic("duration must be > 0")
51 }
52 caller := unsafe.PreviousRealm().Address()
53 id := campaigns.Add(&Campaign{
54 Creator: caller,
55 Goal: goal,
56 Deadline: runtime.ChainHeight() + int64(durationBlocks),
57 pledges: avl.NewTree(),
58 })
59 chain.Emit("Launch",
60 "id", id.String(),
61 "creator", caller.String(),
62 "goal", strconv.FormatUint(goal, 10))
63 return int(id)
64}
65
66// Pledge backs campaign id with amount (before the deadline).
67func Pledge(cur realm, id int, amount uint64) {
68 if amount == 0 {
69 panic("amount must be > 0")
70 }
71 c := mustGet(id)
72 if runtime.ChainHeight() > c.Deadline {
73 panic("campaign ended")
74 }
75 caller := unsafe.PreviousRealm().Address()
76 prev := uint64(0)
77 if v := c.pledges.Get(caller.String()); v != nil {
78 prev = v.(uint64)
79 }
80 c.pledges.Set(caller.String(), prev+amount)
81 c.Pledged += amount
82 chain.Emit("Pledge",
83 "id", strconv.Itoa(id),
84 "from", caller.String(),
85 "amount", strconv.FormatUint(amount, 10))
86}
87
88// Unpledge withdraws amount from the caller's pledge (before the deadline).
89func Unpledge(cur realm, id int, amount uint64) {
90 if amount == 0 {
91 panic("amount must be > 0")
92 }
93 c := mustGet(id)
94 if runtime.ChainHeight() > c.Deadline {
95 panic("campaign ended")
96 }
97 caller := unsafe.PreviousRealm().Address()
98 v := c.pledges.Get(caller.String())
99 if v == nil {
100 panic("nothing pledged")
101 }
102 have := v.(uint64)
103 if amount > have {
104 panic("amount exceeds pledge")
105 }
106 if remaining := have - amount; remaining == 0 {
107 c.pledges.Remove(caller.String())
108 } else {
109 c.pledges.Set(caller.String(), remaining)
110 }
111 c.Pledged -= amount
112 chain.Emit("Unpledge",
113 "id", strconv.Itoa(id),
114 "from", caller.String(),
115 "amount", strconv.FormatUint(amount, 10))
116}
117
118// Claim lets the creator take the funds if the goal was met after the deadline.
119func Claim(cur realm, id int) {
120 c := mustGet(id)
121 if runtime.ChainHeight() <= c.Deadline {
122 panic("campaign not ended")
123 }
124 caller := unsafe.PreviousRealm().Address()
125 if caller != c.Creator {
126 panic("only creator can claim")
127 }
128 if c.Pledged < c.Goal {
129 panic("goal not reached")
130 }
131 if c.Claimed {
132 panic("already claimed")
133 }
134 c.Claimed = true
135 chain.Emit("Claim",
136 "id", strconv.Itoa(id),
137 "amount", strconv.FormatUint(c.Pledged, 10))
138}
139
140// Refund returns the caller's pledge if the campaign failed after the deadline.
141func Refund(cur realm, id int) {
142 c := mustGet(id)
143 if runtime.ChainHeight() <= c.Deadline {
144 panic("campaign not ended")
145 }
146 if c.Pledged >= c.Goal {
147 panic("campaign succeeded, no refund")
148 }
149 caller := unsafe.PreviousRealm().Address()
150 v := c.pledges.Get(caller.String())
151 if v == nil {
152 panic("nothing to refund")
153 }
154 amount := v.(uint64)
155 c.pledges.Remove(caller.String())
156 c.Pledged -= amount
157 chain.Emit("Refund",
158 "id", strconv.Itoa(id),
159 "to", caller.String(),
160 "amount", strconv.FormatUint(amount, 10))
161}
162
163// --- pure/read-only helpers (also used by Render) ---
164
165// pct returns the funded percentage (0..100).
166func pct(pledged, goal uint64) int {
167 if goal == 0 {
168 return 0
169 }
170 p := int(pledged * 100 / goal)
171 if p > 100 {
172 p = 100
173 }
174 return p
175}
176
177// progressBar renders a fixed-width ▓░ bar for pledged/goal.
178func progressBar(pledged, goal uint64) string {
179 const width = 20
180 filled := 0
181 if goal > 0 {
182 filled = int(pledged * width / goal)
183 if filled > width {
184 filled = width
185 }
186 }
187 bar := ""
188 for i := 0; i < width; i++ {
189 if i < filled {
190 bar += "▓"
191 } else {
192 bar += "░"
193 }
194 }
195 return bar
196}
197
198// state describes a campaign relative to a given block height.
199func state(c *Campaign, height int64) string {
200 if height <= c.Deadline {
201 return "Active"
202 }
203 if c.Pledged >= c.Goal {
204 if c.Claimed {
205 return "Funded (claimed)"
206 }
207 return "Funded"
208 }
209 return "Failed"
210}
211
212// Render shows all campaigns with a progress bar, goal, pledged and state.
213func Render(path string) string {
214 if campaigns.Len() == 0 {
215 return "# Crowdfund\n\nNo campaigns yet. Call `Launch(goal, durationBlocks)` to start one.\n"
216 }
217 height := runtime.ChainHeight()
218 out := "# Crowdfund\n\n"
219 out += "Current block height: **" + strconv.FormatInt(height, 10) + "**\n\n"
220 campaigns.Each(func(id store.ID, v any) {
221 c := v.(*Campaign)
222 out += "## Campaign #" + id.String() + "\n\n"
223 out += "`" + progressBar(c.Pledged, c.Goal) + "` " + strconv.Itoa(pct(c.Pledged, c.Goal)) + "%\n\n"
224 out += "- State: **" + state(c, height) + "**\n"
225 out += "- Goal: " + strconv.FormatUint(c.Goal, 10) + "\n"
226 out += "- Pledged: " + strconv.FormatUint(c.Pledged, 10) + "\n"
227 out += "- Deadline: block " + strconv.FormatInt(c.Deadline, 10) + "\n"
228 out += "- Creator: `" + c.Creator.String() + "`\n\n"
229 })
230 return out
231}