Transaction
5F5C7EFCD265D4…BF8F7E14FEE3
Block 490,126 · index 0 · indexed
Summary
- Hash
- 5F5C7EFCD265D417098F8A713EDEF794CCEBC06D9ED53FB0C2ACBF8F7E14FEE3
- Block
- 490,126
- Size
- 65283 bytes
- Gas used
- 85,219,496 / 128,000,000
- Fee
- 128000 ugnot
- Status
- success
Messages
- Attached funds
- 10000000 ugnot
Arguments · 9
- #1crowdfund
- #2crowdfund.gno
- #3// Realm crowdfund is permissionless all-or-nothing crowdfunding in GNOT: // a creator opens a campaign with a funding goal and a block deadline, // backers pledge real coins that the realm holds, and settlement is // conditional — if the goal is reached by the deadline the creator may // collect the pot (minus a bounded success fee), and if it is not, every // backer reclaims exactly what they pledged, fee-free. // // WHY THIS EXISTS (see DISCOVERY.md): at the heights recorded there, // nothing on onyx-1 holds backer coins against a goal-and-deadline // condition at all, and the one live crowdfunding realm in the wider // ecosystem (mainnet r/moul/x/daily/crowdfund, read in full) is // explicitly accounting-only — its own doc says "No real coin moves". // The one real-coin implementation found anywhere ran on retired pearl-1 // under a third-party namespace and is unreachable. This realm's delta // is stated at that size and no larger: it is the assurance-contract // mechanic — conditional custody with a fee-free refund path — done with // this portfolio's live-validated custody discipline, not a new idea. // // COMPOSITION: coin movement is delegated to coinio, fee arithmetic and // the fee pot to feeledger, settlement timing and exactly-once // authorization to duebook, ordered storage to p/nt/avl/v0, and // free-text render output to the ecosystem sanitizer // p/nt/markdown/sanitize/v0. This realm owns only the campaign state // machine and the conservation bookkeeping. // // THE SETTLEMENT IS ONE DEFERRAL. Launch schedules exactly one duebook // deferral per campaign — owner: the creator, due: the deadline, expiry: // the end of the claim window — and every terminal transition is one of // the ways that deferral can be consumed: // // CreatorClaim -> book.Claim (due, goal met, creator collects) // SettleFailed -> book.Claim (due, goal UNMET, anyone; duebook // leaves claim policy to its consumer) // CreatorCancel -> book.Cancel (owner renounces, any time while open) // Lapse -> book.Expire (anyone, once the claim window is over) // // Because duebook consumes a deferral before returning and never reuses // an ID, a campaign can settle AT MOST ONCE, structurally — double-claim // is not guarded against, it is unrepresentable. The book's open- // deferral cap is likewise this realm's cap on open campaigns. // // LIFECYCLE (stored state in brackets; "succeeded"/"failed" are derived // views of an open campaign after its deadline, not stored states): // // Launch (anyone) : opens [funding]; schedules the deferral. // Pledge (backer, +send) : while height < deadline. Real coins. // Unpledge (backer) : full own pledge back, while height < // deadline. All-or-nothing holds: nothing // is locked until the deadline passes. // CreatorClaim (creator) : height in [deadline, claimDeadline), // raised >= goal -> [paid]. Pays // raised - fee to the creator, accrues the // fee. The ONLY transition that charges // anything. // SettleFailed (ANYONE) : height >= deadline, raised < goal -> // [failed]. Makes the failure terminal and // frees the campaign's slots immediately; // refunds were already open on this path. // CreatorCancel (creator) : while the deferral is open -> [cancelled]. // Refunds open. A creator who cancels after // succeeding is renouncing the pot. // Lapse (ANYONE) : height >= claimDeadline -> [lapsed]. // Refunds open. The permissionless valve: a // creator who never collects cannot strand // backer money, and a dead campaign cannot // hold its duebook slot forever. // Refund (backer) : own pledge back, fee-free, whenever the // campaign is [cancelled], [lapsed], or open // past its deadline with raised < goal. // Prune (ANYONE) : removes a settled, fully-drained campaign // record. The freed storage deposit is // refunded by the chain to the CALLER, which // is the incentive to call it. // WithdrawFees (ANYONE) : pays the accrued fee pot to the // compile-time FeeCollector. Permissionless // because the destination is fixed. // SweepSurplus (ANYONE) : out-of-band coins to the FeeCollector, // never touching tracked liabilities. // // REFUNDS NEVER RACE THE CLAIM: while a succeeded campaign is inside its // claim window, Refund refuses — the pot is the creator's to collect. // The moment the window ends (Lapse) or the creator renounces (Cancel), // and at any time after a failed deadline, Refund pays each backer // exactly their pledge. There is no partial outcome and no fee on any // refund path, so a backer's worst case is their money back. // // THE FEE, precisely: feeBps is snapshotted at Launch from the // compile-time SuccessFeeBps, so a campaign's terms are fixed when it is // created and visible in CampaignInfo from that moment. The fee is // charged ONCE, at CreatorClaim, on the raised amount, with feeledger's // floor rounding (rounding favors the creator). The ledger is // constructed with the compile-time MaxFeeBps cap, so a fee above the // cap is refused by the primitive, not by a check in this file. There is // no fee on pledges, no fee on refunds, and no fee on failure. // // THERE IS NO ADMIN. The fee rate, the fee collector, every bound and // every window are compile-time constants; no address can change terms, // pause the realm, or touch a campaign it does not own. The deployer has // no privilege of any kind after deployment. // // MONETARY INVARIANT (conservation): let H be the ugnot held at this // realm's address, B the sum of raised amounts across all stored // campaigns (tracked O(1) as totalBacked), F the accrued fee pot, and // S >= 0 the out-of-band surplus: // // H == B + F + S // // Every transition moves value between exactly two terms inside one // transaction: Pledge raises H and B together (coinio.Receive is the // receipt-guaranteed shape); Unpledge and Refund debit B before the // identical amount leaves H (checks-effects-interactions); CreatorClaim // moves one campaign's raised out of B, splits it into a creator payout // (leaves H) and a fee (enters F); WithdrawFees debits F before the // payout; any panic aborts the whole transaction; this realm never // issues or removes coins. // // ONLY GNOT IS ACCEPTED: Pledge rejects any envelope that is not exactly // one positive ugnot coin (coinio.Receive). Every other entrypoint // rejects attached coins outright rather than converting them into // sweepable surplus. // // AUTHORIZATION: every identity is derived from the crossing // entrypoint's cur.Previous().Address(). No function takes a caller // identity as a parameter, so pledging as another backer, claiming // another creator's campaign, or refunding another backer's pledge is // impossible by construction. // // STORAGE: a backer's pledge entry is paid for by the backer's own // transaction deposit; a campaign record by the creator's. CreatorClaim // drops the whole pledge table in one assignment, and Prune removes the // settled record — both free storage, and the chain refunds freed // deposits to the transaction that frees them. package crowdfund import ( "chain" "chain/runtime" "chain/runtime/unsafe" "strconv" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/duebook" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/feeledger" "gno.land/p/nt/avl/v0" "gno.land/p/nt/markdown/sanitize/v0" ) // RealmPath is this realm's own path, used to build Render links. const RealmPath = "/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/crowdfund" // Denom is the only asset this realm holds. const Denom = "ugnot" // MaxFeeBps is the hard ceiling on any success fee this realm could ever // charge, enforced by the feeledger the realm is constructed with — a // feeBps above it is refused by the primitive's own validation, not by a // check in this file. 500 bps = 5%. const MaxFeeBps = int64(500) // SuccessFeeBps is the fee actually snapshotted into every campaign at // Launch: 100 bps = 1%, charged once, on CreatorClaim, on the raised // amount, floor-rounded in the creator's favor. fee_split precedent: // the most conservative live fee in this portfolio is also 1%. const SuccessFeeBps = int64(100) // FeeCollector receives withdrawn fees and swept surplus. Compile-time // constant: there is no setter and no transfer path. It is the deploying // namespace's address — stated plainly: the operator of this realm. const FeeCollector = address("g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39") // MinGoal keeps dust campaigns out: 1 GNOT. const MinGoal = int64(1_000_000) // MinPledge bounds pledge-table growth per GNOT of hostile capital // (audit open question 1): at 0.01 GNOT per entry, bloating one // campaign's table to even 10,000 entries costs 100 refundable-but- // locked GNOT plus gas. Honest micro-backing survives: 0.01 GNOT is // two orders of magnitude below MinGoal. const MinPledge = int64(10_000) // MinDurationBlocks and MaxDurationBlocks bound a campaign's funding // period, enforced structurally: they are the duebook's minDelay and // maxDelay, so an out-of-range duration is refused by the primitive. // At onyx-1's roughly 3-5s blocks, ~1,200 blocks is on the order of an // hour or two, and ~1,300,000 blocks is on the order of 45-75 days. const ( MinDurationBlocks = int64(1_200) MaxDurationBlocks = int64(1_300_000) ) // ClaimWindowBlocks is how long after the deadline a successful creator // may collect before the campaign becomes permissionlessly lapsable and // the pot refundable. It is every campaign's deferral ttl. const ClaimWindowBlocks = int64(650_000) // MaxOpenCampaigns caps simultaneously unsettled campaigns, via the // duebook's own open-deferral cap. The AVAILABILITY BOUND this implies // is documented rather than hidden: Launch is permissionless, so the // cap is exhaustible in principle. What each unsettled slot costs an // attacker is the mitigation — a failed campaign is permissionlessly // settleable (SettleFailed) the moment its deadline passes, so holding // a slot past its deadline requires MEETING THE GOAL, i.e. locking at // least MinGoal of real capital per slot for the claim window. Filling // the whole table therefore costs >= 1,024 GNOT locked for ~a month // per cycle, against a realm whose existing campaigns keep working // regardless. Accepted for a testnet deployment and recorded in the // deployment record (audit finding Y1). const MaxOpenCampaigns = 1024 // MaxOpenPerCreator bounds how much of the global cap one creator can // occupy (permission_registry R1 precedent: an uncapped per-principal // count lets one key monopolize a shared bound). const MaxOpenPerCreator = int64(8) // Input bounds. Both fields are free text and are sanitized before // reaching markdown. const ( MaxTitleLen = 100 MaxMemoLen = 256 ) // MaxRenderRows bounds the campaign list on the index page. const MaxRenderRows = 20 // Campaign states. statePaid, stateFailed, stateCancelled and // stateLapsed are terminal: the campaign's deferral has been consumed // and can never be consumed again. const ( stateFunding = "funding" statePaid = "paid" stateFailed = "failed" stateCancelled = "cancelled" stateLapsed = "lapsed" ) type campaign struct { id int64 creator address title string memo string goal int64 feeBps int64 // snapshotted at Launch createdAt int64 deadline int64 // createdAt + duration; pledging while height < deadline claimEnd int64 // deadline + ClaimWindowBlocks; == deferral ExpiresAt defID uint64 state string raised int64 // coins currently held for this campaign pledges *avl.Tree // backer address string -> int64 (> 0); nil after payout backers int // final count, frozen when pledges is dropped } var ( self address campaigns = avl.NewTree() // padID(id) -> *campaign nextID int64 // openByCreator tracks each creator's unsettled campaigns: // address string -> int64 count (> 0). openByCreator = avl.NewTree() // totalBacked is the B term of H == B + F + S: the sum of raised // across all stored campaigns, maintained O(1) at every transition. totalBacked int64 // lifetime counters, rendered for operators. lifetimePledged int64 lifetimePaidOut int64 // creator payouts, net of fee lifetimeReturned int64 // unpledges + refunds // ledger provides the fee arithmetic, the fee cap, and the fee pot. // Its user-balance side is used only transiently inside // CreatorClaim, so UsersTotal() is 0 between transactions. ledger = feeledger.MustNew(MaxFeeBps) // book holds one open deferral per unsettled campaign. The bounds // are the primitive's own validation: a duration outside // [MinDurationBlocks, MaxDurationBlocks] is refused by Schedule. book = duebook.MustNew(MinDurationBlocks, MaxDurationBlocks, MaxOpenCampaigns) ) func init() { self = unsafe.CurrentRealm().Address() } // rejectStraySend aborts when coins are attached to a non-payable call. // This realm holds funds, so a stray send would become sweepable // surplus, silently converting a user's coins into operator funds. // Aborting reverts the transfer instead. Guarded on IsUserCall, not // IsUser, because a MsgRun ephemeral can consume the OriginSend envelope // before forwarding control; for a realm-routed call the envelope lands // at the intermediary, so there is nothing here to reject and the guard // deliberately fails open (treasury_board documents the same scope). func rejectStraySend(cur realm) { if !cur.IsCurrent() { // Every call site forwards a crossing entrypoint's own live cur, // so this cannot fire today; it exists so a future refactor that // hands this guard a stale realm value fails closed, not open // (coinio names IsCurrent as the guard secondary realm // parameters require, and this helper follows that discipline). panic("crowdfund: realm capability is not current") } if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 { panic("this entrypoint does not accept coins") } } // --- campaign lifecycle --- // Launch opens a campaign and returns its id. Anyone may launch; the // caller becomes the creator. The fee terms (SuccessFeeBps at this // moment — a compile-time constant, so always SuccessFeeBps) are // snapshotted into the campaign and are fixed from here on. The funding // duration is given in blocks and must be within // [MinDurationBlocks, MaxDurationBlocks] — enforced by the duebook. func Launch(cur realm, title, memo string, goal, durationBlocks int64) int64 { rejectStraySend(cur) creator := cur.Previous().Address() if title == "" { panic("title must not be empty") } if len(title) > MaxTitleLen { panic("title exceeds " + strconv.Itoa(MaxTitleLen) + " bytes") } if len(memo) > MaxMemoLen { panic("memo exceeds " + strconv.Itoa(MaxMemoLen) + " bytes") } if goal < MinGoal { panic("goal must be at least " + itoa(MinGoal) + Denom) } key := creator.String() if creatorOpen(key) >= MaxOpenPerCreator { panic("creator already has " + itoa(MaxOpenPerCreator) + " unsettled campaigns") } now := runtime.ChainHeight() id := nextID + 1 // The deferral is the settlement authorization: due at the deadline, // expiring when the claim window closes. Duration bounds and the // global open-campaign cap are enforced here by the primitive. defID := book.MustSchedule(key, itoa(id), now, durationBlocks, ClaimWindowBlocks) c := &campaign{ id: id, creator: creator, title: title, memo: memo, goal: goal, feeBps: SuccessFeeBps, createdAt: now, deadline: now + durationBlocks, claimEnd: now + durationBlocks + ClaimWindowBlocks, defID: defID, state: stateFunding, pledges: avl.NewTree(), } nextID = id campaigns.Set(padID(id), c) setCreatorOpen(key, creatorOpen(key)+1) chain.Emit("Launched", "id", itoa(id), "creator", key, "goal", itoa(goal), "deadline", itoa(c.deadline), "feeBps", itoa(c.feeBps)) return id } // Pledge backs a campaign with the attached coins. Direct EOA calls // with -send only (the receipt-guaranteed shape); exactly one positive // ugnot coin. Open while height < deadline. func Pledge(cur realm, id int64) { backer, amount := coinio.Receive(0, cur, Denom) c := mustGet(id) if c.state != stateFunding { panic("campaign is settled") } if runtime.ChainHeight() >= c.deadline { panic("funding is closed") } if amount < MinPledge { panic("pledge at least " + itoa(MinPledge) + Denom) } key := backer.String() newPledge, ok := checkedAdd(pledgeOf(c, key), amount) if !ok { panic("pledge would overflow") } newRaised, ok := checkedAdd(c.raised, amount) if !ok { panic("raised would overflow") } newBacked, ok := checkedAdd(totalBacked, amount) if !ok { panic("total backed would overflow") } newLifetime, ok := checkedAdd(lifetimePledged, amount) if !ok { panic("lifetime pledged would overflow") } c.pledges.Set(key, newPledge) c.raised = newRaised totalBacked = newBacked lifetimePledged = newLifetime chain.Emit("Pledged", "id", itoa(c.id), "backer", key, "amount", itoa(amount), "raised", itoa(c.raised)) } // Unpledge returns the caller's entire pledge while funding is still // open. All-or-nothing means nothing is committed before the deadline, // so backing out is always whole and always free. func Unpledge(cur realm, id int64) { rejectStraySend(cur) caller := cur.Previous().Address() c := mustGet(id) if c.state != stateFunding { panic("campaign is settled") } if runtime.ChainHeight() >= c.deadline { panic("funding is closed; use Refund if the campaign failed") } payBack(cur, c, caller, "Unpledged") } // CreatorClaim collects a successful campaign: creator only, from the // deadline until the claim window closes, and only with the goal // reached. Pays raised minus the snapshotted fee to the creator and // accrues the fee. The duebook Claim consumes the campaign's deferral, // so this can succeed at most once per campaign, ever. func CreatorClaim(cur realm, id int64) { rejectStraySend(cur) caller := cur.Previous().Address() c := mustGet(id) if caller != c.creator { panic("creator only") } if c.state != stateFunding { panic("campaign is settled") } if c.raised < c.goal { panic("goal not reached") } // Timing (not due / expired) is the primitive's verdict; its error // names the reason. A success consumes the deferral before returning. d := book.MustClaim(c.defID, runtime.ChainHeight()) if d.Owner != c.creator.String() { // Unreachable by construction (the deferral was scheduled with // this campaign's creator as owner); checked because the payout // below is irreversible. panic("settlement owner mismatch") } amount := c.raised credited, fee := ledger.MustDeposit(c.creator.String(), amount, c.feeBps) ledger.MustWithdraw(c.creator.String(), credited) // Debit the campaign before the coins move. c.raised = 0 totalBacked -= amount c.backers = c.pledges.Size() c.pledges = nil // drop the whole table; entitlements are settled c.state = statePaid decCreatorOpen(c.creator.String()) newPaid, ok := checkedAdd(lifetimePaidOut, credited) if !ok { panic("lifetime paid would overflow") } lifetimePaidOut = newPaid coinio.Payout(0, cur, c.creator, Denom, credited) chain.Emit("Claimed", "id", itoa(c.id), "creator", c.creator.String(), "amount", itoa(credited), "fee", itoa(fee)) } // CreatorCancel settles the caller's own campaign as cancelled and opens // refunds. Permitted at any time while the settlement deferral is open — // during funding, and equally after a successful deadline (renouncing // the pot). The duebook Cancel is owner-gated and consumes the deferral. func CreatorCancel(cur realm, id int64) { rejectStraySend(cur) caller := cur.Previous().Address() c := mustGet(id) if caller != c.creator { panic("creator only") } if c.state != stateFunding { panic("campaign is settled") } book.MustCancel(c.defID, caller.String()) c.state = stateCancelled decCreatorOpen(c.creator.String()) chain.Emit("Cancelled", "id", itoa(c.id), "creator", c.creator.String(), "raised", itoa(c.raised)) } // SettleFailed settles a campaign that reached its deadline with the // goal unmet. Anyone may call it — there is nothing to steer: refunds // were already open on this path, so the only effects are making the // failure terminal and freeing the campaign's duebook slot (and its // creator's cap slot) immediately instead of at the end of the claim // window. This is what makes slot-squatting with unfunded campaigns // pointless: holding a slot past the deadline requires meeting the // goal, i.e. real locked capital. // // The deferral is consumed with the duebook's Claim under this realm's // policy (goal unmet), which the primitive explicitly leaves to its // consumer; the timing verdict (not due before the deadline) is the // primitive's own. func SettleFailed(cur realm, id int64) { rejectStraySend(cur) c := mustGet(id) if c.state != stateFunding { panic("campaign is settled") } if c.raised >= c.goal { panic("goal reached; the claim window is the creator's") } book.MustClaim(c.defID, runtime.ChainHeight()) c.state = stateFailed decCreatorOpen(c.creator.String()) chain.Emit("Failed", "id", itoa(c.id), "by", cur.Previous().Address().String(), "raised", itoa(c.raised)) } // Lapse settles a campaign whose claim window has closed. Anyone may // call it — a lapsed campaign's pot belongs to its backers, and the // caller is doing them (and the duebook's open-slot budget) a service. // The duebook Expire refuses while the window is still open. func Lapse(cur realm, id int64) { rejectStraySend(cur) c := mustGet(id) if c.state != stateFunding { panic("campaign is settled") } book.MustExpire(c.defID, runtime.ChainHeight()) c.state = stateLapsed decCreatorOpen(c.creator.String()) chain.Emit("Lapsed", "id", itoa(c.id), "by", cur.Previous().Address().String(), "raised", itoa(c.raised)) } // Refund returns the caller's entire pledge from a campaign that will // never pay its creator: cancelled, lapsed, or past its deadline with // the goal unmet. Always whole, always fee-free. While a succeeded // campaign is inside its claim window the pot is the creator's to // collect, so Refund refuses — if the creator never collects, Lapse // reopens this path. func Refund(cur realm, id int64) { rejectStraySend(cur) caller := cur.Previous().Address() c := mustGet(id) if !refundable(c, runtime.ChainHeight()) { panic("campaign is not refundable") } payBack(cur, c, caller, "Refunded") } // Prune removes a settled campaign record that holds no coins, // reclaiming its storage. Anyone may call it: the chain refunds the // freed storage deposit to the pruning transaction, which is the // incentive. A record with raised > 0 still carries backer entitlements // and is not prunable. func Prune(cur realm, id int64) { rejectStraySend(cur) c := mustGet(id) if c.state == stateFunding { panic("campaign is not settled") } if c.raised != 0 { panic("campaign still holds backer funds") } campaigns.Remove(padID(c.id)) chain.Emit("Pruned", "id", itoa(c.id), "by", cur.Previous().Address().String()) } // --- fees and surplus --- // WithdrawFees pays the entire accrued fee pot to the compile-time // FeeCollector. Anyone may call it: the destination is fixed, so there // is nothing for a caller to steer. func WithdrawFees(cur realm) int64 { rejectStraySend(cur) fees := ledger.WithdrawFees() if fees == 0 { panic("no fees accrued") } coinio.Payout(0, cur, FeeCollector, Denom, fees) chain.Emit("FeesWithdrawn", "to", FeeCollector.String(), "amount", itoa(fees)) return fees } // SweepSurplus sends out-of-band coins to the FeeCollector: for the pot // denom, everything above tracked liabilities; for any foreign denom, // the full balance. Tracked backer money and the fee pot are // untouchable by construction (coinio.Sweep refuses to dip below the // reserve). Anyone may call it. func SweepSurplus(cur realm, denom string) int64 { rejectStraySend(cur) reserve := int64(0) if denom == Denom { reserve = Liabilities() } swept := coinio.Sweep(0, cur, FeeCollector, denom, reserve) chain.Emit("Swept", "denom", denom, "to", FeeCollector.String(), "amount", itoa(swept)) return swept } // --- views --- // CampaignInfo returns a campaign's fixed terms and live tallies. func CampaignInfo(id int64) (creator address, goal, raised, feeBps, createdAt, deadline, claimEnd int64, backers int, state string) { c := mustGet(id) return c.creator, c.goal, c.raised, c.feeBps, c.createdAt, c.deadline, c.claimEnd, backerCount(c), c.state } // CampaignTitle returns a campaign's raw title (unsanitized: callers // rendering it into markdown must sanitize, as this realm's Render does). func CampaignTitle(id int64) string { return mustGet(id).title } // CampaignMemo returns a campaign's raw memo (same caveat as the title). func CampaignMemo(id int64) string { return mustGet(id).memo } // Status returns the human verdict for a campaign right now: "funding", // "succeeded (awaiting creator claim)", "failed (refunds open)", // "paid", "cancelled", or "lapsed". func Status(id int64) string { return status(mustGet(id), runtime.ChainHeight()) } // PledgeOf returns addr's live pledge in a campaign (0 if none, and 0 // for settled campaigns whose pledge table has been dropped). func PledgeOf(id int64, addr address) int64 { c := mustGet(id) if c.pledges == nil { return 0 } return pledgeOf(c, addr.String()) } // IsRefundable reports whether Refund would currently accept this // campaign (for any backer with a live pledge). func IsRefundable(id int64) bool { return refundable(mustGet(id), runtime.ChainHeight()) } // NumCampaigns returns how many campaigns have ever been launched. // Prune removes records but never reuses ids. func NumCampaigns() int64 { return nextID } // OpenCampaigns returns how many campaigns are currently unsettled. func OpenCampaigns() int64 { return int64(book.OpenCount()) } // OpenOf returns how many unsettled campaigns addr currently has. func OpenOf(addr address) int64 { return creatorOpen(addr.String()) } // TotalBacked returns the B term: coins held for campaigns. func TotalBacked() int64 { return totalBacked } // FeesAccrued returns the F term: charged, not yet withdrawn. func FeesAccrued() int64 { return ledger.FeesAccrued() } // Liabilities returns B + F — everything this realm owes. func Liabilities() int64 { return totalBacked + ledger.Liabilities() } // Held returns H: the ugnot actually at this realm's address. func Held() int64 { return coinio.HeldAt(self, Denom) } // Surplus returns H - (B + F) — out-of-band coins, sweepable. func Surplus() int64 { return Held() - Liabilities() } // Address returns this realm's own address. func Address() address { return self } // Height returns the current chain height, the clock every deadline is // measured against. func Height() int64 { return runtime.ChainHeight() } // --- render --- // Render shows the realm at "" and a campaign page at "<id>". Titles // and memos are free text and pass through the ecosystem sanitizer // before hitting markdown. func Render(path string) string { if path != "" { return renderCampaign(path) } now := runtime.ChainHeight() held := Held() liab := Liabilities() conservation := "OK" if held < liab { conservation = "VIOLATED" } out := "# Crowdfund\n\n" out += "All-or-nothing crowdfunding in GNOT: hit the goal by the deadline " + "and the creator collects (minus a " + bps(SuccessFeeBps) + " success fee); miss it and every backer reclaims exactly what they " + "pledged, fee-free.\n\n" out += "## Terms (fixed at deploy; no admin, no setters)\n\n" out += "- success fee: " + bps(SuccessFeeBps) + " of raised, charged only on a successful claim (hard cap " + bps(MaxFeeBps) + ")\n" out += "- refunds and unpledges: always whole, always fee-free\n" out += "- goal: at least " + itoa(MinGoal) + Denom + "\n" out += "- funding duration: " + itoa(MinDurationBlocks) + " to " + itoa(MaxDurationBlocks) + " blocks\n" out += "- claim window after deadline: " + itoa(ClaimWindowBlocks) + " blocks, then anyone may Lapse\n" out += "- open campaigns: " + itoa(OpenCampaigns()) + " of " + strconv.Itoa(MaxOpenCampaigns) + " (per creator: " + itoa(MaxOpenPerCreator) + ")\n" out += "- current height: " + itoa(now) + "\n\n" out += "## Accounting (H == B + F + S)\n\n" out += "- backed (B): " + itoa(totalBacked) + Denom + "\n" out += "- fee pot (F): " + itoa(ledger.FeesAccrued()) + Denom + "\n" out += "- held (H): " + itoa(held) + Denom + "\n" out += "- surplus (S): " + itoa(held-liab) + Denom + "\n" out += "- conservation: " + conservation + "\n" out += "- lifetime pledged / paid out / returned: " + itoa(lifetimePledged) + " / " + itoa(lifetimePaidOut) + " / " + itoa(lifetimeReturned) + Denom + "\n\n" out += "## Latest campaigns\n\n" if campaigns.Size() == 0 { out += "None yet. Launch one.\n" } else { shown := 0 campaigns.ReverseIterate("", "", func(_ string, v any) bool { c := v.(*campaign) out += "- [#" + itoa(c.id) + "](" + RealmPath + ":" + itoa(c.id) + ") [" + status(c, now) + "] " + sanitize.InlineText(c.title) + " — " + itoa(c.raised) + " / " + itoa(c.goal) + Denom + "\n" shown++ return shown >= MaxRenderRows }) if int64(shown) < nextID { out += "\n> [!NOTE]\n> Showing the " + strconv.Itoa(shown) + " most recent of " + itoa(nextID) + " ever launched (settled records may have been pruned). " + "Use CampaignInfo(id) for any specific campaign.\n" } } due := book.Due(now, 5) lapsable := book.Expirable(now, 5) if len(due) > 0 || len(lapsable) > 0 { out += "\n## Keeper work\n\n" } if len(due) > 0 { out += "Settlements due — the creator may CreatorClaim(id) if the " + "goal is met; anyone may SettleFailed(id) if it is not:\n\n" for _, d := range due { out += "- campaign #" + sanitize.InlineText(d.Payload) + "\n" } } if len(lapsable) > 0 { out += "Settlements past their claim window — anyone may call Lapse(id) " + "to open refunds:\n\n" for _, d := range lapsable { out += "- campaign #" + sanitize.InlineText(d.Payload) + "\n" } } return out } func renderCampaign(path string) string { id, err := strconv.ParseInt(path, 10, 64) if err != nil { return "> [!WARNING]\n> invalid campaign id\n" } v := campaigns.Get(padID(id)) if v == nil { return "> [!WARNING]\n> unknown campaign id (never launched, or settled and pruned)\n" } c := v.(*campaign) now := runtime.ChainHeight() out := "# #" + itoa(c.id) + ": " + sanitize.InlineText(c.title) + "\n\n" out += "- status: **" + status(c, now) + "**\n" out += "- creator: `" + c.creator.String() + "`\n" out += "- raised: " + itoa(c.raised) + " / " + itoa(c.goal) + Denom + " (" + percent(c.raised, c.goal) + ")\n" out += "- backers: " + strconv.Itoa(backerCount(c)) + "\n" out += "- success fee: " + bps(c.feeBps) + " (snapshotted at launch)\n" out += "- created at block: " + itoa(c.createdAt) + "\n" out += "- deadline: block " + itoa(c.deadline) + "\n" out += "- claim window closes: block " + itoa(c.claimEnd) + "\n" out += "- current height: " + itoa(now) + "\n\n" switch { case c.state == stateFunding && now < c.deadline: out += "Pledge(" + itoa(c.id) + ") with -send to back it; " + "Unpledge(" + itoa(c.id) + ") to back out. " + itoa(c.deadline-now) + " blocks remain.\n" case c.state == stateFunding && c.raised >= c.goal && now < c.claimEnd: out += "**Goal reached.** The creator may CreatorClaim(" + itoa(c.id) + ") until block " + itoa(c.claimEnd) + ".\n" case c.state == stateFunding && c.raised >= c.goal: out += "**Claim window over.** Anyone may Lapse(" + itoa(c.id) + ") to open refunds.\n" case c.state == stateFunding && now < c.claimEnd: out += "**Goal not reached.** Backers may Refund(" + itoa(c.id) + "); anyone may SettleFailed(" + itoa(c.id) + ") to close it out.\n" case c.state == stateFunding: // Past the claim window the deferral is expired, so the failed // path closes via Lapse, not SettleFailed (audit G1). out += "**Goal not reached.** Backers may Refund(" + itoa(c.id) + "); anyone may Lapse(" + itoa(c.id) + ") to close it out.\n" case c.raised > 0: out += "Backers may Refund(" + itoa(c.id) + ").\n" default: out += "Fully settled. Anyone may Prune(" + itoa(c.id) + ") to reclaim the record's storage.\n" } if c.memo != "" { out += "\n## About\n\n" + sanitize.InlineText(c.memo) + "\n" } return out } // --- internals --- func mustGet(id int64) *campaign { v := campaigns.Get(padID(id)) if v == nil { panic("unknown campaign id") } return v.(*campaign) } // payBack is the shared whole-pledge return path for Unpledge and // Refund: debit the backer's entry and the campaign before the // identical amount leaves the realm. func payBack(cur realm, c *campaign, backer address, event string) { key := backer.String() amount := pledgeOf(c, key) if amount == 0 { panic("no pledge to return") } c.pledges.Remove(key) c.raised -= amount totalBacked -= amount newReturned, ok := checkedAdd(lifetimeReturned, amount) if !ok { panic("lifetime returned would overflow") } lifetimeReturned = newReturned coinio.Payout(0, cur, backer, Denom, amount) chain.Emit(event, "id", itoa(c.id), "backer", key, "amount", itoa(amount), "raised", itoa(c.raised)) } // refundable: failed, cancelled and lapsed campaigns always; an // unsettled campaign once its deadline has passed with the goal unmet. // A succeeded campaign inside its claim window is NOT refundable — the // pot is the creator's to collect until Cancel or Lapse says otherwise. func refundable(c *campaign, now int64) bool { switch c.state { case stateFailed, stateCancelled, stateLapsed: return true case stateFunding: return now >= c.deadline && c.raised < c.goal default: return false } } func status(c *campaign, now int64) string { if c.state != stateFunding { return c.state } switch { case now < c.deadline: return stateFunding case c.raised >= c.goal: return "succeeded (awaiting creator claim)" default: return "failed (refunds open)" } } func pledgeOf(c *campaign, key string) int64 { v := c.pledges.Get(key) if v == nil { return 0 } return v.(int64) } func backerCount(c *campaign) int { if c.pledges == nil { return c.backers } return c.pledges.Size() } func creatorOpen(key string) int64 { v := openByCreator.Get(key) if v == nil { return 0 } return v.(int64) } func setCreatorOpen(key string, n int64) { if n <= 0 { openByCreator.Remove(key) return } openByCreator.Set(key, n) } func decCreatorOpen(key string) { setCreatorOpen(key, creatorOpen(key)-1) } func itoa(n int64) string { return strconv.FormatInt(n, 10) } // bps renders a basis-point figure as a human percentage, exactly. func bps(n int64) string { whole := n / 100 frac := n % 100 if frac == 0 { return itoa(whole) + "%" } if frac%10 == 0 { return itoa(whole) + "." + itoa(frac/10) + "%" } pad := "" if frac < 10 { pad = "0" } return itoa(whole) + "." + pad + itoa(frac) + "%" } // percent renders raised/goal as an integer percentage (floor). // goal >= MinGoal > 0 by construction, so no divide guard is needed. func percent(raised, goal int64) string { q := raised / goal r := raised % goal var p int64 if r <= (int64(1)<<62)/100 { p = q*100 + r*100/goal } else { // goal (> r) is so large that r*100 would overflow; the scaled // form loses at most a rounding step, which is cosmetic here. p = q*100 + r/(goal/100) } return itoa(p) + "%" } // padID encodes an id so avl's lexical order matches numeric order. func padID(id int64) string { s := strconv.FormatInt(id, 10) const width = 20 if len(s) >= width { return s } return zeros[:width-len(s)] + s } const zeros = "00000000000000000000" // checkedAdd returns a+b and reports whether the addition did not // overflow int64. func checkedAdd(a, b int64) (int64, bool) { sum := a + b if (b > 0 && sum < a) || (b < 0 && sum > a) { return 0, false } return sum, true }
- #4crowdfund_test.gno
- #5package crowdfund import ( "strings" "testing" "chain" "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/coinio" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( creator1 = testutils.TestAddress("cfcreator1") creator2 = testutils.TestAddress("cfcreator2") backerA = testutils.TestAddress("cfbackera") backerB = testutils.TestAddress("cfbackerb") mallory = testutils.TestAddress("cfmallory") ) // HARNESS NOTES, carried from the treasury_board, vesting and grants // suites (measured there, relied on here): // // - testing.SetRealm records the override per FRAME INDEX: stage the // caller INLINE in the test function's frame before a uassert call, // or inside a helper that stages and calls in one frame of its own. // Never inside a uassert closure — that staging does not take. // // - testing.SkipHeights REPLACES the whole context (OriginCaller, // CurrentRealm, OriginSend included). Re-stage after every skip. // // - Realm globals PERSIST across tests while banker state resets per // test. begin() disarms any leftover envelope and re-seeds the // realm's bank to its carried liabilities, so conservation is // asserted in DELTA form. // // - A panic caught by uassert does NOT roll back realm globals here, // whereas on-chain the whole transaction reverts. Every abort // asserted below happens strictly BEFORE this realm writes state, // with one measured exception documented inline // (TestClaimTimingIsTheDuebooksVerdict). // // - The SEND envelope is process-global: a staged envelope must be // cleared (SetOriginSend(nil)) before the next non-payable call, or // rejectStraySend fires and the test asserts the harness leak, not // the guard. func init() { self = chain.PackageAddress("gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/crowdfund") } type baseline struct { held int64 backed int64 fees int64 } func begin() baseline { testing.SetOriginSend(nil) if l := Liabilities(); l > 0 { testing.IssueCoins(Address(), ugnot(l)) } return baseline{held: Held(), backed: TotalBacked(), fees: FeesAccrued()} } func ugnot(n int64) chain.Coins { return chain.NewCoins(chain.NewCoin(Denom, n)) } // launchAs stages `from` and launches a MinGoal x MinDuration campaign. func launchAs(cur realm, from address, title string) int64 { testing.SetRealm(testing.NewUserRealm(from)) return Launch(cross(cur), title, "", MinGoal, MinDurationBlocks) } // pledgeAs stages a direct EOA call with -send and mirrors the // envelope into the realm's bank, as the chain would. func pledgeAs(cur realm, from address, id, amount int64) { testing.SetRealm(testing.NewUserRealm(from)) testing.SetOriginSend(ugnot(amount)) testing.IssueCoins(Address(), ugnot(amount)) Pledge(cross(cur), id) testing.SetOriginSend(nil) } func balanceOf(addr address) int64 { return coinio.HeldAt(addr, Denom) } // --- launch --- func TestLaunchBasics(cur realm, t *testing.T) { begin() before := NumCampaigns() openBefore := OpenCampaigns() id := launchAs(cur, creator1, "Solar panels for the hackerspace") uassert.Equal(t, before+1, id) uassert.Equal(t, before+1, NumCampaigns()) uassert.Equal(t, openBefore+1, OpenCampaigns()) uassert.Equal(t, int64(1), OpenOf(creator1)) cr, goal, raised, feeBps, createdAt, deadline, claimEnd, backers, state := CampaignInfo(id) uassert.Equal(t, creator1, cr) uassert.Equal(t, MinGoal, goal) uassert.Equal(t, int64(0), raised) uassert.Equal(t, SuccessFeeBps, feeBps) uassert.Equal(t, createdAt+MinDurationBlocks, deadline) uassert.Equal(t, deadline+ClaimWindowBlocks, claimEnd) uassert.Equal(t, 0, backers) uassert.Equal(t, stateFunding, state) uassert.Equal(t, stateFunding, Status(id)) // Clean up the open slot so later per-creator-cap tests see a known // count. testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) uassert.Equal(t, int64(0), OpenOf(creator1)) } func TestLaunchValidation(cur realm, t *testing.T) { begin() testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "title must not be empty", func() { Launch(cross(cur), "", "", MinGoal, MinDurationBlocks) }) testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "title exceeds 100 bytes", func() { Launch(cross(cur), strings.Repeat("x", MaxTitleLen+1), "", MinGoal, MinDurationBlocks) }) testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "memo exceeds 256 bytes", func() { Launch(cross(cur), "t", strings.Repeat("x", MaxMemoLen+1), MinGoal, MinDurationBlocks) }) testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "goal must be at least 1000000ugnot", func() { Launch(cross(cur), "t", "", MinGoal-1, MinDurationBlocks) }) // Duration bounds are the duebook's own validation, not a check in // this realm — asserted against the primitive's error text. testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "duebook: delay outside [minDelay, maxDelay]", func() { Launch(cross(cur), "t", "", MinGoal, MinDurationBlocks-1) }) testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "duebook: delay outside [minDelay, maxDelay]", func() { Launch(cross(cur), "t", "", MinGoal, MaxDurationBlocks+1) }) uassert.Equal(t, int64(0), OpenOf(creator1)) } func TestLaunchPerCreatorCap(cur realm, t *testing.T) { begin() uassert.Equal(t, int64(0), OpenOf(creator2)) ids := []int64{} for i := int64(0); i < MaxOpenPerCreator; i++ { ids = append(ids, launchAs(cur, creator2, "cap filler")) } uassert.Equal(t, MaxOpenPerCreator, OpenOf(creator2)) testing.SetRealm(testing.NewUserRealm(creator2)) uassert.AbortsWithMessage(t, cur, "creator already has 8 unsettled campaigns", func() { Launch(cross(cur), "one too many", "", MinGoal, MinDurationBlocks) }) // A settled campaign releases its slot. testing.SetRealm(testing.NewUserRealm(creator2)) CreatorCancel(cross(cur), ids[0]) uassert.Equal(t, MaxOpenPerCreator-1, OpenOf(creator2)) idNew := launchAs(cur, creator2, "slot reopened") uassert.Equal(t, MaxOpenPerCreator, OpenOf(creator2)) // Drain the slots so later tests start from a clean count. for _, id := range append(ids[1:], idNew) { testing.SetRealm(testing.NewUserRealm(creator2)) CreatorCancel(cross(cur), id) } uassert.Equal(t, int64(0), OpenOf(creator2)) } // --- pledging --- func TestPledgeAndConservation(cur realm, t *testing.T) { b := begin() id := launchAs(cur, creator1, "conservation case") pledgeAs(cur, backerA, id, 400_000) pledgeAs(cur, backerB, id, 250_000) pledgeAs(cur, backerA, id, 100_000) // same backer accumulates uassert.Equal(t, int64(500_000), PledgeOf(id, backerA)) uassert.Equal(t, int64(250_000), PledgeOf(id, backerB)) _, _, raised, _, _, _, _, backers, _ := CampaignInfo(id) uassert.Equal(t, int64(750_000), raised) uassert.Equal(t, 2, backers) // Conservation, delta form: every pledged coin is in B and in H. uassert.Equal(t, b.backed+750_000, TotalBacked()) uassert.Equal(t, b.held+750_000, Held()) uassert.Equal(t, b.fees, FeesAccrued()) uassert.True(t, Held() >= Liabilities()) // Clean up: back out both pledges, cancel. testing.SetRealm(testing.NewUserRealm(backerA)) Unpledge(cross(cur), id) testing.SetRealm(testing.NewUserRealm(backerB)) Unpledge(cross(cur), id) uassert.Equal(t, b.backed, TotalBacked()) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) } func TestPledgeGuards(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "guard case") // The payment-guard regression: a realm-routed pledge is refused by // coinio's receipt shape, not by anything this file could forget. testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/attacker")) testing.SetOriginSend(ugnot(1000)) uassert.AbortsWithMessage(t, cur, "coinio: payment must be a direct EOA call with -send (realms and maketx-run are rejected)", func() { Pledge(cross(cur), id) }) testing.SetOriginSend(nil) // Wrong denomination. testing.SetRealm(testing.NewUserRealm(backerA)) testing.SetOriginSend(chain.NewCoins(chain.NewCoin("foocoin", 1000))) uassert.AbortsWithMessage(t, cur, "coinio: send exactly one coin type: ugnot", func() { Pledge(cross(cur), id) }) testing.SetOriginSend(nil) // No envelope at all. testing.SetRealm(testing.NewUserRealm(backerA)) uassert.AbortsWithMessage(t, cur, "coinio: send exactly one coin type: ugnot", func() { Pledge(cross(cur), id) }) // Unknown campaign. testing.SetRealm(testing.NewUserRealm(backerA)) testing.SetOriginSend(ugnot(1000)) uassert.AbortsWithMessage(t, cur, "unknown campaign id", func() { Pledge(cross(cur), 999_999) }) testing.SetOriginSend(nil) // Below the pledge minimum (audit open question 1: dust-entry // bloat); exactly the minimum is accepted. testing.SetRealm(testing.NewUserRealm(backerA)) testing.SetOriginSend(ugnot(MinPledge - 1)) testing.IssueCoins(Address(), ugnot(MinPledge-1)) uassert.AbortsWithMessage(t, cur, "pledge at least 10000ugnot", func() { Pledge(cross(cur), id) }) testing.SetOriginSend(nil) pledgeAs(cur, backerA, id, MinPledge) uassert.Equal(t, MinPledge, PledgeOf(id, backerA)) testing.SetRealm(testing.NewUserRealm(backerA)) Unpledge(cross(cur), id) // Settled campaign. testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) testing.SetRealm(testing.NewUserRealm(backerA)) testing.SetOriginSend(ugnot(1000)) uassert.AbortsWithMessage(t, cur, "campaign is settled", func() { Pledge(cross(cur), id) }) testing.SetOriginSend(nil) } func TestPledgeClosesAtDeadline(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "deadline case") testing.SkipHeights(MinDurationBlocks) // height == deadline exactly testing.SetRealm(testing.NewUserRealm(backerA)) testing.SetOriginSend(ugnot(1000)) testing.IssueCoins(Address(), ugnot(1000)) uassert.AbortsWithMessage(t, cur, "funding is closed", func() { Pledge(cross(cur), id) }) testing.SetOriginSend(nil) // Unpledge is likewise closed at the deadline (the failed path is // Refund's). testing.SetRealm(testing.NewUserRealm(backerA)) uassert.AbortsWithMessage(t, cur, "funding is closed; use Refund if the campaign failed", func() { Unpledge(cross(cur), id) }) // Failed (0 < goal) and past deadline: refunds open, nothing to pay. uassert.True(t, IsRefundable(id)) uassert.Equal(t, "failed (refunds open)", Status(id)) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) } // --- unpledge --- func TestUnpledgeReturnsWholePledge(cur realm, t *testing.T) { b := begin() id := launchAs(cur, creator1, "unpledge case") pledgeAs(cur, backerA, id, 300_000) walletBefore := balanceOf(backerA) testing.SetRealm(testing.NewUserRealm(backerA)) Unpledge(cross(cur), id) uassert.Equal(t, walletBefore+300_000, balanceOf(backerA)) uassert.Equal(t, int64(0), PledgeOf(id, backerA)) uassert.Equal(t, b.backed, TotalBacked()) uassert.Equal(t, b.held, Held()) // Nothing left to unpledge. testing.SetRealm(testing.NewUserRealm(backerA)) uassert.AbortsWithMessage(t, cur, "no pledge to return", func() { Unpledge(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) } // --- claim --- func TestClaimPaysCreatorMinusFee(cur realm, t *testing.T) { b := begin() id := launchAs(cur, creator1, "funded project") pledgeAs(cur, backerA, id, 900_000) pledgeAs(cur, backerB, id, 350_000) // raised 1_250_000 >= goal 1_000_000 testing.SkipHeights(MinDurationBlocks) uassert.Equal(t, "succeeded (awaiting creator claim)", Status(id)) uassert.False(t, IsRefundable(id)) // the pot is the creator's to collect // A backer cannot refund out of a succeeded campaign in-window. testing.SetRealm(testing.NewUserRealm(backerA)) uassert.AbortsWithMessage(t, cur, "campaign is not refundable", func() { Refund(cross(cur), id) }) // Only the creator may claim. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "creator only", func() { CreatorClaim(cross(cur), id) }) walletBefore := balanceOf(creator1) openBefore := OpenCampaigns() testing.SetRealm(testing.NewUserRealm(creator1)) CreatorClaim(cross(cur), id) // fee = floor(1_250_000 * 100 / 10_000) = 12_500; credited the rest. uassert.Equal(t, walletBefore+1_237_500, balanceOf(creator1)) uassert.Equal(t, b.fees+12_500, FeesAccrued()) uassert.Equal(t, b.backed, TotalBacked()) uassert.Equal(t, statePaid, Status(id)) uassert.Equal(t, openBefore-1, OpenCampaigns()) uassert.Equal(t, int64(0), OpenOf(creator1)) // The pledge table is dropped; the final backer count is frozen. uassert.Equal(t, int64(0), PledgeOf(id, backerA)) _, _, raised, _, _, _, _, backers, state := CampaignInfo(id) uassert.Equal(t, int64(0), raised) uassert.Equal(t, 2, backers) uassert.Equal(t, statePaid, state) // Settlement is exactly-once: the deferral is consumed. testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "campaign is settled", func() { CreatorClaim(cross(cur), id) }) // Conservation: H gained the fee, B is flat, the payout left. uassert.Equal(t, b.held+12_500, Held()) uassert.True(t, Held() >= Liabilities()) } func TestClaimFeeRoundingFavorsCreator(cur realm, t *testing.T) { b := begin() testing.SetRealm(testing.NewUserRealm(creator1)) id := Launch(cross(cur), "rounding case", "", MinGoal, MinDurationBlocks) // 1_000_050 at 100 bps: exact fee 10_000.5 -> floor 10_000. pledgeAs(cur, backerA, id, 1_000_050) testing.SkipHeights(MinDurationBlocks) walletBefore := balanceOf(creator1) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorClaim(cross(cur), id) uassert.Equal(t, walletBefore+990_050, balanceOf(creator1)) uassert.Equal(t, b.fees+10_000, FeesAccrued()) } func TestClaimGoalNotReached(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "underfunded") pledgeAs(cur, backerA, id, MinGoal-1) testing.SkipHeights(MinDurationBlocks) testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "goal not reached", func() { CreatorClaim(cross(cur), id) }) // The failed pot is refundable immediately — no settlement needed. uassert.True(t, IsRefundable(id)) testing.SetRealm(testing.NewUserRealm(backerA)) Refund(cross(cur), id) testing.SetRealm(testing.NewUserRealm(mallory)) SettleFailed(cross(cur), id) // anyone closes it out; slot freed } func TestClaimTimingIsTheDuebooksVerdict(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "timing case") pledgeAs(cur, backerA, id, MinGoal) // Before the deadline: the duebook refuses, and on-chain the whole // transaction would revert. In the test VM the abort does not roll // back realm globals, but MustClaim aborts BEFORE this realm writes // any state, so the campaign is observably untouched either way — // asserted below. testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "duebook: not due yet", func() { CreatorClaim(cross(cur), id) }) uassert.Equal(t, stateFunding, Status(id)) // still funding, untouched uassert.Equal(t, MinGoal, PledgeOf(id, backerA)) // Past the claim window: expired, the creator's authorization died. testing.SkipHeights(MinDurationBlocks + ClaimWindowBlocks) testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "duebook: deferral has expired", func() { CreatorClaim(cross(cur), id) }) // Which is exactly when Lapse starts working — by anyone. testing.SetRealm(testing.NewUserRealm(mallory)) Lapse(cross(cur), id) uassert.Equal(t, stateLapsed, Status(id)) uassert.True(t, IsRefundable(id)) testing.SetRealm(testing.NewUserRealm(backerA)) Refund(cross(cur), id) } // --- settle-failed (audit Y1 remediation) --- func TestSettleFailedFreesTheSlotAtTheDeadline(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "doomed project") pledgeAs(cur, backerA, id, MinGoal-10_000) // Not before the deadline — the timing verdict is the duebook's. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "duebook: not due yet", func() { SettleFailed(cross(cur), id) }) testing.SkipHeights(MinDurationBlocks) openBefore := OpenCampaigns() uassert.Equal(t, int64(1), OpenOf(creator1)) // Anyone may settle a failed campaign the moment it is due. testing.SetRealm(testing.NewUserRealm(mallory)) SettleFailed(cross(cur), id) uassert.Equal(t, stateFailed, Status(id)) uassert.Equal(t, openBefore-1, OpenCampaigns()) // duebook slot freed uassert.Equal(t, int64(0), OpenOf(creator1)) // creator slot freed uassert.True(t, IsRefundable(id)) // Settlement is exactly-once here too. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "campaign is settled", func() { SettleFailed(cross(cur), id) }) // And the creator cannot claim a settled campaign. testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "campaign is settled", func() { CreatorClaim(cross(cur), id) }) // Refunds work exactly as on the un-settled failed path, and the // drained record is prunable. walletBefore := balanceOf(backerA) testing.SetRealm(testing.NewUserRealm(backerA)) Refund(cross(cur), id) uassert.Equal(t, walletBefore+MinGoal-10_000, balanceOf(backerA)) testing.SetRealm(testing.NewUserRealm(mallory)) Prune(cross(cur), id) } func TestSettleFailedExpiresIntoLapse(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "never settled") pledgeAs(cur, backerA, id, MinPledge) testing.SkipHeights(MinDurationBlocks + ClaimWindowBlocks) // Past the claim window the deferral is expired: SettleFailed's // path is gone (the primitive's verdict) and Lapse is the valve — // which is exactly what the campaign page now advises (audit G1). page := Render(itoa(id)) uassert.True(t, strings.Contains(page, "Lapse("+itoa(id)+")")) uassert.False(t, strings.Contains(page, "SettleFailed(")) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "duebook: deferral has expired", func() { SettleFailed(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(mallory)) Lapse(cross(cur), id) uassert.Equal(t, stateLapsed, Status(id)) testing.SetRealm(testing.NewUserRealm(backerA)) Refund(cross(cur), id) } func TestSettleFailedRefusesAMetGoal(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "funded, not failed") pledgeAs(cur, backerA, id, MinGoal) testing.SkipHeights(MinDurationBlocks) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "goal reached; the claim window is the creator's", func() { SettleFailed(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorClaim(cross(cur), id) } // --- cancel --- func TestCancelOpensRefunds(cur realm, t *testing.T) { b := begin() id := launchAs(cur, creator1, "cancelled project") pledgeAs(cur, backerA, id, 600_000) // Mallory cannot cancel someone else's campaign. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "creator only", func() { CreatorCancel(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) uassert.Equal(t, stateCancelled, Status(id)) uassert.True(t, IsRefundable(id)) // Cancelling twice: the deferral is gone, the state says so first. testing.SetRealm(testing.NewUserRealm(creator1)) uassert.AbortsWithMessage(t, cur, "campaign is settled", func() { CreatorCancel(cross(cur), id) }) walletBefore := balanceOf(backerA) testing.SetRealm(testing.NewUserRealm(backerA)) Refund(cross(cur), id) uassert.Equal(t, walletBefore+600_000, balanceOf(backerA)) uassert.Equal(t, b.backed, TotalBacked()) uassert.Equal(t, b.fees, FeesAccrued()) // refunds are fee-free } func TestCancelAfterSuccessRenouncesThePot(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "renounced project") pledgeAs(cur, backerA, id, MinGoal) testing.SkipHeights(MinDurationBlocks) uassert.Equal(t, "succeeded (awaiting creator claim)", Status(id)) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) uassert.True(t, IsRefundable(id)) testing.SetRealm(testing.NewUserRealm(backerA)) Refund(cross(cur), id) uassert.Equal(t, int64(0), PledgeOf(id, backerA)) } // --- lapse --- func TestLapseRefusesWhileClaimWindowIsOpen(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "lapse timing") pledgeAs(cur, backerA, id, MinGoal) testing.SkipHeights(MinDurationBlocks) // due, but window open testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "duebook: deferral has not expired", func() { Lapse(cross(cur), id) }) testing.SkipHeights(ClaimWindowBlocks) testing.SetRealm(testing.NewUserRealm(mallory)) Lapse(cross(cur), id) uassert.Equal(t, stateLapsed, Status(id)) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "campaign is settled", func() { Lapse(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(backerA)) Refund(cross(cur), id) } // --- refund guards --- func TestRefundGuards(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "refund guards") pledgeAs(cur, backerA, id, 100_000) // Not refundable while funding is open. testing.SetRealm(testing.NewUserRealm(backerA)) uassert.AbortsWithMessage(t, cur, "campaign is not refundable", func() { Refund(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) // A stranger with no pledge gets nothing. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "no pledge to return", func() { Refund(cross(cur), id) }) // The backer refunds once, then has nothing left. testing.SetRealm(testing.NewUserRealm(backerA)) Refund(cross(cur), id) testing.SetRealm(testing.NewUserRealm(backerA)) uassert.AbortsWithMessage(t, cur, "no pledge to return", func() { Refund(cross(cur), id) }) } // --- prune --- func TestPrune(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "prunable") pledgeAs(cur, backerA, id, 50_000) // Not while unsettled. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "campaign is not settled", func() { Prune(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) // Not while backer funds remain. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "campaign still holds backer funds", func() { Prune(cross(cur), id) }) testing.SetRealm(testing.NewUserRealm(backerA)) Refund(cross(cur), id) nBefore := NumCampaigns() testing.SetRealm(testing.NewUserRealm(mallory)) Prune(cross(cur), id) uassert.Equal(t, nBefore, NumCampaigns()) // ids are never reused testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "unknown campaign id", func() { Prune(cross(cur), id) }) uassert.PanicsWithMessage(t, cur, "unknown campaign id", func() { CampaignInfo(id) }) } // --- fees and surplus --- func TestWithdrawFees(cur realm, t *testing.T) { b := begin() if b.fees == 0 { // Accrue a fee: fund and claim a campaign. id := launchAs(cur, creator1, "fee source") pledgeAs(cur, backerA, id, MinGoal) testing.SkipHeights(MinDurationBlocks) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorClaim(cross(cur), id) } fees := FeesAccrued() uassert.True(t, fees > 0) collectorBefore := balanceOf(FeeCollector) testing.SetRealm(testing.NewUserRealm(mallory)) // anyone may trigger got := WithdrawFees(cross(cur)) uassert.Equal(t, fees, got) uassert.Equal(t, collectorBefore+fees, balanceOf(FeeCollector)) uassert.Equal(t, int64(0), FeesAccrued()) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "no fees accrued", func() { WithdrawFees(cross(cur)) }) } func TestSweepSurplus(cur realm, t *testing.T) { begin() // No surplus: coinio refuses. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "coinio: no surplus to sweep for ugnot", func() { SweepSurplus(cross(cur), Denom) }) // Force-send 33_000 ugnot out-of-band, then sweep it. testing.IssueCoins(Address(), ugnot(33_000)) liabBefore := Liabilities() collectorBefore := balanceOf(FeeCollector) testing.SetRealm(testing.NewUserRealm(mallory)) swept := SweepSurplus(cross(cur), Denom) uassert.Equal(t, int64(33_000), swept) uassert.Equal(t, collectorBefore+33_000, balanceOf(FeeCollector)) uassert.Equal(t, liabBefore, Liabilities()) // tracked money untouched // A foreign denomination sweeps in full. testing.IssueCoins(Address(), chain.NewCoins(chain.NewCoin("foocoin", 4_200))) testing.SetRealm(testing.NewUserRealm(mallory)) uassert.Equal(t, int64(4_200), SweepSurplus(cross(cur), "foocoin")) } // --- stray sends --- func TestNonPayableEntrypointsRejectCoins(cur realm, t *testing.T) { begin() id := launchAs(cur, creator1, "stray send case") cases := []func(){ func() { Launch(cross(cur), "t", "", MinGoal, MinDurationBlocks) }, func() { Unpledge(cross(cur), id) }, func() { CreatorClaim(cross(cur), id) }, func() { CreatorCancel(cross(cur), id) }, func() { SettleFailed(cross(cur), id) }, func() { Lapse(cross(cur), id) }, func() { Refund(cross(cur), id) }, func() { Prune(cross(cur), id) }, func() { WithdrawFees(cross(cur)) }, func() { SweepSurplus(cross(cur), Denom) }, } for _, call := range cases { testing.SetRealm(testing.NewUserRealm(mallory)) testing.SetOriginSend(ugnot(1)) uassert.AbortsWithMessage(t, cur, "this entrypoint does not accept coins", call) } testing.SetOriginSend(nil) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) } // --- render --- func TestRenderIndexAndCampaign(cur realm, t *testing.T) { begin() // A hostile title must come out of the sanitizer defanged. hostile := "[evil](https://x) <script>alert(1)</script>" testing.SetRealm(testing.NewUserRealm(creator1)) id := Launch(cross(cur), hostile, "memo with [link](x) inside", MinGoal, MinDurationBlocks) index := Render("") uassert.True(t, strings.Contains(index, "# Crowdfund")) uassert.True(t, strings.Contains(index, "conservation: OK")) uassert.False(t, strings.Contains(index, "<script>")) page := Render(itoa(id)) uassert.True(t, strings.Contains(page, "status: **funding**")) uassert.True(t, strings.Contains(page, "0%")) uassert.False(t, strings.Contains(page, "<script>")) uassert.False(t, strings.Contains(page, "[evil](https://x)")) uassert.False(t, strings.Contains(page, "[link](x)")) uassert.True(t, strings.Contains(Render("notanumber"), "invalid campaign id")) uassert.True(t, strings.Contains(Render("999999"), "unknown campaign id")) testing.SetRealm(testing.NewUserRealm(creator1)) CreatorCancel(cross(cur), id) } func TestPercentAndBps(t *testing.T) { uassert.Equal(t, "0%", percent(0, MinGoal)) uassert.Equal(t, "50%", percent(500_000, MinGoal)) uassert.Equal(t, "99%", percent(999_999, MinGoal)) uassert.Equal(t, "100%", percent(MinGoal, MinGoal)) uassert.Equal(t, "250%", percent(2_500_000, MinGoal)) // The overflow-guard branch: r*100 would not fit in int64. Three // quarters of 2^62 is exactly 75%, and the scaled form lands on it. huge := int64(1) << 62 uassert.Equal(t, "75%", percent(3*(huge/4), huge)) uassert.Equal(t, "1%", bps(100)) uassert.Equal(t, "5%", bps(500)) uassert.Equal(t, "0.5%", bps(50)) uassert.Equal(t, "0.01%", bps(1)) uassert.Equal(t, "2.25%", bps(225)) }
- #6gnomod.toml
- #7module = "gno.land/r/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/crowdfund" gno = "0.9"
- #8/gno.MemPackageType
- #9 MPUserAll
Result log
msg:0,success:true,log:,events:[]