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

36.06 Kb · 979 lines
  1// Realm crowdfund is permissionless all-or-nothing crowdfunding in GNOT:
  2// a creator opens a campaign with a funding goal and a block deadline,
  3// backers pledge real coins that the realm holds, and settlement is
  4// conditional — if the goal is reached by the deadline the creator may
  5// collect the pot (minus a bounded success fee), and if it is not, every
  6// backer reclaims exactly what they pledged, fee-free.
  7//
  8// WHY THIS EXISTS (see DISCOVERY.md): at the heights recorded there,
  9// nothing on onyx-1 holds backer coins against a goal-and-deadline
 10// condition at all, and the one live crowdfunding realm in the wider
 11// ecosystem (mainnet r/moul/x/daily/crowdfund, read in full) is
 12// explicitly accounting-only — its own doc says "No real coin moves".
 13// The one real-coin implementation found anywhere ran on retired pearl-1
 14// under a third-party namespace and is unreachable. This realm's delta
 15// is stated at that size and no larger: it is the assurance-contract
 16// mechanic — conditional custody with a fee-free refund path — done with
 17// this portfolio's live-validated custody discipline, not a new idea.
 18//
 19// COMPOSITION: coin movement is delegated to coinio, fee arithmetic and
 20// the fee pot to feeledger, settlement timing and exactly-once
 21// authorization to duebook, ordered storage to p/nt/avl/v0, and
 22// free-text render output to the ecosystem sanitizer
 23// p/nt/markdown/sanitize/v0. This realm owns only the campaign state
 24// machine and the conservation bookkeeping.
 25//
 26// THE SETTLEMENT IS ONE DEFERRAL. Launch schedules exactly one duebook
 27// deferral per campaign — owner: the creator, due: the deadline, expiry:
 28// the end of the claim window — and every terminal transition is one of
 29// the ways that deferral can be consumed:
 30//
 31//	CreatorClaim  -> book.Claim   (due, goal met, creator collects)
 32//	SettleFailed  -> book.Claim   (due, goal UNMET, anyone; duebook
 33//	                               leaves claim policy to its consumer)
 34//	CreatorCancel -> book.Cancel  (owner renounces, any time while open)
 35//	Lapse         -> book.Expire  (anyone, once the claim window is over)
 36//
 37// Because duebook consumes a deferral before returning and never reuses
 38// an ID, a campaign can settle AT MOST ONCE, structurally — double-claim
 39// is not guarded against, it is unrepresentable. The book's open-
 40// deferral cap is likewise this realm's cap on open campaigns.
 41//
 42// LIFECYCLE (stored state in brackets; "succeeded"/"failed" are derived
 43// views of an open campaign after its deadline, not stored states):
 44//
 45//	Launch (anyone)          : opens [funding]; schedules the deferral.
 46//	Pledge (backer, +send)   : while height < deadline. Real coins.
 47//	Unpledge (backer)        : full own pledge back, while height <
 48//	                           deadline. All-or-nothing holds: nothing
 49//	                           is locked until the deadline passes.
 50//	CreatorClaim (creator)   : height in [deadline, claimDeadline),
 51//	                           raised >= goal -> [paid]. Pays
 52//	                           raised - fee to the creator, accrues the
 53//	                           fee. The ONLY transition that charges
 54//	                           anything.
 55//	SettleFailed (ANYONE)    : height >= deadline, raised < goal ->
 56//	                           [failed]. Makes the failure terminal and
 57//	                           frees the campaign's slots immediately;
 58//	                           refunds were already open on this path.
 59//	CreatorCancel (creator)  : while the deferral is open -> [cancelled].
 60//	                           Refunds open. A creator who cancels after
 61//	                           succeeding is renouncing the pot.
 62//	Lapse (ANYONE)           : height >= claimDeadline -> [lapsed].
 63//	                           Refunds open. The permissionless valve: a
 64//	                           creator who never collects cannot strand
 65//	                           backer money, and a dead campaign cannot
 66//	                           hold its duebook slot forever.
 67//	Refund (backer)          : own pledge back, fee-free, whenever the
 68//	                           campaign is [cancelled], [lapsed], or open
 69//	                           past its deadline with raised < goal.
 70//	Prune (ANYONE)           : removes a settled, fully-drained campaign
 71//	                           record. The freed storage deposit is
 72//	                           refunded by the chain to the CALLER, which
 73//	                           is the incentive to call it.
 74//	WithdrawFees (ANYONE)    : pays the accrued fee pot to the
 75//	                           compile-time FeeCollector. Permissionless
 76//	                           because the destination is fixed.
 77//	SweepSurplus (ANYONE)    : out-of-band coins to the FeeCollector,
 78//	                           never touching tracked liabilities.
 79//
 80// REFUNDS NEVER RACE THE CLAIM: while a succeeded campaign is inside its
 81// claim window, Refund refuses — the pot is the creator's to collect.
 82// The moment the window ends (Lapse) or the creator renounces (Cancel),
 83// and at any time after a failed deadline, Refund pays each backer
 84// exactly their pledge. There is no partial outcome and no fee on any
 85// refund path, so a backer's worst case is their money back.
 86//
 87// THE FEE, precisely: feeBps is snapshotted at Launch from the
 88// compile-time SuccessFeeBps, so a campaign's terms are fixed when it is
 89// created and visible in CampaignInfo from that moment. The fee is
 90// charged ONCE, at CreatorClaim, on the raised amount, with feeledger's
 91// floor rounding (rounding favors the creator). The ledger is
 92// constructed with the compile-time MaxFeeBps cap, so a fee above the
 93// cap is refused by the primitive, not by a check in this file. There is
 94// no fee on pledges, no fee on refunds, and no fee on failure.
 95//
 96// THERE IS NO ADMIN. The fee rate, the fee collector, every bound and
 97// every window are compile-time constants; no address can change terms,
 98// pause the realm, or touch a campaign it does not own. The deployer has
 99// no privilege of any kind after deployment.
100//
101// MONETARY INVARIANT (conservation): let H be the ugnot held at this
102// realm's address, B the sum of raised amounts across all stored
103// campaigns (tracked O(1) as totalBacked), F the accrued fee pot, and
104// S >= 0 the out-of-band surplus:
105//
106//	H == B + F + S
107//
108// Every transition moves value between exactly two terms inside one
109// transaction: Pledge raises H and B together (coinio.Receive is the
110// receipt-guaranteed shape); Unpledge and Refund debit B before the
111// identical amount leaves H (checks-effects-interactions); CreatorClaim
112// moves one campaign's raised out of B, splits it into a creator payout
113// (leaves H) and a fee (enters F); WithdrawFees debits F before the
114// payout; any panic aborts the whole transaction; this realm never
115// issues or removes coins.
116//
117// ONLY GNOT IS ACCEPTED: Pledge rejects any envelope that is not exactly
118// one positive ugnot coin (coinio.Receive). Every other entrypoint
119// rejects attached coins outright rather than converting them into
120// sweepable surplus.
121//
122// AUTHORIZATION: every identity is derived from the crossing
123// entrypoint's cur.Previous().Address(). No function takes a caller
124// identity as a parameter, so pledging as another backer, claiming
125// another creator's campaign, or refunding another backer's pledge is
126// impossible by construction.
127//
128// STORAGE: a backer's pledge entry is paid for by the backer's own
129// transaction deposit; a campaign record by the creator's. CreatorClaim
130// drops the whole pledge table in one assignment, and Prune removes the
131// settled record — both free storage, and the chain refunds freed
132// deposits to the transaction that frees them.
133package crowdfund
134
135import (
136	"chain"
137	"chain/runtime"
138	"chain/runtime/unsafe"
139	"strconv"
140
141	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
142	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/duebook"
143	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
144	"gno.land/p/nt/avl/v0"
145	"gno.land/p/nt/markdown/sanitize/v0"
146)
147
148// RealmPath is this realm's own path, used to build Render links.
149const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/crowdfund"
150
151// Denom is the only asset this realm holds.
152const Denom = "ugnot"
153
154// MaxFeeBps is the hard ceiling on any success fee this realm could ever
155// charge, enforced by the feeledger the realm is constructed with — a
156// feeBps above it is refused by the primitive's own validation, not by a
157// check in this file. 500 bps = 5%.
158const MaxFeeBps = int64(500)
159
160// SuccessFeeBps is the fee actually snapshotted into every campaign at
161// Launch: 100 bps = 1%, charged once, on CreatorClaim, on the raised
162// amount, floor-rounded in the creator's favor. fee_split precedent:
163// the most conservative live fee in this portfolio is also 1%.
164const SuccessFeeBps = int64(100)
165
166// FeeCollector receives withdrawn fees and swept surplus. Compile-time
167// constant: there is no setter and no transfer path. It is the deploying
168// namespace's address — stated plainly: the operator of this realm.
169const FeeCollector = address("g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3")
170
171// MinGoal keeps dust campaigns out: 1 GNOT.
172const MinGoal = int64(1_000_000)
173
174// MinPledge bounds pledge-table growth per GNOT of hostile capital
175// (audit open question 1): at 0.01 GNOT per entry, bloating one
176// campaign's table to even 10,000 entries costs 100 refundable-but-
177// locked GNOT plus gas. Honest micro-backing survives: 0.01 GNOT is
178// two orders of magnitude below MinGoal.
179const MinPledge = int64(10_000)
180
181// MinDurationBlocks and MaxDurationBlocks bound a campaign's funding
182// period, enforced structurally: they are the duebook's minDelay and
183// maxDelay, so an out-of-range duration is refused by the primitive.
184// At onyx-1's roughly 3-5s blocks, ~1,200 blocks is on the order of an
185// hour or two, and ~1,300,000 blocks is on the order of 45-75 days.
186const (
187	MinDurationBlocks = int64(1_200)
188	MaxDurationBlocks = int64(1_300_000)
189)
190
191// ClaimWindowBlocks is how long after the deadline a successful creator
192// may collect before the campaign becomes permissionlessly lapsable and
193// the pot refundable. It is every campaign's deferral ttl.
194const ClaimWindowBlocks = int64(650_000)
195
196// MaxOpenCampaigns caps simultaneously unsettled campaigns, via the
197// duebook's own open-deferral cap. The AVAILABILITY BOUND this implies
198// is documented rather than hidden: Launch is permissionless, so the
199// cap is exhaustible in principle. What each unsettled slot costs an
200// attacker is the mitigation — a failed campaign is permissionlessly
201// settleable (SettleFailed) the moment its deadline passes, so holding
202// a slot past its deadline requires MEETING THE GOAL, i.e. locking at
203// least MinGoal of real capital per slot for the claim window. Filling
204// the whole table therefore costs >= 1,024 GNOT locked for ~a month
205// per cycle, against a realm whose existing campaigns keep working
206// regardless. Accepted for a testnet deployment and recorded in the
207// deployment record (audit finding Y1).
208const MaxOpenCampaigns = 1024
209
210// MaxOpenPerCreator bounds how much of the global cap one creator can
211// occupy (permission_registry R1 precedent: an uncapped per-principal
212// count lets one key monopolize a shared bound).
213const MaxOpenPerCreator = int64(8)
214
215// Input bounds. Both fields are free text and are sanitized before
216// reaching markdown.
217const (
218	MaxTitleLen = 100
219	MaxMemoLen  = 256
220)
221
222// MaxRenderRows bounds the campaign list on the index page.
223const MaxRenderRows = 20
224
225// Campaign states. statePaid, stateFailed, stateCancelled and
226// stateLapsed are terminal: the campaign's deferral has been consumed
227// and can never be consumed again.
228const (
229	stateFunding   = "funding"
230	statePaid      = "paid"
231	stateFailed    = "failed"
232	stateCancelled = "cancelled"
233	stateLapsed    = "lapsed"
234)
235
236type campaign struct {
237	id        int64
238	creator   address
239	title     string
240	memo      string
241	goal      int64
242	feeBps    int64 // snapshotted at Launch
243	createdAt int64
244	deadline  int64 // createdAt + duration; pledging while height < deadline
245	claimEnd  int64 // deadline + ClaimWindowBlocks; == deferral ExpiresAt
246	defID     uint64
247	state     string
248
249	raised  int64     // coins currently held for this campaign
250	pledges *avl.Tree // backer address string -> int64 (> 0); nil after payout
251	backers int       // final count, frozen when pledges is dropped
252}
253
254var (
255	self address
256
257	campaigns = avl.NewTree() // padID(id) -> *campaign
258	nextID    int64
259
260	// openByCreator tracks each creator's unsettled campaigns:
261	// address string -> int64 count (> 0).
262	openByCreator = avl.NewTree()
263
264	// totalBacked is the B term of H == B + F + S: the sum of raised
265	// across all stored campaigns, maintained O(1) at every transition.
266	totalBacked int64
267
268	// lifetime counters, rendered for operators.
269	lifetimePledged  int64
270	lifetimePaidOut  int64 // creator payouts, net of fee
271	lifetimeReturned int64 // unpledges + refunds
272
273	// ledger provides the fee arithmetic, the fee cap, and the fee pot.
274	// Its user-balance side is used only transiently inside
275	// CreatorClaim, so UsersTotal() is 0 between transactions.
276	ledger = feeledger.MustNew(MaxFeeBps)
277
278	// book holds one open deferral per unsettled campaign. The bounds
279	// are the primitive's own validation: a duration outside
280	// [MinDurationBlocks, MaxDurationBlocks] is refused by Schedule.
281	book = duebook.MustNew(MinDurationBlocks, MaxDurationBlocks, MaxOpenCampaigns)
282)
283
284func init() {
285	self = unsafe.CurrentRealm().Address()
286}
287
288// rejectStraySend aborts when coins are attached to a non-payable call.
289// This realm holds funds, so a stray send would become sweepable
290// surplus, silently converting a user's coins into operator funds.
291// Aborting reverts the transfer instead. Guarded on IsUserCall, not
292// IsUser, because a MsgRun ephemeral can consume the OriginSend envelope
293// before forwarding control; for a realm-routed call the envelope lands
294// at the intermediary, so there is nothing here to reject and the guard
295// deliberately fails open (treasury_board documents the same scope).
296func rejectStraySend(cur realm) {
297	if !cur.IsCurrent() {
298		// Every call site forwards a crossing entrypoint's own live cur,
299		// so this cannot fire today; it exists so a future refactor that
300		// hands this guard a stale realm value fails closed, not open
301		// (coinio names IsCurrent as the guard secondary realm
302		// parameters require, and this helper follows that discipline).
303		panic("crowdfund: realm capability is not current")
304	}
305	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
306		panic("this entrypoint does not accept coins")
307	}
308}
309
310// --- campaign lifecycle ---
311
312// Launch opens a campaign and returns its id. Anyone may launch; the
313// caller becomes the creator. The fee terms (SuccessFeeBps at this
314// moment — a compile-time constant, so always SuccessFeeBps) are
315// snapshotted into the campaign and are fixed from here on. The funding
316// duration is given in blocks and must be within
317// [MinDurationBlocks, MaxDurationBlocks] — enforced by the duebook.
318func Launch(cur realm, title, memo string, goal, durationBlocks int64) int64 {
319	rejectStraySend(cur)
320	creator := cur.Previous().Address()
321
322	if title == "" {
323		panic("title must not be empty")
324	}
325	if len(title) > MaxTitleLen {
326		panic("title exceeds " + strconv.Itoa(MaxTitleLen) + " bytes")
327	}
328	if len(memo) > MaxMemoLen {
329		panic("memo exceeds " + strconv.Itoa(MaxMemoLen) + " bytes")
330	}
331	if goal < MinGoal {
332		panic("goal must be at least " + itoa(MinGoal) + Denom)
333	}
334	key := creator.String()
335	if creatorOpen(key) >= MaxOpenPerCreator {
336		panic("creator already has " + itoa(MaxOpenPerCreator) + " unsettled campaigns")
337	}
338
339	now := runtime.ChainHeight()
340	id := nextID + 1
341
342	// The deferral is the settlement authorization: due at the deadline,
343	// expiring when the claim window closes. Duration bounds and the
344	// global open-campaign cap are enforced here by the primitive.
345	defID := book.MustSchedule(key, itoa(id), now, durationBlocks, ClaimWindowBlocks)
346
347	c := &campaign{
348		id:        id,
349		creator:   creator,
350		title:     title,
351		memo:      memo,
352		goal:      goal,
353		feeBps:    SuccessFeeBps,
354		createdAt: now,
355		deadline:  now + durationBlocks,
356		claimEnd:  now + durationBlocks + ClaimWindowBlocks,
357		defID:     defID,
358		state:     stateFunding,
359		pledges:   avl.NewTree(),
360	}
361	nextID = id
362	campaigns.Set(padID(id), c)
363	setCreatorOpen(key, creatorOpen(key)+1)
364
365	chain.Emit("Launched", "id", itoa(id), "creator", key,
366		"goal", itoa(goal), "deadline", itoa(c.deadline),
367		"feeBps", itoa(c.feeBps))
368	return id
369}
370
371// Pledge backs a campaign with the attached coins. Direct EOA calls
372// with -send only (the receipt-guaranteed shape); exactly one positive
373// ugnot coin. Open while height < deadline.
374func Pledge(cur realm, id int64) {
375	backer, amount := coinio.Receive(0, cur, Denom)
376	c := mustGet(id)
377	if c.state != stateFunding {
378		panic("campaign is settled")
379	}
380	if runtime.ChainHeight() >= c.deadline {
381		panic("funding is closed")
382	}
383	if amount < MinPledge {
384		panic("pledge at least " + itoa(MinPledge) + Denom)
385	}
386
387	key := backer.String()
388	newPledge, ok := checkedAdd(pledgeOf(c, key), amount)
389	if !ok {
390		panic("pledge would overflow")
391	}
392	newRaised, ok := checkedAdd(c.raised, amount)
393	if !ok {
394		panic("raised would overflow")
395	}
396	newBacked, ok := checkedAdd(totalBacked, amount)
397	if !ok {
398		panic("total backed would overflow")
399	}
400	newLifetime, ok := checkedAdd(lifetimePledged, amount)
401	if !ok {
402		panic("lifetime pledged would overflow")
403	}
404
405	c.pledges.Set(key, newPledge)
406	c.raised = newRaised
407	totalBacked = newBacked
408	lifetimePledged = newLifetime
409
410	chain.Emit("Pledged", "id", itoa(c.id), "backer", key,
411		"amount", itoa(amount), "raised", itoa(c.raised))
412}
413
414// Unpledge returns the caller's entire pledge while funding is still
415// open. All-or-nothing means nothing is committed before the deadline,
416// so backing out is always whole and always free.
417func Unpledge(cur realm, id int64) {
418	rejectStraySend(cur)
419	caller := cur.Previous().Address()
420	c := mustGet(id)
421	if c.state != stateFunding {
422		panic("campaign is settled")
423	}
424	if runtime.ChainHeight() >= c.deadline {
425		panic("funding is closed; use Refund if the campaign failed")
426	}
427	payBack(cur, c, caller, "Unpledged")
428}
429
430// CreatorClaim collects a successful campaign: creator only, from the
431// deadline until the claim window closes, and only with the goal
432// reached. Pays raised minus the snapshotted fee to the creator and
433// accrues the fee. The duebook Claim consumes the campaign's deferral,
434// so this can succeed at most once per campaign, ever.
435func CreatorClaim(cur realm, id int64) {
436	rejectStraySend(cur)
437	caller := cur.Previous().Address()
438	c := mustGet(id)
439	if caller != c.creator {
440		panic("creator only")
441	}
442	if c.state != stateFunding {
443		panic("campaign is settled")
444	}
445	if c.raised < c.goal {
446		panic("goal not reached")
447	}
448
449	// Timing (not due / expired) is the primitive's verdict; its error
450	// names the reason. A success consumes the deferral before returning.
451	d := book.MustClaim(c.defID, runtime.ChainHeight())
452	if d.Owner != c.creator.String() {
453		// Unreachable by construction (the deferral was scheduled with
454		// this campaign's creator as owner); checked because the payout
455		// below is irreversible.
456		panic("settlement owner mismatch")
457	}
458
459	amount := c.raised
460	credited, fee := ledger.MustDeposit(c.creator.String(), amount, c.feeBps)
461	ledger.MustWithdraw(c.creator.String(), credited)
462
463	// Debit the campaign before the coins move.
464	c.raised = 0
465	totalBacked -= amount
466	c.backers = c.pledges.Size()
467	c.pledges = nil // drop the whole table; entitlements are settled
468	c.state = statePaid
469	decCreatorOpen(c.creator.String())
470	newPaid, ok := checkedAdd(lifetimePaidOut, credited)
471	if !ok {
472		panic("lifetime paid would overflow")
473	}
474	lifetimePaidOut = newPaid
475
476	coinio.Payout(0, cur, c.creator, Denom, credited)
477
478	chain.Emit("Claimed", "id", itoa(c.id), "creator", c.creator.String(),
479		"amount", itoa(credited), "fee", itoa(fee))
480}
481
482// CreatorCancel settles the caller's own campaign as cancelled and opens
483// refunds. Permitted at any time while the settlement deferral is open —
484// during funding, and equally after a successful deadline (renouncing
485// the pot). The duebook Cancel is owner-gated and consumes the deferral.
486func CreatorCancel(cur realm, id int64) {
487	rejectStraySend(cur)
488	caller := cur.Previous().Address()
489	c := mustGet(id)
490	if caller != c.creator {
491		panic("creator only")
492	}
493	if c.state != stateFunding {
494		panic("campaign is settled")
495	}
496
497	book.MustCancel(c.defID, caller.String())
498	c.state = stateCancelled
499	decCreatorOpen(c.creator.String())
500
501	chain.Emit("Cancelled", "id", itoa(c.id), "creator", c.creator.String(),
502		"raised", itoa(c.raised))
503}
504
505// SettleFailed settles a campaign that reached its deadline with the
506// goal unmet. Anyone may call it — there is nothing to steer: refunds
507// were already open on this path, so the only effects are making the
508// failure terminal and freeing the campaign's duebook slot (and its
509// creator's cap slot) immediately instead of at the end of the claim
510// window. This is what makes slot-squatting with unfunded campaigns
511// pointless: holding a slot past the deadline requires meeting the
512// goal, i.e. real locked capital.
513//
514// The deferral is consumed with the duebook's Claim under this realm's
515// policy (goal unmet), which the primitive explicitly leaves to its
516// consumer; the timing verdict (not due before the deadline) is the
517// primitive's own.
518func SettleFailed(cur realm, id int64) {
519	rejectStraySend(cur)
520	c := mustGet(id)
521	if c.state != stateFunding {
522		panic("campaign is settled")
523	}
524	if c.raised >= c.goal {
525		panic("goal reached; the claim window is the creator's")
526	}
527
528	book.MustClaim(c.defID, runtime.ChainHeight())
529	c.state = stateFailed
530	decCreatorOpen(c.creator.String())
531
532	chain.Emit("Failed", "id", itoa(c.id), "by",
533		cur.Previous().Address().String(), "raised", itoa(c.raised))
534}
535
536// Lapse settles a campaign whose claim window has closed. Anyone may
537// call it — a lapsed campaign's pot belongs to its backers, and the
538// caller is doing them (and the duebook's open-slot budget) a service.
539// The duebook Expire refuses while the window is still open.
540func Lapse(cur realm, id int64) {
541	rejectStraySend(cur)
542	c := mustGet(id)
543	if c.state != stateFunding {
544		panic("campaign is settled")
545	}
546
547	book.MustExpire(c.defID, runtime.ChainHeight())
548	c.state = stateLapsed
549	decCreatorOpen(c.creator.String())
550
551	chain.Emit("Lapsed", "id", itoa(c.id), "by",
552		cur.Previous().Address().String(), "raised", itoa(c.raised))
553}
554
555// Refund returns the caller's entire pledge from a campaign that will
556// never pay its creator: cancelled, lapsed, or past its deadline with
557// the goal unmet. Always whole, always fee-free. While a succeeded
558// campaign is inside its claim window the pot is the creator's to
559// collect, so Refund refuses — if the creator never collects, Lapse
560// reopens this path.
561func Refund(cur realm, id int64) {
562	rejectStraySend(cur)
563	caller := cur.Previous().Address()
564	c := mustGet(id)
565	if !refundable(c, runtime.ChainHeight()) {
566		panic("campaign is not refundable")
567	}
568	payBack(cur, c, caller, "Refunded")
569}
570
571// Prune removes a settled campaign record that holds no coins,
572// reclaiming its storage. Anyone may call it: the chain refunds the
573// freed storage deposit to the pruning transaction, which is the
574// incentive. A record with raised > 0 still carries backer entitlements
575// and is not prunable.
576func Prune(cur realm, id int64) {
577	rejectStraySend(cur)
578	c := mustGet(id)
579	if c.state == stateFunding {
580		panic("campaign is not settled")
581	}
582	if c.raised != 0 {
583		panic("campaign still holds backer funds")
584	}
585	campaigns.Remove(padID(c.id))
586	chain.Emit("Pruned", "id", itoa(c.id), "by",
587		cur.Previous().Address().String())
588}
589
590// --- fees and surplus ---
591
592// WithdrawFees pays the entire accrued fee pot to the compile-time
593// FeeCollector. Anyone may call it: the destination is fixed, so there
594// is nothing for a caller to steer.
595func WithdrawFees(cur realm) int64 {
596	rejectStraySend(cur)
597	fees := ledger.WithdrawFees()
598	if fees == 0 {
599		panic("no fees accrued")
600	}
601	coinio.Payout(0, cur, FeeCollector, Denom, fees)
602	chain.Emit("FeesWithdrawn", "to", FeeCollector.String(), "amount", itoa(fees))
603	return fees
604}
605
606// SweepSurplus sends out-of-band coins to the FeeCollector: for the pot
607// denom, everything above tracked liabilities; for any foreign denom,
608// the full balance. Tracked backer money and the fee pot are
609// untouchable by construction (coinio.Sweep refuses to dip below the
610// reserve). Anyone may call it.
611func SweepSurplus(cur realm, denom string) int64 {
612	rejectStraySend(cur)
613	reserve := int64(0)
614	if denom == Denom {
615		reserve = Liabilities()
616	}
617	swept := coinio.Sweep(0, cur, FeeCollector, denom, reserve)
618	chain.Emit("Swept", "denom", denom, "to", FeeCollector.String(),
619		"amount", itoa(swept))
620	return swept
621}
622
623// --- views ---
624
625// CampaignInfo returns a campaign's fixed terms and live tallies.
626func CampaignInfo(id int64) (creator address, goal, raised, feeBps,
627	createdAt, deadline, claimEnd int64, backers int, state string) {
628	c := mustGet(id)
629	return c.creator, c.goal, c.raised, c.feeBps,
630		c.createdAt, c.deadline, c.claimEnd, backerCount(c), c.state
631}
632
633// CampaignTitle returns a campaign's raw title (unsanitized: callers
634// rendering it into markdown must sanitize, as this realm's Render does).
635func CampaignTitle(id int64) string { return mustGet(id).title }
636
637// CampaignMemo returns a campaign's raw memo (same caveat as the title).
638func CampaignMemo(id int64) string { return mustGet(id).memo }
639
640// Status returns the human verdict for a campaign right now: "funding",
641// "succeeded (awaiting creator claim)", "failed (refunds open)",
642// "paid", "cancelled", or "lapsed".
643func Status(id int64) string { return status(mustGet(id), runtime.ChainHeight()) }
644
645// PledgeOf returns addr's live pledge in a campaign (0 if none, and 0
646// for settled campaigns whose pledge table has been dropped).
647func PledgeOf(id int64, addr address) int64 {
648	c := mustGet(id)
649	if c.pledges == nil {
650		return 0
651	}
652	return pledgeOf(c, addr.String())
653}
654
655// IsRefundable reports whether Refund would currently accept this
656// campaign (for any backer with a live pledge).
657func IsRefundable(id int64) bool {
658	return refundable(mustGet(id), runtime.ChainHeight())
659}
660
661// NumCampaigns returns how many campaigns have ever been launched.
662// Prune removes records but never reuses ids.
663func NumCampaigns() int64 { return nextID }
664
665// OpenCampaigns returns how many campaigns are currently unsettled.
666func OpenCampaigns() int64 { return int64(book.OpenCount()) }
667
668// OpenOf returns how many unsettled campaigns addr currently has.
669func OpenOf(addr address) int64 { return creatorOpen(addr.String()) }
670
671// TotalBacked returns the B term: coins held for campaigns.
672func TotalBacked() int64 { return totalBacked }
673
674// FeesAccrued returns the F term: charged, not yet withdrawn.
675func FeesAccrued() int64 { return ledger.FeesAccrued() }
676
677// Liabilities returns B + F — everything this realm owes.
678func Liabilities() int64 { return totalBacked + ledger.Liabilities() }
679
680// Held returns H: the ugnot actually at this realm's address.
681func Held() int64 { return coinio.HeldAt(self, Denom) }
682
683// Surplus returns H - (B + F) — out-of-band coins, sweepable.
684func Surplus() int64 { return Held() - Liabilities() }
685
686// Address returns this realm's own address.
687func Address() address { return self }
688
689// Height returns the current chain height, the clock every deadline is
690// measured against.
691func Height() int64 { return runtime.ChainHeight() }
692
693// --- render ---
694
695// Render shows the realm at "" and a campaign page at "<id>". Titles
696// and memos are free text and pass through the ecosystem sanitizer
697// before hitting markdown.
698func Render(path string) string {
699	if path != "" {
700		return renderCampaign(path)
701	}
702	now := runtime.ChainHeight()
703	held := Held()
704	liab := Liabilities()
705	conservation := "OK"
706	if held < liab {
707		conservation = "VIOLATED"
708	}
709	out := "# Crowdfund\n\n"
710	out += "All-or-nothing crowdfunding in GNOT: hit the goal by the deadline " +
711		"and the creator collects (minus a " + bps(SuccessFeeBps) +
712		" success fee); miss it and every backer reclaims exactly what they " +
713		"pledged, fee-free.\n\n"
714	out += "## Terms (fixed at deploy; no admin, no setters)\n\n"
715	out += "- success fee: " + bps(SuccessFeeBps) + " of raised, charged only on a successful claim (hard cap " + bps(MaxFeeBps) + ")\n"
716	out += "- refunds and unpledges: always whole, always fee-free\n"
717	out += "- goal: at least " + itoa(MinGoal) + Denom + "\n"
718	out += "- funding duration: " + itoa(MinDurationBlocks) + " to " + itoa(MaxDurationBlocks) + " blocks\n"
719	out += "- claim window after deadline: " + itoa(ClaimWindowBlocks) + " blocks, then anyone may Lapse\n"
720	out += "- open campaigns: " + itoa(OpenCampaigns()) + " of " + strconv.Itoa(MaxOpenCampaigns) +
721		" (per creator: " + itoa(MaxOpenPerCreator) + ")\n"
722	out += "- current height: " + itoa(now) + "\n\n"
723	out += "## Accounting (H == B + F + S)\n\n"
724	out += "- backed (B): " + itoa(totalBacked) + Denom + "\n"
725	out += "- fee pot (F): " + itoa(ledger.FeesAccrued()) + Denom + "\n"
726	out += "- held (H): " + itoa(held) + Denom + "\n"
727	out += "- surplus (S): " + itoa(held-liab) + Denom + "\n"
728	out += "- conservation: " + conservation + "\n"
729	out += "- lifetime pledged / paid out / returned: " + itoa(lifetimePledged) +
730		" / " + itoa(lifetimePaidOut) + " / " + itoa(lifetimeReturned) + Denom + "\n\n"
731	out += "## Latest campaigns\n\n"
732	if campaigns.Size() == 0 {
733		out += "None yet. Launch one.\n"
734	} else {
735		shown := 0
736		campaigns.ReverseIterate("", "", func(_ string, v any) bool {
737			c := v.(*campaign)
738			out += "- [#" + itoa(c.id) + "](" + RealmPath + ":" + itoa(c.id) + ") [" +
739				status(c, now) + "] " + sanitize.InlineText(c.title) + " — " +
740				itoa(c.raised) + " / " + itoa(c.goal) + Denom + "\n"
741			shown++
742			return shown >= MaxRenderRows
743		})
744		if int64(shown) < nextID {
745			out += "\n> [!NOTE]\n> Showing the " + strconv.Itoa(shown) +
746				" most recent of " + itoa(nextID) +
747				" ever launched (settled records may have been pruned). " +
748				"Use CampaignInfo(id) for any specific campaign.\n"
749		}
750	}
751	due := book.Due(now, 5)
752	lapsable := book.Expirable(now, 5)
753	if len(due) > 0 || len(lapsable) > 0 {
754		out += "\n## Keeper work\n\n"
755	}
756	if len(due) > 0 {
757		out += "Settlements due — the creator may CreatorClaim(id) if the " +
758			"goal is met; anyone may SettleFailed(id) if it is not:\n\n"
759		for _, d := range due {
760			out += "- campaign #" + sanitize.InlineText(d.Payload) + "\n"
761		}
762	}
763	if len(lapsable) > 0 {
764		out += "Settlements past their claim window — anyone may call Lapse(id) " +
765			"to open refunds:\n\n"
766		for _, d := range lapsable {
767			out += "- campaign #" + sanitize.InlineText(d.Payload) + "\n"
768		}
769	}
770	return out
771}
772
773func renderCampaign(path string) string {
774	id, err := strconv.ParseInt(path, 10, 64)
775	if err != nil {
776		return "> [!WARNING]\n> invalid campaign id\n"
777	}
778	v := campaigns.Get(padID(id))
779	if v == nil {
780		return "> [!WARNING]\n> unknown campaign id (never launched, or settled and pruned)\n"
781	}
782	c := v.(*campaign)
783	now := runtime.ChainHeight()
784
785	out := "# #" + itoa(c.id) + ": " + sanitize.InlineText(c.title) + "\n\n"
786	out += "- status: **" + status(c, now) + "**\n"
787	out += "- creator: `" + c.creator.String() + "`\n"
788	out += "- raised: " + itoa(c.raised) + " / " + itoa(c.goal) + Denom +
789		" (" + percent(c.raised, c.goal) + ")\n"
790	out += "- backers: " + strconv.Itoa(backerCount(c)) + "\n"
791	out += "- success fee: " + bps(c.feeBps) + " (snapshotted at launch)\n"
792	out += "- created at block: " + itoa(c.createdAt) + "\n"
793	out += "- deadline: block " + itoa(c.deadline) + "\n"
794	out += "- claim window closes: block " + itoa(c.claimEnd) + "\n"
795	out += "- current height: " + itoa(now) + "\n\n"
796	switch {
797	case c.state == stateFunding && now < c.deadline:
798		out += "Pledge(" + itoa(c.id) + ") with -send to back it; " +
799			"Unpledge(" + itoa(c.id) + ") to back out. " +
800			itoa(c.deadline-now) + " blocks remain.\n"
801	case c.state == stateFunding && c.raised >= c.goal && now < c.claimEnd:
802		out += "**Goal reached.** The creator may CreatorClaim(" + itoa(c.id) +
803			") until block " + itoa(c.claimEnd) + ".\n"
804	case c.state == stateFunding && c.raised >= c.goal:
805		out += "**Claim window over.** Anyone may Lapse(" + itoa(c.id) +
806			") to open refunds.\n"
807	case c.state == stateFunding && now < c.claimEnd:
808		out += "**Goal not reached.** Backers may Refund(" + itoa(c.id) +
809			"); anyone may SettleFailed(" + itoa(c.id) +
810			") to close it out.\n"
811	case c.state == stateFunding:
812		// Past the claim window the deferral is expired, so the failed
813		// path closes via Lapse, not SettleFailed (audit G1).
814		out += "**Goal not reached.** Backers may Refund(" + itoa(c.id) +
815			"); anyone may Lapse(" + itoa(c.id) + ") to close it out.\n"
816	case c.raised > 0:
817		out += "Backers may Refund(" + itoa(c.id) + ").\n"
818	default:
819		out += "Fully settled. Anyone may Prune(" + itoa(c.id) +
820			") to reclaim the record's storage.\n"
821	}
822	if c.memo != "" {
823		out += "\n## About\n\n" + sanitize.InlineText(c.memo) + "\n"
824	}
825	return out
826}
827
828// --- internals ---
829
830func mustGet(id int64) *campaign {
831	v := campaigns.Get(padID(id))
832	if v == nil {
833		panic("unknown campaign id")
834	}
835	return v.(*campaign)
836}
837
838// payBack is the shared whole-pledge return path for Unpledge and
839// Refund: debit the backer's entry and the campaign before the
840// identical amount leaves the realm.
841func payBack(cur realm, c *campaign, backer address, event string) {
842	key := backer.String()
843	amount := pledgeOf(c, key)
844	if amount == 0 {
845		panic("no pledge to return")
846	}
847	c.pledges.Remove(key)
848	c.raised -= amount
849	totalBacked -= amount
850	newReturned, ok := checkedAdd(lifetimeReturned, amount)
851	if !ok {
852		panic("lifetime returned would overflow")
853	}
854	lifetimeReturned = newReturned
855
856	coinio.Payout(0, cur, backer, Denom, amount)
857
858	chain.Emit(event, "id", itoa(c.id), "backer", key,
859		"amount", itoa(amount), "raised", itoa(c.raised))
860}
861
862// refundable: failed, cancelled and lapsed campaigns always; an
863// unsettled campaign once its deadline has passed with the goal unmet.
864// A succeeded campaign inside its claim window is NOT refundable — the
865// pot is the creator's to collect until Cancel or Lapse says otherwise.
866func refundable(c *campaign, now int64) bool {
867	switch c.state {
868	case stateFailed, stateCancelled, stateLapsed:
869		return true
870	case stateFunding:
871		return now >= c.deadline && c.raised < c.goal
872	default:
873		return false
874	}
875}
876
877func status(c *campaign, now int64) string {
878	if c.state != stateFunding {
879		return c.state
880	}
881	switch {
882	case now < c.deadline:
883		return stateFunding
884	case c.raised >= c.goal:
885		return "succeeded (awaiting creator claim)"
886	default:
887		return "failed (refunds open)"
888	}
889}
890
891func pledgeOf(c *campaign, key string) int64 {
892	v := c.pledges.Get(key)
893	if v == nil {
894		return 0
895	}
896	return v.(int64)
897}
898
899func backerCount(c *campaign) int {
900	if c.pledges == nil {
901		return c.backers
902	}
903	return c.pledges.Size()
904}
905
906func creatorOpen(key string) int64 {
907	v := openByCreator.Get(key)
908	if v == nil {
909		return 0
910	}
911	return v.(int64)
912}
913
914func setCreatorOpen(key string, n int64) {
915	if n <= 0 {
916		openByCreator.Remove(key)
917		return
918	}
919	openByCreator.Set(key, n)
920}
921
922func decCreatorOpen(key string) { setCreatorOpen(key, creatorOpen(key)-1) }
923
924func itoa(n int64) string { return strconv.FormatInt(n, 10) }
925
926// bps renders a basis-point figure as a human percentage, exactly.
927func bps(n int64) string {
928	whole := n / 100
929	frac := n % 100
930	if frac == 0 {
931		return itoa(whole) + "%"
932	}
933	if frac%10 == 0 {
934		return itoa(whole) + "." + itoa(frac/10) + "%"
935	}
936	pad := ""
937	if frac < 10 {
938		pad = "0"
939	}
940	return itoa(whole) + "." + pad + itoa(frac) + "%"
941}
942
943// percent renders raised/goal as an integer percentage (floor).
944// goal >= MinGoal > 0 by construction, so no divide guard is needed.
945func percent(raised, goal int64) string {
946	q := raised / goal
947	r := raised % goal
948	var p int64
949	if r <= (int64(1)<<62)/100 {
950		p = q*100 + r*100/goal
951	} else {
952		// goal (> r) is so large that r*100 would overflow; the scaled
953		// form loses at most a rounding step, which is cosmetic here.
954		p = q*100 + r/(goal/100)
955	}
956	return itoa(p) + "%"
957}
958
959// padID encodes an id so avl's lexical order matches numeric order.
960func padID(id int64) string {
961	s := strconv.FormatInt(id, 10)
962	const width = 20
963	if len(s) >= width {
964		return s
965	}
966	return zeros[:width-len(s)] + s
967}
968
969const zeros = "00000000000000000000"
970
971// checkedAdd returns a+b and reports whether the addition did not
972// overflow int64.
973func checkedAdd(a, b int64) (int64, bool) {
974	sum := a + b
975	if (b > 0 && sum < a) || (b < 0 && sum > a) {
976		return 0, false
977	}
978	return sum, true
979}