joinprice_test.gno
4.49 Kb · 119 lines
1package crews
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "chain"
9
10 "gno.land/p/moul/x/social/crew/v0"
11 "gno.land/p/nt/uassert/v0"
12 "gno.land/p/nt/urequire/v0"
13)
14
15// The price a crew page shows a joiner, and the floor Join's envelope guard
16// enforces, must both be exactly the least amount that mints a whole share.
17//
18// Join mints amount * totalShares / treasury, rounded down, so that amount is
19// treasury / totalShares rounded UP. Rounded down instead, it is one ugnot
20// short whenever the treasury is not an exact multiple of the share count,
21// which is the ordinary state after any Fund: the page quotes a price, the
22// guard accepts it, and the join reverts on "buys no whole share".
23//
24// The table is over that rule, not over one input: awkward remainders where
25// floor and ceiling differ, exact multiples where they must not, and a crew
26// everybody left, where the opening price applies.
27func TestTheShownPriceBuysAShareAndOneLessIsRefusedByTheGuard(cur realm, t *testing.T) {
28 cases := []struct {
29 name string
30 create int64 // what the founder pays in, at the opening price
31 fund int64 // then funded with this, which mints nothing
32 leave bool // then the founder ragequits, leaving an empty crew
33 want int64 // the least amount that mints one share
34 }{
35 {"treasury 3001 over 2 shares", 2000, 1001, false, 1501},
36 {"treasury 3001 over 3 shares", 3000, 1, false, 1001},
37 {"ten GNOT and seven ugnot over 10000 shares", tenGNOT, 7, false, 1001},
38 {"ten GNOT and 999 ugnot over 10000 shares", tenGNOT, 999, false, 1001},
39 {"exact multiple at the opening price", tenGNOT, 0, false, 1000},
40 {"exact multiple after doubling", tenGNOT, tenGNOT, false, 2000},
41 {"a single share", 1000, 0, false, 1000},
42 {"everybody left", 1000, 0, true, crew.InitialPricePerShare},
43 }
44 for _, tc := range cases {
45 reset()
46 id := create(cur, alice, tc.create, "crew")
47 if tc.fund > 0 {
48 fund(cur, carol, tc.fund, id)
49 }
50 if tc.leave {
51 testing.SetRealm(testing.NewUserRealm(alice))
52 Ragequit(cross(cur), id)
53 }
54
55 // The oracle, independent of the code under test: the least a with
56 // a * shares >= treasury, or the opening price when nobody holds one.
57 _, _, _, shares, treasury := Get(id)
58 oracle := crew.InitialPricePerShare
59 if shares > 0 && treasury > 0 {
60 oracle = treasury / shares
61 if treasury%shares != 0 {
62 oracle++
63 }
64 }
65 urequire.Equal(t, tc.want, oracle, tc.name+": the table agrees with the rule")
66
67 page := Render("crew/" + strconv.FormatInt(id, 10))
68 uassert.Equal(t, tc.want, shownPrice(t, page),
69 tc.name+": the page quotes what buys a share: "+page)
70
71 // One ugnot under it is refused by the envelope guard, which names the
72 // real price, rather than by Join after the guard let it through.
73 testing.IssueCoins(realmAddr, chain.Coins{{denom, tc.want - 1}})
74 testing.SetOriginSend(chain.Coins{{denom, tc.want - 1}})
75 testing.SetRealm(testing.NewUserRealm(bob))
76 uassert.AbortsContains(t, cur, "costs at least "+strconv.FormatInt(tc.want, 10)+denom,
77 func() { Join(cross(cur), id) }, tc.name+": the guard refuses one ugnot short")
78
79 // Exactly the quoted price buys exactly one share.
80 got := join(cur, bob, tc.want, id)
81 uassert.Equal(t, int64(1), got, tc.name+": the quoted price mints one share")
82 }
83}
84
85// shownPrice reads the price cell of a crew page's summary table, the first
86// row after the header that starts with "| members", in ugnot. It is keyed on
87// the row's position, not the column's title, so it reads the same cell
88// whatever that column is called.
89func shownPrice(t *testing.T, page string) int64 {
90 lines := strings.Split(page, "\n")
91 for i, l := range lines {
92 if !strings.HasPrefix(l, "| members") {
93 continue
94 }
95 urequire.True(t, i+2 < len(lines), "the summary table has a row: "+page)
96 cells := strings.Split(lines[i+2], "|")
97 urequire.Equal(t, 6, len(cells), "four cells: "+lines[i+2])
98 return parseGNOT(t, strings.TrimSpace(cells[4]))
99 }
100 t.Fatalf("no summary table on the crew page: %s", page)
101 return 0
102}
103
104// parseGNOT turns num.GNOTf's "1.0015 GNOT" back into ugnot.
105func parseGNOT(t *testing.T, s string) int64 {
106 urequire.True(t, strings.HasSuffix(s, " GNOT"), "a GNOT amount: "+s)
107 s = strings.TrimSuffix(s, " GNOT")
108 whole, frac := s, ""
109 if i := strings.Index(s, "."); i >= 0 {
110 whole, frac = s[:i], s[i+1:]
111 }
112 urequire.True(t, len(frac) <= 6, "at most six decimals: "+s)
113 frac += strings.Repeat("0", 6-len(frac))
114 w, err := strconv.ParseInt(whole, 10, 64)
115 urequire.NoError(t, err, s)
116 f, err := strconv.ParseInt(frac, 10, 64)
117 urequire.NoError(t, err, s)
118 return w*1000000 + f
119}