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 · 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}