Search Apps Documentation Source Content File Folder Download Copy Actions Download 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}