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}