Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}