crowdfund_test.gno
27.17 Kb · 799 lines
1package crowdfund
2
3import (
4 "strings"
5 "testing"
6
7 "chain"
8
9 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
10 "gno.land/p/nt/testutils/v0"
11 "gno.land/p/nt/uassert/v0"
12)
13
14var (
15 creator1 = testutils.TestAddress("cfcreator1")
16 creator2 = testutils.TestAddress("cfcreator2")
17 backerA = testutils.TestAddress("cfbackera")
18 backerB = testutils.TestAddress("cfbackerb")
19 mallory = testutils.TestAddress("cfmallory")
20)
21
22// HARNESS NOTES, carried from the treasury_board, vesting and grants
23// suites (measured there, relied on here):
24//
25// - testing.SetRealm records the override per FRAME INDEX: stage the
26// caller INLINE in the test function's frame before a uassert call,
27// or inside a helper that stages and calls in one frame of its own.
28// Never inside a uassert closure — that staging does not take.
29//
30// - testing.SkipHeights REPLACES the whole context (OriginCaller,
31// CurrentRealm, OriginSend included). Re-stage after every skip.
32//
33// - Realm globals PERSIST across tests while banker state resets per
34// test. begin() disarms any leftover envelope and re-seeds the
35// realm's bank to its carried liabilities, so conservation is
36// asserted in DELTA form.
37//
38// - A panic caught by uassert does NOT roll back realm globals here,
39// whereas on-chain the whole transaction reverts. Every abort
40// asserted below happens strictly BEFORE this realm writes state,
41// with one measured exception documented inline
42// (TestClaimTimingIsTheDuebooksVerdict).
43//
44// - The SEND envelope is process-global: a staged envelope must be
45// cleared (SetOriginSend(nil)) before the next non-payable call, or
46// rejectStraySend fires and the test asserts the harness leak, not
47// the guard.
48func init() {
49 self = chain.PackageAddress("gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/crowdfund")
50}
51
52type baseline struct {
53 held int64
54 backed int64
55 fees int64
56}
57
58func begin() baseline {
59 testing.SetOriginSend(nil)
60 if l := Liabilities(); l > 0 {
61 testing.IssueCoins(Address(), ugnot(l))
62 }
63 return baseline{held: Held(), backed: TotalBacked(), fees: FeesAccrued()}
64}
65
66func ugnot(n int64) chain.Coins {
67 return chain.NewCoins(chain.NewCoin(Denom, n))
68}
69
70// launchAs stages `from` and launches a MinGoal x MinDuration campaign.
71func launchAs(cur realm, from address, title string) int64 {
72 testing.SetRealm(testing.NewUserRealm(from))
73 return Launch(cross(cur), title, "", MinGoal, MinDurationBlocks)
74}
75
76// pledgeAs stages a direct EOA call with -send and mirrors the
77// envelope into the realm's bank, as the chain would.
78func pledgeAs(cur realm, from address, id, amount int64) {
79 testing.SetRealm(testing.NewUserRealm(from))
80 testing.SetOriginSend(ugnot(amount))
81 testing.IssueCoins(Address(), ugnot(amount))
82 Pledge(cross(cur), id)
83 testing.SetOriginSend(nil)
84}
85
86func balanceOf(addr address) int64 {
87 return coinio.HeldAt(addr, Denom)
88}
89
90// --- launch ---
91
92func TestLaunchBasics(cur realm, t *testing.T) {
93 begin()
94 before := NumCampaigns()
95 openBefore := OpenCampaigns()
96
97 id := launchAs(cur, creator1, "Solar panels for the hackerspace")
98 uassert.Equal(t, before+1, id)
99 uassert.Equal(t, before+1, NumCampaigns())
100 uassert.Equal(t, openBefore+1, OpenCampaigns())
101 uassert.Equal(t, int64(1), OpenOf(creator1))
102
103 cr, goal, raised, feeBps, createdAt, deadline, claimEnd, backers, state := CampaignInfo(id)
104 uassert.Equal(t, creator1, cr)
105 uassert.Equal(t, MinGoal, goal)
106 uassert.Equal(t, int64(0), raised)
107 uassert.Equal(t, SuccessFeeBps, feeBps)
108 uassert.Equal(t, createdAt+MinDurationBlocks, deadline)
109 uassert.Equal(t, deadline+ClaimWindowBlocks, claimEnd)
110 uassert.Equal(t, 0, backers)
111 uassert.Equal(t, stateFunding, state)
112 uassert.Equal(t, stateFunding, Status(id))
113
114 // Clean up the open slot so later per-creator-cap tests see a known
115 // count.
116 testing.SetRealm(testing.NewUserRealm(creator1))
117 CreatorCancel(cross(cur), id)
118 uassert.Equal(t, int64(0), OpenOf(creator1))
119}
120
121func TestLaunchValidation(cur realm, t *testing.T) {
122 begin()
123 testing.SetRealm(testing.NewUserRealm(creator1))
124 uassert.AbortsWithMessage(t, cur, "title must not be empty", func() {
125 Launch(cross(cur), "", "", MinGoal, MinDurationBlocks)
126 })
127 testing.SetRealm(testing.NewUserRealm(creator1))
128 uassert.AbortsWithMessage(t, cur, "title exceeds 100 bytes", func() {
129 Launch(cross(cur), strings.Repeat("x", MaxTitleLen+1), "", MinGoal, MinDurationBlocks)
130 })
131 testing.SetRealm(testing.NewUserRealm(creator1))
132 uassert.AbortsWithMessage(t, cur, "memo exceeds 256 bytes", func() {
133 Launch(cross(cur), "t", strings.Repeat("x", MaxMemoLen+1), MinGoal, MinDurationBlocks)
134 })
135 testing.SetRealm(testing.NewUserRealm(creator1))
136 uassert.AbortsWithMessage(t, cur, "goal must be at least 1000000ugnot", func() {
137 Launch(cross(cur), "t", "", MinGoal-1, MinDurationBlocks)
138 })
139 // Duration bounds are the duebook's own validation, not a check in
140 // this realm — asserted against the primitive's error text.
141 testing.SetRealm(testing.NewUserRealm(creator1))
142 uassert.AbortsWithMessage(t, cur, "duebook: delay outside [minDelay, maxDelay]", func() {
143 Launch(cross(cur), "t", "", MinGoal, MinDurationBlocks-1)
144 })
145 testing.SetRealm(testing.NewUserRealm(creator1))
146 uassert.AbortsWithMessage(t, cur, "duebook: delay outside [minDelay, maxDelay]", func() {
147 Launch(cross(cur), "t", "", MinGoal, MaxDurationBlocks+1)
148 })
149 uassert.Equal(t, int64(0), OpenOf(creator1))
150}
151
152func TestLaunchPerCreatorCap(cur realm, t *testing.T) {
153 begin()
154 uassert.Equal(t, int64(0), OpenOf(creator2))
155 ids := []int64{}
156 for i := int64(0); i < MaxOpenPerCreator; i++ {
157 ids = append(ids, launchAs(cur, creator2, "cap filler"))
158 }
159 uassert.Equal(t, MaxOpenPerCreator, OpenOf(creator2))
160
161 testing.SetRealm(testing.NewUserRealm(creator2))
162 uassert.AbortsWithMessage(t, cur, "creator already has 8 unsettled campaigns", func() {
163 Launch(cross(cur), "one too many", "", MinGoal, MinDurationBlocks)
164 })
165
166 // A settled campaign releases its slot.
167 testing.SetRealm(testing.NewUserRealm(creator2))
168 CreatorCancel(cross(cur), ids[0])
169 uassert.Equal(t, MaxOpenPerCreator-1, OpenOf(creator2))
170 idNew := launchAs(cur, creator2, "slot reopened")
171 uassert.Equal(t, MaxOpenPerCreator, OpenOf(creator2))
172
173 // Drain the slots so later tests start from a clean count.
174 for _, id := range append(ids[1:], idNew) {
175 testing.SetRealm(testing.NewUserRealm(creator2))
176 CreatorCancel(cross(cur), id)
177 }
178 uassert.Equal(t, int64(0), OpenOf(creator2))
179}
180
181// --- pledging ---
182
183func TestPledgeAndConservation(cur realm, t *testing.T) {
184 b := begin()
185 id := launchAs(cur, creator1, "conservation case")
186
187 pledgeAs(cur, backerA, id, 400_000)
188 pledgeAs(cur, backerB, id, 250_000)
189 pledgeAs(cur, backerA, id, 100_000) // same backer accumulates
190
191 uassert.Equal(t, int64(500_000), PledgeOf(id, backerA))
192 uassert.Equal(t, int64(250_000), PledgeOf(id, backerB))
193 _, _, raised, _, _, _, _, backers, _ := CampaignInfo(id)
194 uassert.Equal(t, int64(750_000), raised)
195 uassert.Equal(t, 2, backers)
196
197 // Conservation, delta form: every pledged coin is in B and in H.
198 uassert.Equal(t, b.backed+750_000, TotalBacked())
199 uassert.Equal(t, b.held+750_000, Held())
200 uassert.Equal(t, b.fees, FeesAccrued())
201 uassert.True(t, Held() >= Liabilities())
202
203 // Clean up: back out both pledges, cancel.
204 testing.SetRealm(testing.NewUserRealm(backerA))
205 Unpledge(cross(cur), id)
206 testing.SetRealm(testing.NewUserRealm(backerB))
207 Unpledge(cross(cur), id)
208 uassert.Equal(t, b.backed, TotalBacked())
209 testing.SetRealm(testing.NewUserRealm(creator1))
210 CreatorCancel(cross(cur), id)
211}
212
213func TestPledgeGuards(cur realm, t *testing.T) {
214 begin()
215 id := launchAs(cur, creator1, "guard case")
216
217 // The payment-guard regression: a realm-routed pledge is refused by
218 // coinio's receipt shape, not by anything this file could forget.
219 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/attacker"))
220 testing.SetOriginSend(ugnot(1000))
221 uassert.AbortsWithMessage(t, cur,
222 "coinio: payment must be a direct EOA call with -send (realms and maketx-run are rejected)",
223 func() { Pledge(cross(cur), id) })
224 testing.SetOriginSend(nil)
225
226 // Wrong denomination.
227 testing.SetRealm(testing.NewUserRealm(backerA))
228 testing.SetOriginSend(chain.NewCoins(chain.NewCoin("foocoin", 1000)))
229 uassert.AbortsWithMessage(t, cur, "coinio: send exactly one coin type: ugnot", func() {
230 Pledge(cross(cur), id)
231 })
232 testing.SetOriginSend(nil)
233
234 // No envelope at all.
235 testing.SetRealm(testing.NewUserRealm(backerA))
236 uassert.AbortsWithMessage(t, cur, "coinio: send exactly one coin type: ugnot", func() {
237 Pledge(cross(cur), id)
238 })
239
240 // Unknown campaign.
241 testing.SetRealm(testing.NewUserRealm(backerA))
242 testing.SetOriginSend(ugnot(1000))
243 uassert.AbortsWithMessage(t, cur, "unknown campaign id", func() {
244 Pledge(cross(cur), 999_999)
245 })
246 testing.SetOriginSend(nil)
247
248 // Below the pledge minimum (audit open question 1: dust-entry
249 // bloat); exactly the minimum is accepted.
250 testing.SetRealm(testing.NewUserRealm(backerA))
251 testing.SetOriginSend(ugnot(MinPledge - 1))
252 testing.IssueCoins(Address(), ugnot(MinPledge-1))
253 uassert.AbortsWithMessage(t, cur, "pledge at least 10000ugnot", func() {
254 Pledge(cross(cur), id)
255 })
256 testing.SetOriginSend(nil)
257 pledgeAs(cur, backerA, id, MinPledge)
258 uassert.Equal(t, MinPledge, PledgeOf(id, backerA))
259 testing.SetRealm(testing.NewUserRealm(backerA))
260 Unpledge(cross(cur), id)
261
262 // Settled campaign.
263 testing.SetRealm(testing.NewUserRealm(creator1))
264 CreatorCancel(cross(cur), id)
265 testing.SetRealm(testing.NewUserRealm(backerA))
266 testing.SetOriginSend(ugnot(1000))
267 uassert.AbortsWithMessage(t, cur, "campaign is settled", func() {
268 Pledge(cross(cur), id)
269 })
270 testing.SetOriginSend(nil)
271}
272
273func TestPledgeClosesAtDeadline(cur realm, t *testing.T) {
274 begin()
275 id := launchAs(cur, creator1, "deadline case")
276 testing.SkipHeights(MinDurationBlocks) // height == deadline exactly
277
278 testing.SetRealm(testing.NewUserRealm(backerA))
279 testing.SetOriginSend(ugnot(1000))
280 testing.IssueCoins(Address(), ugnot(1000))
281 uassert.AbortsWithMessage(t, cur, "funding is closed", func() {
282 Pledge(cross(cur), id)
283 })
284 testing.SetOriginSend(nil)
285
286 // Unpledge is likewise closed at the deadline (the failed path is
287 // Refund's).
288 testing.SetRealm(testing.NewUserRealm(backerA))
289 uassert.AbortsWithMessage(t, cur,
290 "funding is closed; use Refund if the campaign failed",
291 func() { Unpledge(cross(cur), id) })
292
293 // Failed (0 < goal) and past deadline: refunds open, nothing to pay.
294 uassert.True(t, IsRefundable(id))
295 uassert.Equal(t, "failed (refunds open)", Status(id))
296
297 testing.SetRealm(testing.NewUserRealm(creator1))
298 CreatorCancel(cross(cur), id)
299}
300
301// --- unpledge ---
302
303func TestUnpledgeReturnsWholePledge(cur realm, t *testing.T) {
304 b := begin()
305 id := launchAs(cur, creator1, "unpledge case")
306 pledgeAs(cur, backerA, id, 300_000)
307
308 walletBefore := balanceOf(backerA)
309 testing.SetRealm(testing.NewUserRealm(backerA))
310 Unpledge(cross(cur), id)
311
312 uassert.Equal(t, walletBefore+300_000, balanceOf(backerA))
313 uassert.Equal(t, int64(0), PledgeOf(id, backerA))
314 uassert.Equal(t, b.backed, TotalBacked())
315 uassert.Equal(t, b.held, Held())
316
317 // Nothing left to unpledge.
318 testing.SetRealm(testing.NewUserRealm(backerA))
319 uassert.AbortsWithMessage(t, cur, "no pledge to return", func() {
320 Unpledge(cross(cur), id)
321 })
322 testing.SetRealm(testing.NewUserRealm(creator1))
323 CreatorCancel(cross(cur), id)
324}
325
326// --- claim ---
327
328func TestClaimPaysCreatorMinusFee(cur realm, t *testing.T) {
329 b := begin()
330 id := launchAs(cur, creator1, "funded project")
331 pledgeAs(cur, backerA, id, 900_000)
332 pledgeAs(cur, backerB, id, 350_000) // raised 1_250_000 >= goal 1_000_000
333
334 testing.SkipHeights(MinDurationBlocks)
335 uassert.Equal(t, "succeeded (awaiting creator claim)", Status(id))
336 uassert.False(t, IsRefundable(id)) // the pot is the creator's to collect
337
338 // A backer cannot refund out of a succeeded campaign in-window.
339 testing.SetRealm(testing.NewUserRealm(backerA))
340 uassert.AbortsWithMessage(t, cur, "campaign is not refundable", func() {
341 Refund(cross(cur), id)
342 })
343
344 // Only the creator may claim.
345 testing.SetRealm(testing.NewUserRealm(mallory))
346 uassert.AbortsWithMessage(t, cur, "creator only", func() {
347 CreatorClaim(cross(cur), id)
348 })
349
350 walletBefore := balanceOf(creator1)
351 openBefore := OpenCampaigns()
352 testing.SetRealm(testing.NewUserRealm(creator1))
353 CreatorClaim(cross(cur), id)
354
355 // fee = floor(1_250_000 * 100 / 10_000) = 12_500; credited the rest.
356 uassert.Equal(t, walletBefore+1_237_500, balanceOf(creator1))
357 uassert.Equal(t, b.fees+12_500, FeesAccrued())
358 uassert.Equal(t, b.backed, TotalBacked())
359 uassert.Equal(t, statePaid, Status(id))
360 uassert.Equal(t, openBefore-1, OpenCampaigns())
361 uassert.Equal(t, int64(0), OpenOf(creator1))
362
363 // The pledge table is dropped; the final backer count is frozen.
364 uassert.Equal(t, int64(0), PledgeOf(id, backerA))
365 _, _, raised, _, _, _, _, backers, state := CampaignInfo(id)
366 uassert.Equal(t, int64(0), raised)
367 uassert.Equal(t, 2, backers)
368 uassert.Equal(t, statePaid, state)
369
370 // Settlement is exactly-once: the deferral is consumed.
371 testing.SetRealm(testing.NewUserRealm(creator1))
372 uassert.AbortsWithMessage(t, cur, "campaign is settled", func() {
373 CreatorClaim(cross(cur), id)
374 })
375 // Conservation: H gained the fee, B is flat, the payout left.
376 uassert.Equal(t, b.held+12_500, Held())
377 uassert.True(t, Held() >= Liabilities())
378}
379
380func TestClaimFeeRoundingFavorsCreator(cur realm, t *testing.T) {
381 b := begin()
382 testing.SetRealm(testing.NewUserRealm(creator1))
383 id := Launch(cross(cur), "rounding case", "", MinGoal, MinDurationBlocks)
384 // 1_000_050 at 100 bps: exact fee 10_000.5 -> floor 10_000.
385 pledgeAs(cur, backerA, id, 1_000_050)
386 testing.SkipHeights(MinDurationBlocks)
387
388 walletBefore := balanceOf(creator1)
389 testing.SetRealm(testing.NewUserRealm(creator1))
390 CreatorClaim(cross(cur), id)
391 uassert.Equal(t, walletBefore+990_050, balanceOf(creator1))
392 uassert.Equal(t, b.fees+10_000, FeesAccrued())
393}
394
395func TestClaimGoalNotReached(cur realm, t *testing.T) {
396 begin()
397 id := launchAs(cur, creator1, "underfunded")
398 pledgeAs(cur, backerA, id, MinGoal-1)
399 testing.SkipHeights(MinDurationBlocks)
400
401 testing.SetRealm(testing.NewUserRealm(creator1))
402 uassert.AbortsWithMessage(t, cur, "goal not reached", func() {
403 CreatorClaim(cross(cur), id)
404 })
405
406 // The failed pot is refundable immediately — no settlement needed.
407 uassert.True(t, IsRefundable(id))
408 testing.SetRealm(testing.NewUserRealm(backerA))
409 Refund(cross(cur), id)
410 testing.SetRealm(testing.NewUserRealm(mallory))
411 SettleFailed(cross(cur), id) // anyone closes it out; slot freed
412}
413
414func TestClaimTimingIsTheDuebooksVerdict(cur realm, t *testing.T) {
415 begin()
416 id := launchAs(cur, creator1, "timing case")
417 pledgeAs(cur, backerA, id, MinGoal)
418
419 // Before the deadline: the duebook refuses, and on-chain the whole
420 // transaction would revert. In the test VM the abort does not roll
421 // back realm globals, but MustClaim aborts BEFORE this realm writes
422 // any state, so the campaign is observably untouched either way —
423 // asserted below.
424 testing.SetRealm(testing.NewUserRealm(creator1))
425 uassert.AbortsWithMessage(t, cur, "duebook: not due yet", func() {
426 CreatorClaim(cross(cur), id)
427 })
428 uassert.Equal(t, stateFunding, Status(id)) // still funding, untouched
429 uassert.Equal(t, MinGoal, PledgeOf(id, backerA))
430
431 // Past the claim window: expired, the creator's authorization died.
432 testing.SkipHeights(MinDurationBlocks + ClaimWindowBlocks)
433 testing.SetRealm(testing.NewUserRealm(creator1))
434 uassert.AbortsWithMessage(t, cur, "duebook: deferral has expired", func() {
435 CreatorClaim(cross(cur), id)
436 })
437
438 // Which is exactly when Lapse starts working — by anyone.
439 testing.SetRealm(testing.NewUserRealm(mallory))
440 Lapse(cross(cur), id)
441 uassert.Equal(t, stateLapsed, Status(id))
442 uassert.True(t, IsRefundable(id))
443
444 testing.SetRealm(testing.NewUserRealm(backerA))
445 Refund(cross(cur), id)
446}
447
448// --- settle-failed (audit Y1 remediation) ---
449
450func TestSettleFailedFreesTheSlotAtTheDeadline(cur realm, t *testing.T) {
451 begin()
452 id := launchAs(cur, creator1, "doomed project")
453 pledgeAs(cur, backerA, id, MinGoal-10_000)
454
455 // Not before the deadline — the timing verdict is the duebook's.
456 testing.SetRealm(testing.NewUserRealm(mallory))
457 uassert.AbortsWithMessage(t, cur, "duebook: not due yet", func() {
458 SettleFailed(cross(cur), id)
459 })
460
461 testing.SkipHeights(MinDurationBlocks)
462 openBefore := OpenCampaigns()
463 uassert.Equal(t, int64(1), OpenOf(creator1))
464
465 // Anyone may settle a failed campaign the moment it is due.
466 testing.SetRealm(testing.NewUserRealm(mallory))
467 SettleFailed(cross(cur), id)
468 uassert.Equal(t, stateFailed, Status(id))
469 uassert.Equal(t, openBefore-1, OpenCampaigns()) // duebook slot freed
470 uassert.Equal(t, int64(0), OpenOf(creator1)) // creator slot freed
471 uassert.True(t, IsRefundable(id))
472
473 // Settlement is exactly-once here too.
474 testing.SetRealm(testing.NewUserRealm(mallory))
475 uassert.AbortsWithMessage(t, cur, "campaign is settled", func() {
476 SettleFailed(cross(cur), id)
477 })
478 // And the creator cannot claim a settled campaign.
479 testing.SetRealm(testing.NewUserRealm(creator1))
480 uassert.AbortsWithMessage(t, cur, "campaign is settled", func() {
481 CreatorClaim(cross(cur), id)
482 })
483
484 // Refunds work exactly as on the un-settled failed path, and the
485 // drained record is prunable.
486 walletBefore := balanceOf(backerA)
487 testing.SetRealm(testing.NewUserRealm(backerA))
488 Refund(cross(cur), id)
489 uassert.Equal(t, walletBefore+MinGoal-10_000, balanceOf(backerA))
490 testing.SetRealm(testing.NewUserRealm(mallory))
491 Prune(cross(cur), id)
492}
493
494func TestSettleFailedExpiresIntoLapse(cur realm, t *testing.T) {
495 begin()
496 id := launchAs(cur, creator1, "never settled")
497 pledgeAs(cur, backerA, id, MinPledge)
498 testing.SkipHeights(MinDurationBlocks + ClaimWindowBlocks)
499
500 // Past the claim window the deferral is expired: SettleFailed's
501 // path is gone (the primitive's verdict) and Lapse is the valve —
502 // which is exactly what the campaign page now advises (audit G1).
503 page := Render(itoa(id))
504 uassert.True(t, strings.Contains(page, "Lapse("+itoa(id)+")"))
505 uassert.False(t, strings.Contains(page, "SettleFailed("))
506
507 testing.SetRealm(testing.NewUserRealm(mallory))
508 uassert.AbortsWithMessage(t, cur, "duebook: deferral has expired", func() {
509 SettleFailed(cross(cur), id)
510 })
511 testing.SetRealm(testing.NewUserRealm(mallory))
512 Lapse(cross(cur), id)
513 uassert.Equal(t, stateLapsed, Status(id))
514 testing.SetRealm(testing.NewUserRealm(backerA))
515 Refund(cross(cur), id)
516}
517
518func TestSettleFailedRefusesAMetGoal(cur realm, t *testing.T) {
519 begin()
520 id := launchAs(cur, creator1, "funded, not failed")
521 pledgeAs(cur, backerA, id, MinGoal)
522 testing.SkipHeights(MinDurationBlocks)
523
524 testing.SetRealm(testing.NewUserRealm(mallory))
525 uassert.AbortsWithMessage(t, cur,
526 "goal reached; the claim window is the creator's",
527 func() { SettleFailed(cross(cur), id) })
528
529 testing.SetRealm(testing.NewUserRealm(creator1))
530 CreatorClaim(cross(cur), id)
531}
532
533// --- cancel ---
534
535func TestCancelOpensRefunds(cur realm, t *testing.T) {
536 b := begin()
537 id := launchAs(cur, creator1, "cancelled project")
538 pledgeAs(cur, backerA, id, 600_000)
539
540 // Mallory cannot cancel someone else's campaign.
541 testing.SetRealm(testing.NewUserRealm(mallory))
542 uassert.AbortsWithMessage(t, cur, "creator only", func() {
543 CreatorCancel(cross(cur), id)
544 })
545
546 testing.SetRealm(testing.NewUserRealm(creator1))
547 CreatorCancel(cross(cur), id)
548 uassert.Equal(t, stateCancelled, Status(id))
549 uassert.True(t, IsRefundable(id))
550
551 // Cancelling twice: the deferral is gone, the state says so first.
552 testing.SetRealm(testing.NewUserRealm(creator1))
553 uassert.AbortsWithMessage(t, cur, "campaign is settled", func() {
554 CreatorCancel(cross(cur), id)
555 })
556
557 walletBefore := balanceOf(backerA)
558 testing.SetRealm(testing.NewUserRealm(backerA))
559 Refund(cross(cur), id)
560 uassert.Equal(t, walletBefore+600_000, balanceOf(backerA))
561 uassert.Equal(t, b.backed, TotalBacked())
562 uassert.Equal(t, b.fees, FeesAccrued()) // refunds are fee-free
563}
564
565func TestCancelAfterSuccessRenouncesThePot(cur realm, t *testing.T) {
566 begin()
567 id := launchAs(cur, creator1, "renounced project")
568 pledgeAs(cur, backerA, id, MinGoal)
569 testing.SkipHeights(MinDurationBlocks)
570 uassert.Equal(t, "succeeded (awaiting creator claim)", Status(id))
571
572 testing.SetRealm(testing.NewUserRealm(creator1))
573 CreatorCancel(cross(cur), id)
574 uassert.True(t, IsRefundable(id))
575 testing.SetRealm(testing.NewUserRealm(backerA))
576 Refund(cross(cur), id)
577 uassert.Equal(t, int64(0), PledgeOf(id, backerA))
578}
579
580// --- lapse ---
581
582func TestLapseRefusesWhileClaimWindowIsOpen(cur realm, t *testing.T) {
583 begin()
584 id := launchAs(cur, creator1, "lapse timing")
585 pledgeAs(cur, backerA, id, MinGoal)
586 testing.SkipHeights(MinDurationBlocks) // due, but window open
587
588 testing.SetRealm(testing.NewUserRealm(mallory))
589 uassert.AbortsWithMessage(t, cur, "duebook: deferral has not expired", func() {
590 Lapse(cross(cur), id)
591 })
592
593 testing.SkipHeights(ClaimWindowBlocks)
594 testing.SetRealm(testing.NewUserRealm(mallory))
595 Lapse(cross(cur), id)
596 uassert.Equal(t, stateLapsed, Status(id))
597
598 testing.SetRealm(testing.NewUserRealm(mallory))
599 uassert.AbortsWithMessage(t, cur, "campaign is settled", func() {
600 Lapse(cross(cur), id)
601 })
602 testing.SetRealm(testing.NewUserRealm(backerA))
603 Refund(cross(cur), id)
604}
605
606// --- refund guards ---
607
608func TestRefundGuards(cur realm, t *testing.T) {
609 begin()
610 id := launchAs(cur, creator1, "refund guards")
611 pledgeAs(cur, backerA, id, 100_000)
612
613 // Not refundable while funding is open.
614 testing.SetRealm(testing.NewUserRealm(backerA))
615 uassert.AbortsWithMessage(t, cur, "campaign is not refundable", func() {
616 Refund(cross(cur), id)
617 })
618
619 testing.SetRealm(testing.NewUserRealm(creator1))
620 CreatorCancel(cross(cur), id)
621
622 // A stranger with no pledge gets nothing.
623 testing.SetRealm(testing.NewUserRealm(mallory))
624 uassert.AbortsWithMessage(t, cur, "no pledge to return", func() {
625 Refund(cross(cur), id)
626 })
627
628 // The backer refunds once, then has nothing left.
629 testing.SetRealm(testing.NewUserRealm(backerA))
630 Refund(cross(cur), id)
631 testing.SetRealm(testing.NewUserRealm(backerA))
632 uassert.AbortsWithMessage(t, cur, "no pledge to return", func() {
633 Refund(cross(cur), id)
634 })
635}
636
637// --- prune ---
638
639func TestPrune(cur realm, t *testing.T) {
640 begin()
641 id := launchAs(cur, creator1, "prunable")
642 pledgeAs(cur, backerA, id, 50_000)
643
644 // Not while unsettled.
645 testing.SetRealm(testing.NewUserRealm(mallory))
646 uassert.AbortsWithMessage(t, cur, "campaign is not settled", func() {
647 Prune(cross(cur), id)
648 })
649
650 testing.SetRealm(testing.NewUserRealm(creator1))
651 CreatorCancel(cross(cur), id)
652
653 // Not while backer funds remain.
654 testing.SetRealm(testing.NewUserRealm(mallory))
655 uassert.AbortsWithMessage(t, cur, "campaign still holds backer funds", func() {
656 Prune(cross(cur), id)
657 })
658
659 testing.SetRealm(testing.NewUserRealm(backerA))
660 Refund(cross(cur), id)
661
662 nBefore := NumCampaigns()
663 testing.SetRealm(testing.NewUserRealm(mallory))
664 Prune(cross(cur), id)
665 uassert.Equal(t, nBefore, NumCampaigns()) // ids are never reused
666 testing.SetRealm(testing.NewUserRealm(mallory))
667 uassert.AbortsWithMessage(t, cur, "unknown campaign id", func() {
668 Prune(cross(cur), id)
669 })
670 uassert.PanicsWithMessage(t, cur, "unknown campaign id", func() {
671 CampaignInfo(id)
672 })
673}
674
675// --- fees and surplus ---
676
677func TestWithdrawFees(cur realm, t *testing.T) {
678 b := begin()
679 if b.fees == 0 {
680 // Accrue a fee: fund and claim a campaign.
681 id := launchAs(cur, creator1, "fee source")
682 pledgeAs(cur, backerA, id, MinGoal)
683 testing.SkipHeights(MinDurationBlocks)
684 testing.SetRealm(testing.NewUserRealm(creator1))
685 CreatorClaim(cross(cur), id)
686 }
687 fees := FeesAccrued()
688 uassert.True(t, fees > 0)
689
690 collectorBefore := balanceOf(FeeCollector)
691 testing.SetRealm(testing.NewUserRealm(mallory)) // anyone may trigger
692 got := WithdrawFees(cross(cur))
693 uassert.Equal(t, fees, got)
694 uassert.Equal(t, collectorBefore+fees, balanceOf(FeeCollector))
695 uassert.Equal(t, int64(0), FeesAccrued())
696
697 testing.SetRealm(testing.NewUserRealm(mallory))
698 uassert.AbortsWithMessage(t, cur, "no fees accrued", func() {
699 WithdrawFees(cross(cur))
700 })
701}
702
703func TestSweepSurplus(cur realm, t *testing.T) {
704 begin()
705 // No surplus: coinio refuses.
706 testing.SetRealm(testing.NewUserRealm(mallory))
707 uassert.AbortsWithMessage(t, cur, "coinio: no surplus to sweep for ugnot", func() {
708 SweepSurplus(cross(cur), Denom)
709 })
710
711 // Force-send 33_000 ugnot out-of-band, then sweep it.
712 testing.IssueCoins(Address(), ugnot(33_000))
713 liabBefore := Liabilities()
714 collectorBefore := balanceOf(FeeCollector)
715 testing.SetRealm(testing.NewUserRealm(mallory))
716 swept := SweepSurplus(cross(cur), Denom)
717 uassert.Equal(t, int64(33_000), swept)
718 uassert.Equal(t, collectorBefore+33_000, balanceOf(FeeCollector))
719 uassert.Equal(t, liabBefore, Liabilities()) // tracked money untouched
720
721 // A foreign denomination sweeps in full.
722 testing.IssueCoins(Address(), chain.NewCoins(chain.NewCoin("foocoin", 4_200)))
723 testing.SetRealm(testing.NewUserRealm(mallory))
724 uassert.Equal(t, int64(4_200), SweepSurplus(cross(cur), "foocoin"))
725}
726
727// --- stray sends ---
728
729func TestNonPayableEntrypointsRejectCoins(cur realm, t *testing.T) {
730 begin()
731 id := launchAs(cur, creator1, "stray send case")
732
733 cases := []func(){
734 func() { Launch(cross(cur), "t", "", MinGoal, MinDurationBlocks) },
735 func() { Unpledge(cross(cur), id) },
736 func() { CreatorClaim(cross(cur), id) },
737 func() { CreatorCancel(cross(cur), id) },
738 func() { SettleFailed(cross(cur), id) },
739 func() { Lapse(cross(cur), id) },
740 func() { Refund(cross(cur), id) },
741 func() { Prune(cross(cur), id) },
742 func() { WithdrawFees(cross(cur)) },
743 func() { SweepSurplus(cross(cur), Denom) },
744 }
745 for _, call := range cases {
746 testing.SetRealm(testing.NewUserRealm(mallory))
747 testing.SetOriginSend(ugnot(1))
748 uassert.AbortsWithMessage(t, cur, "this entrypoint does not accept coins", call)
749 }
750 testing.SetOriginSend(nil)
751 testing.SetRealm(testing.NewUserRealm(creator1))
752 CreatorCancel(cross(cur), id)
753}
754
755// --- render ---
756
757func TestRenderIndexAndCampaign(cur realm, t *testing.T) {
758 begin()
759 // A hostile title must come out of the sanitizer defanged.
760 hostile := "[evil](https://x) <script>alert(1)</script>"
761 testing.SetRealm(testing.NewUserRealm(creator1))
762 id := Launch(cross(cur), hostile, "memo with [link](x) inside", MinGoal, MinDurationBlocks)
763
764 index := Render("")
765 uassert.True(t, strings.Contains(index, "# Crowdfund"))
766 uassert.True(t, strings.Contains(index, "conservation: OK"))
767 uassert.False(t, strings.Contains(index, "<script>"))
768
769 page := Render(itoa(id))
770 uassert.True(t, strings.Contains(page, "status: **funding**"))
771 uassert.True(t, strings.Contains(page, "0%"))
772 uassert.False(t, strings.Contains(page, "<script>"))
773 uassert.False(t, strings.Contains(page, "[evil](https://x)"))
774 uassert.False(t, strings.Contains(page, "[link](x)"))
775
776 uassert.True(t, strings.Contains(Render("notanumber"), "invalid campaign id"))
777 uassert.True(t, strings.Contains(Render("999999"), "unknown campaign id"))
778
779 testing.SetRealm(testing.NewUserRealm(creator1))
780 CreatorCancel(cross(cur), id)
781}
782
783func TestPercentAndBps(t *testing.T) {
784 uassert.Equal(t, "0%", percent(0, MinGoal))
785 uassert.Equal(t, "50%", percent(500_000, MinGoal))
786 uassert.Equal(t, "99%", percent(999_999, MinGoal))
787 uassert.Equal(t, "100%", percent(MinGoal, MinGoal))
788 uassert.Equal(t, "250%", percent(2_500_000, MinGoal))
789 // The overflow-guard branch: r*100 would not fit in int64. Three
790 // quarters of 2^62 is exactly 75%, and the scaled form lands on it.
791 huge := int64(1) << 62
792 uassert.Equal(t, "75%", percent(3*(huge/4), huge))
793
794 uassert.Equal(t, "1%", bps(100))
795 uassert.Equal(t, "5%", bps(500))
796 uassert.Equal(t, "0.5%", bps(50))
797 uassert.Equal(t, "0.01%", bps(1))
798 uassert.Equal(t, "2.25%", bps(225))
799}