1package crew23import (4 "strconv"5 "strings"6 "testing"78 "gno.land/p/moul/kit/store/v0"9 "gno.land/p/nt/testutils/v0"10 "gno.land/p/nt/uassert/v0"11 "gno.land/p/nt/urequire/v0"12)1314var (15 alice = testutils.TestAddress("alice")16 bob = testutils.TestAddress("bob")17 carol = testutils.TestAddress("carol")18 dave = testutils.TestAddress("dave")19)2021// join is Join with the minted share count dropped, for the cases that only22// care that it worked.23func join(t *testing.T, cs *Crews, id store.ID, who address, amount int64) {24 t.Helper()25 _, err := cs.Join(id, who, amount)26 urequire.NoError(t, err)27}2829// tenGNOT is the amount every founder in these tests puts in, chosen so the30// share count is a round 10,000 and a halved or doubled price is still exact.31const tenGNOT = int64(10000000)3233func TestValidName(t *testing.T) {34 tests := []struct {35 name string36 want bool37 }{38 {"validators", true},39 {"The Hackathon Team", true},40 {"a", true},41 {strings.Repeat("x", MaxNameLen), true},42 {"", false},43 {" ", false},44 {strings.Repeat("x", MaxNameLen+1), false},45 {"two\nlines", false},46 {"a\tb", false},47 {"a\x7fb", false},48 {"a | pipe", false},49 }50 for _, tt := range tests {51 uassert.Equal(t, tt.want, ValidName(tt.name), tt.name)52 }53}5455func TestValidText(t *testing.T) {56 tests := []struct {57 name string58 text string59 want bool60 }{61 {"plain", "ship v1 before the conference", true},62 {"newlines and tabs are content", "a\nb\tc", true},63 {"markdown is allowed and escaped later", "[x](y) | z", true},64 {"empty", "", false},65 {"blank", " \n ", false},66 {"too long", strings.Repeat("x", MaxTextLen+1), false},67 {"control character", "a\x01b", false},68 {"delete character", "a\x7fb", false},69 }70 for _, tt := range tests {71 uassert.Equal(t, tt.want, ValidText(tt.text), tt.name)72 }73}7475func TestCreateMintsAtTheOpeningPrice(t *testing.T) {76 cs := New()77 id, err := cs.Create("validators", alice, tenGNOT, 100)78 urequire.NoError(t, err)7980 c, ok := cs.Get(id)81 urequire.True(t, ok, "the crew exists")82 uassert.Equal(t, "validators", c.Name)83 uassert.Equal(t, int64(100), c.CreatedAt)84 uassert.Equal(t, tenGNOT/InitialPricePerShare, c.TotalShares)85 uassert.Equal(t, tenGNOT, c.Treasury)86 uassert.Equal(t, c.TotalShares, c.SharesOf(alice))87 uassert.Equal(t, 1, c.MemberCount())88 uassert.Equal(t, InitialPricePerShare, c.ValuePerShare())89 uassert.Equal(t, 1, cs.Len())90}9192// The remainder below one whole share stays in the treasury, which is the same93// direction every other division here rounds.94func TestCreateKeepsTheRemainder(t *testing.T) {95 cs := New()96 id, err := cs.Create("crew", alice, 2999, 100)97 urequire.NoError(t, err)9899 c, _ := cs.Get(id)100 uassert.Equal(t, int64(2), c.TotalShares, "2999 ugnot buys two whole shares")101 uassert.Equal(t, int64(2999), c.Treasury, "and the 999 left over is the crew's")102}103104func TestCreateRefusals(t *testing.T) {105 tests := []struct {106 label string107 name string108 amount int64109 want error110 }{111 {"empty name", "", tenGNOT, ErrBadName},112 {"name with a newline", "two\nlines", tenGNOT, ErrBadName},113 {"name with a pipe", "a | pipe", tenGNOT, ErrBadName},114 {"nothing sent", "crew", 0, ErrBadAmount},115 {"negative", "crew", -1, ErrBadAmount},116 {"under one share", "crew", InitialPricePerShare - 1, ErrNoShares},117 }118 for _, tt := range tests {119 cs := New()120 _, err := cs.Create(tt.name, alice, tt.amount, 100)121 uassert.ErrorIs(t, err, tt.want, tt.label)122 uassert.Equal(t, 0, cs.Len(), tt.label+": nothing was stored")123 }124}125126// A second joiner at the same per-share value gets the same shares. This is the127// baseline the anti-dilution rule is measured against.128func TestASecondJoinerAtTheSamePriceGetsTheSameShares(t *testing.T) {129 cs := New()130 id, _ := cs.Create("crew", alice, tenGNOT, 100)131132 got, err := cs.Join(id, bob, tenGNOT)133 urequire.NoError(t, err)134135 c, _ := cs.Get(id)136 uassert.Equal(t, c.SharesOf(alice), got, "same money, same shares")137 uassert.Equal(t, tenGNOT/InitialPricePerShare, got)138 uassert.Equal(t, 2*tenGNOT, c.Treasury)139 uassert.Equal(t, 2*(tenGNOT/InitialPricePerShare), c.TotalShares)140 uassert.Equal(t, InitialPricePerShare, c.ValuePerShare(), "the value per share did not move")141 uassert.Equal(t, 2, c.MemberCount())142}143144// A joiner arriving after the treasury grew gets FEWER shares for the same145// money, because they are buying at what a share is worth now. That is the146// whole point: a late joiner cannot buy into value the existing members built.147func TestAJoinerAfterTheTreasuryGrewGetsFewer(t *testing.T) {148 cs := New()149 id, _ := cs.Create("crew", alice, tenGNOT, 100)150 urequire.NoError(t, cs.Fund(id, tenGNOT)) // the crew doubled its money151152 c, _ := cs.Get(id)153 urequire.Equal(t, 2*InitialPricePerShare, c.ValuePerShare())154155 got, err := cs.Join(id, bob, tenGNOT)156 urequire.NoError(t, err)157 uassert.Equal(t, c.SharesOf(alice)/2, got, "the same money buys half as much")158 uassert.Equal(t, 2*InitialPricePerShare, c.ValuePerShare(),159 "and joining did not move the value for anybody else")160}161162// Rounding down on the way in means a joiner never gets a share they did not163// fully pay for, and the fractional remainder accrues to the crew.164func TestJoinRoundsInTheCrewsFavour(t *testing.T) {165 cs := New()166 id, _ := cs.Create("crew", alice, 1000, 100) // 1 share, treasury 1000167 urequire.NoError(t, cs.Fund(id, 500)) // treasury 1500, still 1 share168169 // 1499 ugnot is 0.999 of a share. Rounding up would hand bob a whole170 // one and half of alice's money with it.171 _, err := cs.Join(id, bob, 1499)172 uassert.ErrorIs(t, err, ErrNoShares)173174 got, err := cs.Join(id, bob, 2999)175 urequire.NoError(t, err)176 uassert.Equal(t, int64(1), got, "1.999 shares' worth buys one share")177178 c, _ := cs.Get(id)179 uassert.Equal(t, int64(4499), c.Treasury, "the 1499 ugnot of remainder stayed")180 uassert.Equal(t, int64(2), c.TotalShares)181}182183func TestJoinRefusals(t *testing.T) {184 cs := New()185 id, _ := cs.Create("crew", alice, tenGNOT, 100)186187 _, err := cs.Join(999, bob, tenGNOT)188 uassert.ErrorIs(t, err, ErrNoCrew)189190 _, err = cs.Join(id, alice, tenGNOT)191 uassert.ErrorIs(t, err, ErrIsMember, "a member cannot buy in twice")192193 _, err = cs.Join(id, bob, 0)194 uassert.ErrorIs(t, err, ErrBadAmount)195196 c, _ := cs.Get(id)197 uassert.Equal(t, 1, c.MemberCount(), "no refusal changed the roster")198 uassert.Equal(t, tenGNOT, c.Treasury)199}200201// Fifteen people is the product constraint, and the sixteenth is refused rather202// than quietly allowed.203func TestACrewIsFullAtMaxMembers(t *testing.T) {204 cs := New()205 id, _ := cs.Create("crew", alice, tenGNOT, 100)206 for i := 1; i < MaxMembers; i++ {207 who := testutils.TestAddress("m" + strconv.Itoa(i))208 _, err := cs.Join(id, who, tenGNOT)209 urequire.NoError(t, err)210 }211212 c, _ := cs.Get(id)213 urequire.Equal(t, MaxMembers, c.MemberCount())214215 _, err := cs.Join(id, testutils.TestAddress("one too many"), tenGNOT)216 uassert.ErrorIs(t, err, ErrFull)217 uassert.Equal(t, MaxMembers, c.MemberCount())218}219220func TestProposeAndTallyByShareWeight(t *testing.T) {221 cs := New()222 id, _ := cs.Create("crew", alice, tenGNOT, 100) // alice: 10000 shares223 cs.Join(id, bob, tenGNOT/2) // bob: 5000 shares224225 pid, err := cs.Propose(id, bob, "ship v1 before the conference", 200)226 urequire.NoError(t, err)227228 p, ok := cs.Proposal(pid)229 urequire.True(t, ok, "the proposal exists")230 uassert.Equal(t, uint64(id), uint64(p.CrewID))231 uassert.Equal(t, int64(200+VoteBlocks), p.Deadline)232 uassert.True(t, p.Open(200))233 uassert.False(t, p.Open(200+VoteBlocks), "the deadline is exclusive")234235 urequire.NoError(t, cs.Vote(pid, bob, true, 210))236 urequire.NoError(t, cs.Vote(pid, alice, false, 211))237 uassert.Equal(t, int64(5000), p.Yes)238 uassert.Equal(t, int64(10000), p.No, "a bigger holder outvotes a smaller one")239 uassert.Equal(t, 2, p.Voters())240241 c, _ := cs.Get(id)242 uassert.Equal(t, 1, len(c.Proposals()))243}244245// One ballot per member, replaced in place while the proposal is open, and246// reweighed from the shares held at the moment it is recast.247func TestAVoteIsChangeableWhileOpen(t *testing.T) {248 cs := New()249 id, _ := cs.Create("crew", alice, tenGNOT, 100)250 pid, _ := cs.Propose(id, alice, "a question", 200)251 p, _ := cs.Proposal(pid)252253 urequire.NoError(t, cs.Vote(pid, alice, true, 210))254 uassert.Equal(t, int64(10000), p.Yes)255 uassert.Equal(t, int64(0), p.No)256257 urequire.NoError(t, cs.Vote(pid, alice, false, 211))258 uassert.Equal(t, int64(0), p.Yes, "the old ballot is removed, not added to")259 uassert.Equal(t, int64(10000), p.No)260 uassert.Equal(t, 1, p.Voters(), "still one voter")261262 yes, weight, voted := p.VoteOf(alice)263 uassert.True(t, voted)264 uassert.False(t, yes)265 uassert.Equal(t, int64(10000), weight)266}267268func TestProposeAndVoteRefusals(t *testing.T) {269 cs := New()270 id, _ := cs.Create("crew", alice, tenGNOT, 100)271272 _, err := cs.Propose(999, alice, "text", 200)273 uassert.ErrorIs(t, err, ErrNoCrew)274 _, err = cs.Propose(id, bob, "text", 200)275 uassert.ErrorIs(t, err, ErrNotMember, "only a member proposes")276 _, err = cs.Propose(id, alice, " ", 200)277 uassert.ErrorIs(t, err, ErrBadText)278279 pid, _ := cs.Propose(id, alice, "a question", 200)280 uassert.ErrorIs(t, cs.Vote(999, alice, true, 210), ErrNoProposal)281 uassert.ErrorIs(t, cs.Vote(pid, bob, true, 210), ErrNotMember)282 uassert.ErrorIs(t, cs.Vote(pid, alice, true, 200+VoteBlocks), ErrVoteClosed,283 "the deadline is exclusive")284}285286func TestCloseRecordsTheResult(t *testing.T) {287 tests := []struct {288 label string289 aliceVotes bool290 bobVotes bool291 want bool292 }{293 {"the bigger holder carries it", true, false, true},294 {"and blocks it", false, true, false},295 {"unanimous yes", true, true, true},296 {"unanimous no", false, false, false},297 }298 for _, tt := range tests {299 cs := New()300 id, _ := cs.Create("crew", alice, tenGNOT, 100) // 10000 shares301 cs.Join(id, bob, tenGNOT/2) // 5000 shares302 pid, _ := cs.Propose(id, alice, "a question", 200)303304 urequire.NoError(t, cs.Vote(pid, alice, tt.aliceVotes, 210))305 urequire.NoError(t, cs.Vote(pid, bob, tt.bobVotes, 211))306307 passed, err := cs.Close(pid, 200+VoteBlocks)308 urequire.NoError(t, err)309 uassert.Equal(t, tt.want, passed, tt.label)310311 p, _ := cs.Proposal(pid)312 uassert.True(t, p.Closed, tt.label)313 uassert.Equal(t, tt.want, p.Passed, tt.label)314 }315}316317// A tie fails, and a proposal nobody voted on is a tie at zero.318func TestATieFails(t *testing.T) {319 cs := New()320 id, _ := cs.Create("crew", alice, tenGNOT, 100)321 cs.Join(id, bob, tenGNOT)322 pid, _ := cs.Propose(id, alice, "a question", 200)323324 urequire.NoError(t, cs.Vote(pid, alice, true, 210))325 urequire.NoError(t, cs.Vote(pid, bob, false, 211))326327 passed, err := cs.Close(pid, 200+VoteBlocks)328 urequire.NoError(t, err)329 uassert.False(t, passed, "equal weight on both sides is not agreement")330331 silent, _ := cs.Propose(id, alice, "nobody cares", 200)332 passed, err = cs.Close(silent, 200+VoteBlocks)333 urequire.NoError(t, err)334 uassert.False(t, passed, "0 to 0 is not agreement either")335}336337func TestCloseRefusals(t *testing.T) {338 cs := New()339 id, _ := cs.Create("crew", alice, tenGNOT, 100)340 pid, _ := cs.Propose(id, alice, "a question", 200)341342 _, err := cs.Close(999, 5000)343 uassert.ErrorIs(t, err, ErrNoProposal)344345 _, err = cs.Close(pid, 200+VoteBlocks-1)346 uassert.ErrorIs(t, err, ErrVoteOpen, "one block early is early")347348 _, err = cs.Close(pid, 200+VoteBlocks)349 urequire.NoError(t, err)350 _, err = cs.Close(pid, 200+VoteBlocks)351 uassert.ErrorIs(t, err, ErrVoteClosed, "closing twice does not re-tally")352}353354// A vote keeps the weight it was cast with. Ragequitting afterwards does not355// unwind it, which is a v0 caveat stated in the package doc rather than a bug.356func TestAVoteKeepsTheWeightItWasCastWith(t *testing.T) {357 cs := New()358 id, _ := cs.Create("crew", alice, tenGNOT, 100)359 cs.Join(id, bob, tenGNOT)360 pid, _ := cs.Propose(id, alice, "a question", 200)361362 urequire.NoError(t, cs.Vote(pid, bob, true, 210))363 _, _, err := cs.Ragequit(id, bob)364 urequire.NoError(t, err)365366 p, _ := cs.Proposal(pid)367 uassert.Equal(t, int64(10000), p.Yes, "bob's ballot stayed behind")368 uassert.ErrorIs(t, cs.Vote(pid, bob, false, 220), ErrNotMember,369 "but he cannot touch it again")370}371372func TestRagequitTakesAProRataSlice(t *testing.T) {373 cs := New()374 id, _ := cs.Create("crew", alice, tenGNOT, 100)375 cs.Join(id, bob, tenGNOT)376 urequire.NoError(t, cs.Fund(id, 2*tenGNOT)) // the crew doubled its money377378 shares, amount, err := cs.Ragequit(id, bob)379 urequire.NoError(t, err)380 uassert.Equal(t, tenGNOT/InitialPricePerShare, shares, "every share is burned")381 uassert.Equal(t, 2*tenGNOT, amount, "half of a treasury worth four, for half the shares")382383 c, _ := cs.Get(id)384 uassert.Equal(t, 1, c.MemberCount())385 uassert.Equal(t, int64(0), c.SharesOf(bob))386 uassert.False(t, c.IsMember(bob))387 uassert.Equal(t, 2*tenGNOT, c.Treasury)388 uassert.Equal(t, tenGNOT/InitialPricePerShare, c.TotalShares)389390 uassert.Equal(t, 2*tenGNOT, cs.CreditOf(bob), "credited, never sent")391 uassert.Equal(t, 2*tenGNOT, cs.TotalOwed())392393 _, _, err = cs.Ragequit(id, bob)394 uassert.ErrorIs(t, err, ErrNotMember, "there is nothing left to burn")395 _, _, err = cs.Ragequit(999, alice)396 uassert.ErrorIs(t, err, ErrNoCrew)397}398399// A one-member crew ragequitting takes everything: their shares ARE the total,400// so the division is exact whatever the numbers are.401func TestAOneMemberCrewTakesEverything(t *testing.T) {402 cs := New()403 id, _ := cs.Create("crew", alice, 5001, 100) // 5 shares, a treasury of 5001404405 shares, amount, err := cs.Ragequit(id, alice)406 urequire.NoError(t, err)407 uassert.Equal(t, int64(5), shares)408 uassert.Equal(t, int64(5001), amount, "including the ugnot that bought no share")409410 c, _ := cs.Get(id)411 uassert.Equal(t, int64(0), c.Treasury)412 uassert.Equal(t, int64(0), c.TotalShares)413 uassert.Equal(t, 0, c.MemberCount())414}415416// Everyone ragequitting empties the treasury to the last ugnot, even when no417// single division is exact: the dust each leaver rounds away belongs to whoever418// is still in, and the last one out holds every share.419func TestEveryoneRagequittingEmptiesTheTreasury(t *testing.T) {420 cs := New()421 id, _ := cs.Create("crew", alice, 1000, 100)422 join(t, cs, id, bob, 1000)423 join(t, cs, id, carol, 1000)424 urequire.NoError(t, cs.Fund(id, 2)) // 3002 ugnot over 3 shares, divides by nothing425426 c, _ := cs.Get(id)427 urequire.Equal(t, int64(3002), c.Treasury)428 urequire.Equal(t, int64(3), c.TotalShares)429430 var paid int64431 for _, who := range []address{alice, bob, carol} {432 _, amount, err := cs.Ragequit(id, who)433 urequire.NoError(t, err)434 paid += amount435 }436437 uassert.Equal(t, int64(3002), paid, "every ugnot left with somebody")438 uassert.Equal(t, int64(0), c.Treasury, "to the last one")439 uassert.Equal(t, int64(0), c.TotalShares)440 uassert.Equal(t, 0, c.MemberCount())441 uassert.Equal(t, int64(3002), cs.TotalOwed())442443 // The dust did not land evenly, which is the point: the people who444 // stayed longest absorbed it.445 uassert.Equal(t, int64(1000), cs.CreditOf(alice), "first out, rounded down hardest")446 uassert.Equal(t, int64(1001), cs.CreditOf(bob))447 uassert.Equal(t, int64(1001), cs.CreditOf(carol))448}449450// An emptied crew is not a dead one. With no shares outstanding there is no451// value to price against, so it reopens at the price it opened at.452func TestRejoiningAnEmptiedCrewIsBackAtTheOpeningPrice(t *testing.T) {453 cs := New()454 id, _ := cs.Create("crew", alice, tenGNOT, 100)455 _, _, err := cs.Ragequit(id, alice)456 urequire.NoError(t, err)457458 c, _ := cs.Get(id)459 urequire.Equal(t, int64(0), c.TotalShares)460 uassert.Equal(t, int64(0), c.ValuePerShare(), "nothing is worth anything yet")461 uassert.Equal(t, InitialPricePerShare, c.PricePerShare(), "but a share still has a price")462463 got, err := cs.Join(id, dave, tenGNOT)464 urequire.NoError(t, err)465 uassert.Equal(t, tenGNOT/InitialPricePerShare, got)466 uassert.Equal(t, tenGNOT, c.Treasury)467}468469func TestWithdrawZeroesTheCreditBeforeItReturns(t *testing.T) {470 cs := New()471 id, _ := cs.Create("crew", alice, tenGNOT, 100)472 cs.Join(id, bob, tenGNOT)473 _, owed, _ := cs.Ragequit(id, bob)474 urequire.True(t, owed > 0, "bob is owed something")475476 got, err := cs.Withdraw(bob)477 urequire.NoError(t, err)478 uassert.Equal(t, owed, got)479 uassert.Equal(t, int64(0), cs.CreditOf(bob), "zeroed, so a reentrant call finds nothing")480 uassert.Equal(t, int64(0), cs.TotalOwed())481482 _, err = cs.Withdraw(bob)483 uassert.ErrorIs(t, err, ErrNothing)484 _, err = cs.Withdraw(carol)485 uassert.ErrorIs(t, err, ErrNothing)486}487488// Credits accumulate across crews, because one ledger serves all of them.489func TestCreditsAccumulateAcrossCrews(t *testing.T) {490 cs := New()491 first, _ := cs.Create("first", alice, tenGNOT, 100)492 second, _ := cs.Create("second", bob, tenGNOT, 100)493 join(t, cs, second, alice, tenGNOT)494495 _, a, _ := cs.Ragequit(first, alice)496 _, b, _ := cs.Ragequit(second, alice)497 uassert.Equal(t, a+b, cs.CreditOf(alice), "one withdrawal collects both")498 uassert.Equal(t, 2, cs.Len())499}500501func TestListIsNewestFirst(t *testing.T) {502 cs := New()503 first, _ := cs.Create("first", alice, tenGNOT, 100)504 second, _ := cs.Create("second", bob, tenGNOT, 101)505 third, _ := cs.Create("third", carol, tenGNOT, 102)506507 got := cs.List(10)508 urequire.Equal(t, 3, len(got))509 uassert.Equal(t, uint64(third), uint64(got[0].ID))510 uassert.Equal(t, uint64(second), uint64(got[1].ID))511 uassert.Equal(t, uint64(first), uint64(got[2].ID))512 uassert.Equal(t, "third", got[0].Crew.Name)513514 uassert.Equal(t, 1, len(cs.List(1)))515 uassert.Equal(t, 0, len(cs.List(0)))516}517518// Members comes back in join order and as a copy, so a Render can never519// reshuffle the roster it is reading.520func TestMembersAreAnOrderedCopy(t *testing.T) {521 cs := New()522 id, _ := cs.Create("crew", alice, tenGNOT, 100)523 cs.Join(id, bob, tenGNOT)524 cs.Join(id, carol, tenGNOT)525526 c, _ := cs.Get(id)527 got := c.Members()528 urequire.Equal(t, 3, len(got))529 uassert.Equal(t, alice.String(), got[0].String())530 uassert.Equal(t, bob.String(), got[1].String())531 uassert.Equal(t, carol.String(), got[2].String())532533 got[0] = dave534 uassert.Equal(t, alice.String(), c.Members()[0].String(), "the caller got a copy")535536 // Ragequitting removes the leaver and keeps the rest in order.537 cs.Ragequit(id, bob)538 got = c.Members()539 urequire.Equal(t, 2, len(got))540 uassert.Equal(t, alice.String(), got[0].String())541 uassert.Equal(t, carol.String(), got[1].String())542}543544func TestFundRefusals(t *testing.T) {545 cs := New()546 id, _ := cs.Create("crew", alice, tenGNOT, 100)547548 uassert.ErrorIs(t, cs.Fund(999, 100), ErrNoCrew)549 uassert.ErrorIs(t, cs.Fund(id, 0), ErrBadAmount)550 uassert.ErrorIs(t, cs.Fund(id, -1), ErrBadAmount)551552 c, _ := cs.Get(id)553 uassert.Equal(t, tenGNOT, c.Treasury, "no refusal moved the treasury")554 uassert.Equal(t, 0, cs.ProposalCount())555}556Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.