PathrockNetwork Gno Explorer
HomeBlocksTransactionsTokensRealmsPackagesValidatorsAnalytics

PathrockNetwork Gno Explorer — an independent explorer for Gno.land Mainnet (gnoland-1), operated by PathrockNetwork. Not an official Gno.land service.

gnowebarchive RPC

gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/crowdfund

Realm
Open in gnoweb ↗

Overview

Kind
Realm (renderable)
Name
crowdfund
Namespace
g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39
Files
3 (gnomod.toml)
Exported functions
28
Module
gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/crowdfund
gno
0.9

Files (3)

  • gnomod.tomltoml
  • crowdfund.gnogno
  • crowdfund_test.gnogno
crowdfund.gnogno
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 is4// conditional — if the goal is reached by the deadline the creator may5// collect the pot (minus a bounded success fee), and if it is not, every6// 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-deadline10// condition at all, and the one live crowdfunding realm in the wider11// ecosystem (mainnet r/moul/x/daily/crowdfund, read in full) is12// explicitly accounting-only — its own doc says "No real coin moves".13// The one real-coin implementation found anywhere ran on retired pearl-114// under a third-party namespace and is unreachable. This realm's delta15// is stated at that size and no larger: it is the assurance-contract16// mechanic — conditional custody with a fee-free refund path — done with17// this portfolio's live-validated custody discipline, not a new idea.18//19// COMPOSITION: coin movement is delegated to coinio, fee arithmetic and20// the fee pot to feeledger, settlement timing and exactly-once21// authorization to duebook, ordered storage to p/nt/avl/v0, and22// free-text render output to the ecosystem sanitizer23// p/nt/markdown/sanitize/v0. This realm owns only the campaign state24// machine and the conservation bookkeeping.25//26// THE SETTLEMENT IS ONE DEFERRAL. Launch schedules exactly one duebook27// deferral per campaign — owner: the creator, due: the deadline, expiry:28// the end of the claim window — and every terminal transition is one of29// 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; duebook33//	                               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 reuses38// an ID, a campaign can settle AT MOST ONCE, structurally — double-claim39// 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 derived43// 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: nothing49//	                           is locked until the deadline passes.50//	CreatorClaim (creator)   : height in [deadline, claimDeadline),51//	                           raised >= goal -> [paid]. Pays52//	                           raised - fee to the creator, accrues the53//	                           fee. The ONLY transition that charges54//	                           anything.55//	SettleFailed (ANYONE)    : height >= deadline, raised < goal ->56//	                           [failed]. Makes the failure terminal and57//	                           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 after61//	                           succeeding is renouncing the pot.62//	Lapse (ANYONE)           : height >= claimDeadline -> [lapsed].63//	                           Refunds open. The permissionless valve: a64//	                           creator who never collects cannot strand65//	                           backer money, and a dead campaign cannot66//	                           hold its duebook slot forever.67//	Refund (backer)          : own pledge back, fee-free, whenever the68//	                           campaign is [cancelled], [lapsed], or open69//	                           past its deadline with raised < goal.70//	Prune (ANYONE)           : removes a settled, fully-drained campaign71//	                           record. The freed storage deposit is72//	                           refunded by the chain to the CALLER, which73//	                           is the incentive to call it.74//	WithdrawFees (ANYONE)    : pays the accrued fee pot to the75//	                           compile-time FeeCollector. Permissionless76//	                           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 its81// 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 backer84// exactly their pledge. There is no partial outcome and no fee on any85// refund path, so a backer's worst case is their money back.86//87// THE FEE, precisely: feeBps is snapshotted at Launch from the88// compile-time SuccessFeeBps, so a campaign's terms are fixed when it is89// created and visible in CampaignInfo from that moment. The fee is90// charged ONCE, at CreatorClaim, on the raised amount, with feeledger's91// floor rounding (rounding favors the creator). The ledger is92// constructed with the compile-time MaxFeeBps cap, so a fee above the93// cap is refused by the primitive, not by a check in this file. There is94// 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 and97// 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 has99// no privilege of any kind after deployment.100//101// MONETARY INVARIANT (conservation): let H be the ugnot held at this102// realm's address, B the sum of raised amounts across all stored103// campaigns (tracked O(1) as totalBacked), F the accrued fee pot, and104// S >= 0 the out-of-band surplus:105//106//	H == B + F + S107//108// Every transition moves value between exactly two terms inside one109// transaction: Pledge raises H and B together (coinio.Receive is the110// receipt-guaranteed shape); Unpledge and Refund debit B before the111// identical amount leaves H (checks-effects-interactions); CreatorClaim112// moves one campaign's raised out of B, splits it into a creator payout113// (leaves H) and a fee (enters F); WithdrawFees debits F before the114// payout; any panic aborts the whole transaction; this realm never115// issues or removes coins.116//117// ONLY GNOT IS ACCEPTED: Pledge rejects any envelope that is not exactly118// one positive ugnot coin (coinio.Receive). Every other entrypoint119// rejects attached coins outright rather than converting them into120// sweepable surplus.121//122// AUTHORIZATION: every identity is derived from the crossing123// entrypoint's cur.Previous().Address(). No function takes a caller124// identity as a parameter, so pledging as another backer, claiming125// another creator's campaign, or refunding another backer's pledge is126// impossible by construction.127//128// STORAGE: a backer's pledge entry is paid for by the backer's own129// transaction deposit; a campaign record by the creator's. CreatorClaim130// drops the whole pledge table in one assignment, and Prune removes the131// settled record — both free storage, and the chain refunds freed132// deposits to the transaction that frees them.133package crowdfund134135import (136	"chain"137	"chain/runtime"138	"chain/runtime/unsafe"139	"strconv"140141	"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)147148// RealmPath is this realm's own path, used to build Render links.149const RealmPath = "/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/crowdfund"150151// Denom is the only asset this realm holds.152const Denom = "ugnot"153154// MaxFeeBps is the hard ceiling on any success fee this realm could ever155// charge, enforced by the feeledger the realm is constructed with — a156// feeBps above it is refused by the primitive's own validation, not by a157// check in this file. 500 bps = 5%.158const MaxFeeBps = int64(500)159160// SuccessFeeBps is the fee actually snapshotted into every campaign at161// Launch: 100 bps = 1%, charged once, on CreatorClaim, on the raised162// 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)165166// FeeCollector receives withdrawn fees and swept surplus. Compile-time167// constant: there is no setter and no transfer path. It is the deploying168// namespace's address — stated plainly: the operator of this realm.169const FeeCollector = address("g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39")170171// MinGoal keeps dust campaigns out: 1 GNOT.172const MinGoal = int64(1_000_000)173174// MinPledge bounds pledge-table growth per GNOT of hostile capital175// (audit open question 1): at 0.01 GNOT per entry, bloating one176// campaign's table to even 10,000 entries costs 100 refundable-but-177// locked GNOT plus gas. Honest micro-backing survives: 0.01 GNOT is178// two orders of magnitude below MinGoal.179const MinPledge = int64(10_000)180181// MinDurationBlocks and MaxDurationBlocks bound a campaign's funding182// period, enforced structurally: they are the duebook's minDelay and183// 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 an185// 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)190191// ClaimWindowBlocks is how long after the deadline a successful creator192// may collect before the campaign becomes permissionlessly lapsable and193// the pot refundable. It is every campaign's deferral ttl.194const ClaimWindowBlocks = int64(650_000)195196// MaxOpenCampaigns caps simultaneously unsettled campaigns, via the197// duebook's own open-deferral cap. The AVAILABILITY BOUND this implies198// is documented rather than hidden: Launch is permissionless, so the199// cap is exhaustible in principle. What each unsettled slot costs an200// attacker is the mitigation — a failed campaign is permissionlessly201// settleable (SettleFailed) the moment its deadline passes, so holding202// a slot past its deadline requires MEETING THE GOAL, i.e. locking at203// least MinGoal of real capital per slot for the claim window. Filling204// the whole table therefore costs >= 1,024 GNOT locked for ~a month205// per cycle, against a realm whose existing campaigns keep working206// regardless. Accepted for a testnet deployment and recorded in the207// deployment record (audit finding Y1).208const MaxOpenCampaigns = 1024209210// MaxOpenPerCreator bounds how much of the global cap one creator can211// occupy (permission_registry R1 precedent: an uncapped per-principal212// count lets one key monopolize a shared bound).213const MaxOpenPerCreator = int64(8)214215// Input bounds. Both fields are free text and are sanitized before216// reaching markdown.217const (218	MaxTitleLen = 100219	MaxMemoLen  = 256220)221222// MaxRenderRows bounds the campaign list on the index page.223const MaxRenderRows = 20224225// Campaign states. statePaid, stateFailed, stateCancelled and226// stateLapsed are terminal: the campaign's deferral has been consumed227// and can never be consumed again.228const (229	stateFunding   = "funding"230	statePaid      = "paid"231	stateFailed    = "failed"232	stateCancelled = "cancelled"233	stateLapsed    = "lapsed"234)235236type campaign struct {237	id        int64238	creator   address239	title     string240	memo      string241	goal      int64242	feeBps    int64 // snapshotted at Launch243	createdAt int64244	deadline  int64 // createdAt + duration; pledging while height < deadline245	claimEnd  int64 // deadline + ClaimWindowBlocks; == deferral ExpiresAt246	defID     uint64247	state     string248249	raised  int64     // coins currently held for this campaign250	pledges *avl.Tree // backer address string -> int64 (> 0); nil after payout251	backers int       // final count, frozen when pledges is dropped252}253254var (255	self address256257	campaigns = avl.NewTree() // padID(id) -> *campaign258	nextID    int64259260	// openByCreator tracks each creator's unsettled campaigns:261	// address string -> int64 count (> 0).262	openByCreator = avl.NewTree()263264	// totalBacked is the B term of H == B + F + S: the sum of raised265	// across all stored campaigns, maintained O(1) at every transition.266	totalBacked int64267268	// lifetime counters, rendered for operators.269	lifetimePledged  int64270	lifetimePaidOut  int64 // creator payouts, net of fee271	lifetimeReturned int64 // unpledges + refunds272273	// ledger provides the fee arithmetic, the fee cap, and the fee pot.274	// Its user-balance side is used only transiently inside275	// CreatorClaim, so UsersTotal() is 0 between transactions.276	ledger = feeledger.MustNew(MaxFeeBps)277278	// book holds one open deferral per unsettled campaign. The bounds279	// are the primitive's own validation: a duration outside280	// [MinDurationBlocks, MaxDurationBlocks] is refused by Schedule.281	book = duebook.MustNew(MinDurationBlocks, MaxDurationBlocks, MaxOpenCampaigns)282)283284func init() {285	self = unsafe.CurrentRealm().Address()286}287288// rejectStraySend aborts when coins are attached to a non-payable call.289// This realm holds funds, so a stray send would become sweepable290// surplus, silently converting a user's coins into operator funds.291// Aborting reverts the transfer instead. Guarded on IsUserCall, not292// IsUser, because a MsgRun ephemeral can consume the OriginSend envelope293// before forwarding control; for a realm-routed call the envelope lands294// at the intermediary, so there is nothing here to reject and the guard295// 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 that300		// hands this guard a stale realm value fails closed, not open301		// (coinio names IsCurrent as the guard secondary realm302		// 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}309310// --- campaign lifecycle ---311312// Launch opens a campaign and returns its id. Anyone may launch; the313// caller becomes the creator. The fee terms (SuccessFeeBps at this314// moment — a compile-time constant, so always SuccessFeeBps) are315// snapshotted into the campaign and are fixed from here on. The funding316// duration is given in blocks and must be within317// [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()321322	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	}338339	now := runtime.ChainHeight()340	id := nextID + 1341342	// The deferral is the settlement authorization: due at the deadline,343	// expiring when the claim window closes. Duration bounds and the344	// global open-campaign cap are enforced here by the primitive.345	defID := book.MustSchedule(key, itoa(id), now, durationBlocks, ClaimWindowBlocks)346347	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 = id362	campaigns.Set(padID(id), c)363	setCreatorOpen(key, creatorOpen(key)+1)364365	chain.Emit("Launched", "id", itoa(id), "creator", key,366		"goal", itoa(goal), "deadline", itoa(c.deadline),367		"feeBps", itoa(c.feeBps))368	return id369}370371// Pledge backs a campaign with the attached coins. Direct EOA calls372// with -send only (the receipt-guaranteed shape); exactly one positive373// 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	}386387	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	}404405	c.pledges.Set(key, newPledge)406	c.raised = newRaised407	totalBacked = newBacked408	lifetimePledged = newLifetime409410	chain.Emit("Pledged", "id", itoa(c.id), "backer", key,411		"amount", itoa(amount), "raised", itoa(c.raised))412}413414// Unpledge returns the caller's entire pledge while funding is still415// 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}429430// CreatorClaim collects a successful campaign: creator only, from the431// deadline until the claim window closes, and only with the goal432// reached. Pays raised minus the snapshotted fee to the creator and433// 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	}448449	// Timing (not due / expired) is the primitive's verdict; its error450	// 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 with454		// this campaign's creator as owner); checked because the payout455		// below is irreversible.456		panic("settlement owner mismatch")457	}458459	amount := c.raised460	credited, fee := ledger.MustDeposit(c.creator.String(), amount, c.feeBps)461	ledger.MustWithdraw(c.creator.String(), credited)462463	// Debit the campaign before the coins move.464	c.raised = 0465	totalBacked -= amount466	c.backers = c.pledges.Size()467	c.pledges = nil // drop the whole table; entitlements are settled468	c.state = statePaid469	decCreatorOpen(c.creator.String())470	newPaid, ok := checkedAdd(lifetimePaidOut, credited)471	if !ok {472		panic("lifetime paid would overflow")473	}474	lifetimePaidOut = newPaid475476	coinio.Payout(0, cur, c.creator, Denom, credited)477478	chain.Emit("Claimed", "id", itoa(c.id), "creator", c.creator.String(),479		"amount", itoa(credited), "fee", itoa(fee))480}481482// CreatorCancel settles the caller's own campaign as cancelled and opens483// refunds. Permitted at any time while the settlement deferral is open —484// during funding, and equally after a successful deadline (renouncing485// 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	}496497	book.MustCancel(c.defID, caller.String())498	c.state = stateCancelled499	decCreatorOpen(c.creator.String())500501	chain.Emit("Cancelled", "id", itoa(c.id), "creator", c.creator.String(),502		"raised", itoa(c.raised))503}504505// SettleFailed settles a campaign that reached its deadline with the506// goal unmet. Anyone may call it — there is nothing to steer: refunds507// were already open on this path, so the only effects are making the508// failure terminal and freeing the campaign's duebook slot (and its509// creator's cap slot) immediately instead of at the end of the claim510// window. This is what makes slot-squatting with unfunded campaigns511// pointless: holding a slot past the deadline requires meeting the512// goal, i.e. real locked capital.513//514// The deferral is consumed with the duebook's Claim under this realm's515// policy (goal unmet), which the primitive explicitly leaves to its516// consumer; the timing verdict (not due before the deadline) is the517// 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	}527528	book.MustClaim(c.defID, runtime.ChainHeight())529	c.state = stateFailed530	decCreatorOpen(c.creator.String())531532	chain.Emit("Failed", "id", itoa(c.id), "by",533		cur.Previous().Address().String(), "raised", itoa(c.raised))534}535536// Lapse settles a campaign whose claim window has closed. Anyone may537// call it — a lapsed campaign's pot belongs to its backers, and the538// 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	}546547	book.MustExpire(c.defID, runtime.ChainHeight())548	c.state = stateLapsed549	decCreatorOpen(c.creator.String())550551	chain.Emit("Lapsed", "id", itoa(c.id), "by",552		cur.Previous().Address().String(), "raised", itoa(c.raised))553}554555// Refund returns the caller's entire pledge from a campaign that will556// never pay its creator: cancelled, lapsed, or past its deadline with557// the goal unmet. Always whole, always fee-free. While a succeeded558// campaign is inside its claim window the pot is the creator's to559// collect, so Refund refuses — if the creator never collects, Lapse560// 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}570571// Prune removes a settled campaign record that holds no coins,572// reclaiming its storage. Anyone may call it: the chain refunds the573// freed storage deposit to the pruning transaction, which is the574// incentive. A record with raised > 0 still carries backer entitlements575// 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}589590// --- fees and surplus ---591592// WithdrawFees pays the entire accrued fee pot to the compile-time593// FeeCollector. Anyone may call it: the destination is fixed, so there594// 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 fees604}605606// SweepSurplus sends out-of-band coins to the FeeCollector: for the pot607// denom, everything above tracked liabilities; for any foreign denom,608// the full balance. Tracked backer money and the fee pot are609// untouchable by construction (coinio.Sweep refuses to dip below the610// 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 swept621}622623// --- views ---624625// 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.state631}632633// CampaignTitle returns a campaign's raw title (unsanitized: callers634// rendering it into markdown must sanitize, as this realm's Render does).635func CampaignTitle(id int64) string { return mustGet(id).title }636637// CampaignMemo returns a campaign's raw memo (same caveat as the title).638func CampaignMemo(id int64) string { return mustGet(id).memo }639640// 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()) }644645// PledgeOf returns addr's live pledge in a campaign (0 if none, and 0646// 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 0651	}652	return pledgeOf(c, addr.String())653}654655// IsRefundable reports whether Refund would currently accept this656// campaign (for any backer with a live pledge).657func IsRefundable(id int64) bool {658	return refundable(mustGet(id), runtime.ChainHeight())659}660661// NumCampaigns returns how many campaigns have ever been launched.662// Prune removes records but never reuses ids.663func NumCampaigns() int64 { return nextID }664665// OpenCampaigns returns how many campaigns are currently unsettled.666func OpenCampaigns() int64 { return int64(book.OpenCount()) }667668// OpenOf returns how many unsettled campaigns addr currently has.669func OpenOf(addr address) int64 { return creatorOpen(addr.String()) }670671// TotalBacked returns the B term: coins held for campaigns.672func TotalBacked() int64 { return totalBacked }673674// FeesAccrued returns the F term: charged, not yet withdrawn.675func FeesAccrued() int64 { return ledger.FeesAccrued() }676677// Liabilities returns B + F — everything this realm owes.678func Liabilities() int64 { return totalBacked + ledger.Liabilities() }679680// Held returns H: the ugnot actually at this realm's address.681func Held() int64 { return coinio.HeldAt(self, Denom) }682683// Surplus returns H - (B + F) — out-of-band coins, sweepable.684func Surplus() int64 { return Held() - Liabilities() }685686// Address returns this realm's own address.687func Address() address { return self }688689// Height returns the current chain height, the clock every deadline is690// measured against.691func Height() int64 { return runtime.ChainHeight() }692693// --- render ---694695// Render shows the realm at "" and a campaign page at "<id>". Titles696// and memos are free text and pass through the ecosystem sanitizer697// 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 := 0736		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 >= MaxRenderRows743		})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 out771}772773func 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()784785	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 failed813		// 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 out826}827828// --- internals ---829830func 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}837838// payBack is the shared whole-pledge return path for Unpledge and839// Refund: debit the backer's entry and the campaign before the840// 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 -= amount849	totalBacked -= amount850	newReturned, ok := checkedAdd(lifetimeReturned, amount)851	if !ok {852		panic("lifetime returned would overflow")853	}854	lifetimeReturned = newReturned855856	coinio.Payout(0, cur, backer, Denom, amount)857858	chain.Emit(event, "id", itoa(c.id), "backer", key,859		"amount", itoa(amount), "raised", itoa(c.raised))860}861862// refundable: failed, cancelled and lapsed campaigns always; an863// unsettled campaign once its deadline has passed with the goal unmet.864// A succeeded campaign inside its claim window is NOT refundable — the865// 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 true870	case stateFunding:871		return now >= c.deadline && c.raised < c.goal872	default:873		return false874	}875}876877func status(c *campaign, now int64) string {878	if c.state != stateFunding {879		return c.state880	}881	switch {882	case now < c.deadline:883		return stateFunding884	case c.raised >= c.goal:885		return "succeeded (awaiting creator claim)"886	default:887		return "failed (refunds open)"888	}889}890891func pledgeOf(c *campaign, key string) int64 {892	v := c.pledges.Get(key)893	if v == nil {894		return 0895	}896	return v.(int64)897}898899func backerCount(c *campaign) int {900	if c.pledges == nil {901		return c.backers902	}903	return c.pledges.Size()904}905906func creatorOpen(key string) int64 {907	v := openByCreator.Get(key)908	if v == nil {909		return 0910	}911	return v.(int64)912}913914func setCreatorOpen(key string, n int64) {915	if n <= 0 {916		openByCreator.Remove(key)917		return918	}919	openByCreator.Set(key, n)920}921922func decCreatorOpen(key string) { setCreatorOpen(key, creatorOpen(key)-1) }923924func itoa(n int64) string { return strconv.FormatInt(n, 10) }925926// bps renders a basis-point figure as a human percentage, exactly.927func bps(n int64) string {928	whole := n / 100929	frac := n % 100930	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}942943// 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 / goal947	r := raised % goal948	var p int64949	if r <= (int64(1)<<62)/100 {950		p = q*100 + r*100/goal951	} else {952		// goal (> r) is so large that r*100 would overflow; the scaled953		// form loses at most a rounding step, which is cosmetic here.954		p = q*100 + r/(goal/100)955	}956	return itoa(p) + "%"957}958959// 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 = 20963	if len(s) >= width {964		return s965	}966	return zeros[:width-len(s)] + s967}968969const zeros = "00000000000000000000"970971// checkedAdd returns a+b and reports whether the addition did not972// overflow int64.973func checkedAdd(a, b int64) (int64, bool) {974	sum := a + b975	if (b > 0 && sum < a) || (b < 0 && sum > a) {976		return 0, false977	}978	return sum, true979}980

Functions

  • Address() string

  • CampaignInfo(id int64) (creator string, goal int64, raised int64, feeBps int64, createdAt int64, deadline int64, claimEnd int64, backers int, state string)

  • CampaignMemo(id int64) string

  • CampaignTitle(id int64) string

  • CreatorCancel(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)

  • CreatorClaim(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)

  • FeesAccrued() int64

  • Height() int64

  • Held() int64

  • IsRefundable(id int64) bool

  • Lapse(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)

  • Launch(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, title string, memo string, goal int64, durationBlocks int64) int64

  • Liabilities() int64

  • NumCampaigns() int64

  • OpenCampaigns() int64

  • OpenOf(addr string) int64

  • Pledge(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)

  • PledgeOf(id int64, addr string) int64

  • Prune(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)

  • Refund(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)

  • Render(path string) string

  • SettleFailed(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)

  • Status(id int64) string

  • Surplus() int64

  • SweepSurplus(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, denom string) int64

  • TotalBacked() int64

  • Unpledge(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)

  • WithdrawFees(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}) int64

Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.

Rendered

RenderedRawgnoweb ↗

vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.