payout.gno
8.54 Kb · 248 lines
1package bolo
2
3import (
4 "chain"
5 "chain/banker"
6 "chain/runtime"
7 "math/bits"
8
9 "gno.land/p/nt/seqid/v0"
10)
11
12// The refund windows' names: Refund's kind, GetContribution's window (API §3.2).
13const (
14 windowOpen = "open"
15 windowExit = "exit"
16)
17
18// Withdraw sends everything campaign id's escrow holds to its beneficiary, and returns
19// how much: the beneficiary only, until the exit window opens — the campaign's page
20// says when.
21//
22// The beneficiary only — a user by a direct transaction, or a deployed realm, through
23// C11's realm branch (API §3.3). The tracked part counts as Withdrawn,
24// and only it ends the open refund window; coins sent to the escrow outside Bolo go
25// along, recorded apart.
26func Withdraw(cur realm, id string) int64 {
27 assertCurrent(cur)
28 if cur.Previous().IsUserCall() {
29 runtime.AssertOriginCall()
30 } else {
31 assertCodeCaller(cur)
32 }
33 c := campaignOf(id)
34 recordDeadline(cur, c)
35 if cur.Previous().Address() != c.beneficiary {
36 panic(ErrNotBeneficiary)
37 }
38 w := bookWithdrawal(c)
39 total := w.amount + w.untracked
40 banker.NewBanker(banker.BankerTypeRealmSend, escrowSub(cur, c)).SendCoins(escrowAddr(c.id), c.beneficiary, chain.NewCoins(chain.NewCoin("ugnot", total)))
41 chain.Emit(evWithdrawn,
42 "id", idString(c.id),
43 "seq", idString(seqid.ID(w.seq)),
44 "to", w.to.String(),
45 "amount", itoa(w.amount),
46 "untracked", itoa(w.untracked),
47 )
48 return total
49}
50
51// bookWithdrawal checks a withdrawal from c and enters it in the books, before any
52// coin moves (API §3.3): the refusals of checkWithdrawable; nothing at the escrow;
53// INV-1. Everything at the escrow goes, the tracked part as Withdrawn — ending the
54// open refund window only when above 0 — and the rest recorded apart. It returns the
55// stored record.
56func bookWithdrawal(c *campaign) *withdrawal {
57 checkWithdrawable(c)
58 total := banker.NewReadonlyBanker().GetCoin(escrowAddr(c.id), "ugnot")
59 if total == 0 {
60 panic(ErrNothingToWithdraw)
61 }
62 assertBooks(c)
63 s := c.state
64 tracked := trackedBalance(c)
65 s.withdrawn += tracked
66 if tracked > 0 {
67 s.everWithdrawn = true
68 }
69 seq := s.withdrawalSeq.Next()
70 w := &withdrawal{seq: uint64(seq), amount: tracked, untracked: total - tracked, at: now(), to: c.beneficiary}
71 c.withdrawals.Set(seq.Binary(), w)
72 return w
73}
74
75// checkWithdrawable refuses a withdrawal from c (API §3.3), in this order: a direct
76// campaign, a settled one, and a closed one whose exit window has opened — what is
77// left belongs to the exit process then.
78func checkWithdrawable(c *campaign) {
79 if c.mode != modeEscrow {
80 panic(ErrWrongMode)
81 }
82 s := c.state
83 if s.status == statusSettled {
84 panic(ErrCampaignSettled)
85 }
86 if s.status == statusClosed && now() >= s.closedAt+c.params.ExitDelay {
87 panic(ErrExitOpen)
88 }
89}
90
91// Refund gives you back, from campaign id's escrow, what you can take back now, and
92// returns how much: your whole gift until the beneficiary first withdraws, or, once
93// the beneficiary's window after the campaign closes has passed, your share of what
94// they left uncollected. The campaign's page offers it when a donor can.
95//
96// It pays the caller back from their own contribution to the campaign (API §3.2,
97// INV-13): a donor, by a direct transaction, while a refund window is open to them.
98// The open window — no tracked withdrawal ever — pays everything not yet refunded.
99func Refund(cur realm, id string) int64 {
100 assertCurrent(cur)
101 if !cur.Previous().IsUserCall() {
102 panic(ErrNotUserCall)
103 }
104 runtime.AssertOriginCall()
105 c := campaignOf(id)
106 recordDeadline(cur, c)
107 donor := cur.Previous().Address()
108 r := bookRefund(c, donor)
109 banker.NewBanker(banker.BankerTypeRealmSend, escrowSub(cur, c)).SendCoins(escrowAddr(c.id), donor, chain.NewCoins(chain.NewCoin("ugnot", r.amount)))
110 chain.Emit(evRefunded,
111 "id", idString(c.id),
112 "seq", idString(seqid.ID(r.seq)),
113 "donor", donor.String(),
114 "amount", itoa(r.amount),
115 "kind", r.kind,
116 )
117 return r.amount
118}
119
120// bookRefund checks a refund of donor's contribution to c and enters it in the
121// books, before any coin moves (API §3.2): the refusals of checkRefund, then INV-1,
122// on the books as they stand. An exit claim freezes the pool and its base if it is
123// the first, uses the donor's one claim, and may not exceed the tracked balance
124// (INV-17). It returns the stored record.
125func bookRefund(c *campaign, donor address) *refund {
126 k, window, amount := checkRefund(c, donor)
127 assertBooks(c)
128 s := c.state
129 if window == windowExit {
130 if !s.exitFrozen {
131 s.exitFrozen, s.exitPool, s.exitBase = true, trackedBalance(c), s.raised-s.refunded
132 }
133 if amount > trackedBalance(c) {
134 panic(ErrBooksMismatch)
135 }
136 k.exitClaimed = true
137 }
138 k.refunded += amount
139 s.refunded += amount
140 s.refundCount++
141 seq := s.refundSeq.Next()
142 r := &refund{seq: uint64(seq), amount: amount, at: now(), donor: donor, kind: window}
143 c.refunds.Set(seq.Binary(), r)
144 return r
145}
146
147// checkRefund returns donor's contribution to c, the window Refund would use and what
148// it would pay, or panics (API §3.2), in this order: a direct campaign; nothing to
149// refund; no window open, a used exit claim included; an exit claim that rounds down
150// to 0.
151func checkRefund(c *campaign, donor address) (*contribution, string, int64) {
152 if c.mode != modeEscrow {
153 panic(ErrWrongMode)
154 }
155 k := contributionOf(c, donor)
156 if k == nil || k.amount-k.refunded <= 0 {
157 panic(ErrNothingToRefund)
158 }
159 window, amount := refundable(c, k)
160 if window == "" {
161 panic(ErrRefundWindowClosed)
162 }
163 if amount == 0 {
164 panic(ErrNothingToRefund)
165 }
166 return k, window, amount
167}
168
169// refundable returns the window Refund would use for k now, and what it would pay
170// (API §3.2). The open window, while no tracked withdrawal ever happened, pays all not
171// yet refunded. After one, the exit window opens once the campaign is closed and
172// ExitDelay has passed, while a tracked balance is left: one claim per donor, pro-rata
173// against the pool and base the first claim froze — or would freeze now. It reads the
174// effective status, as GetContribution can't record a passed deadline.
175func refundable(c *campaign, k *contribution) (string, int64) {
176 left := k.amount - k.refunded
177 if c.mode != modeEscrow || left <= 0 {
178 return "", 0
179 }
180 s := c.state
181 if !s.everWithdrawn {
182 return windowOpen, left
183 }
184 status, _, closedAt := effective(c)
185 if status == statusOpen || now() < closedAt+c.params.ExitDelay || k.exitClaimed || trackedBalance(c) <= 0 {
186 return "", 0
187 }
188 pool, base := s.exitPool, s.exitBase
189 if !s.exitFrozen {
190 pool, base = trackedBalance(c), s.raised-s.refunded
191 }
192 return windowExit, exitClaim(left, pool, base)
193}
194
195// exitClaim is left × pool / base, rounded down (API §3.2): computed in 128 bits, as
196// both factors fit in 63 bits but their product doesn't. A claimant's left is part of
197// the base, so the base is above 0 and the quotient at most the pool, and Div64's own
198// panics, keyless, can't be reached; both conditions are asserted first.
199func exitClaim(left, pool, base int64) int64 {
200 if base <= 0 || left > base || pool < 0 {
201 panic(ErrBooksMismatch)
202 }
203 hi, lo := bits.Mul64(uint64(left), uint64(pool))
204 quo, _ := bits.Div64(hi, lo, uint64(base))
205 return int64(quo)
206}
207
208// assertBooks panics with ErrBooksMismatch when c's books hold more than its escrow
209// does (INV-1). It runs before every payout from an escrow, on the books as they
210// stand, so a bug in the books stops a payment instead of paying out coins they
211// can't account for.
212func assertBooks(c *campaign) {
213 if banker.NewReadonlyBanker().GetCoin(escrowAddr(c.id), "ugnot") < trackedBalance(c) {
214 panic(ErrBooksMismatch)
215 }
216}
217
218// contributionOf returns donor's record in c, or nil.
219func contributionOf(c *campaign, donor address) *contribution {
220 if v := c.contributions.Get(donor.String()); v != nil {
221 return v.(*contribution)
222 }
223 return nil
224}
225
226// ContributionView is one donor's record in one campaign (API §2.4), with what Refund
227// would pay them now: a copy.
228type ContributionView struct {
229 Amount, Refunded, RefundableNow int64
230 Window string // "open", "exit", or "" for none: the window Refund would use now
231 ExitClaimed bool
232}
233
234// GetContribution returns donor's record in campaign id (API §4): zeros and no
235// window for an address that never gave, and no window for an exit claim that rounds
236// to 0, which Refund refuses. An address is compared as given (C12).
237func GetContribution(id string, donor address) ContributionView {
238 c := campaignOf(id)
239 k := contributionOf(c, donor)
240 if k == nil {
241 return ContributionView{}
242 }
243 window, pay := refundable(c, k)
244 if pay == 0 {
245 window = ""
246 }
247 return ContributionView{Amount: k.amount, Refunded: k.refunded, RefundableNow: pay, Window: window, ExitClaimed: k.exitClaimed}
248}