Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}