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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio"
142	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/duebook"
143	"gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/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/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/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("g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39")
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}