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}980Address() 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.
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.