escrow_host.gno
6.81 Kb · 199 lines
1package memba_weighted_host
2
3import (
4 "chain"
5 "chain/runtime"
6 "gno.land/p/samcrew/memba_weighted_policy"
7)
8
9type EscrowMilestone struct {
10 ID int
11 Amount int64
12 Status string
13 FundedAt, CompletedAt, DisputedAt int64
14 PreDisputeStatus string
15}
16type EscrowContract struct {
17 Exists bool
18 ID, ContentHash, Status string
19 Client, Freelancer address
20 Count int
21 Milestones [20]EscrowMilestone
22}
23type EscrowFeeTerms struct {
24 RawBPS, EffectiveBPS int64
25 RawTreasury, FallbackTreasury, EffectiveTreasury address
26}
27// EscrowState is the escrow_v4 state a proposal freezes. The pause fields copy
28// PauseInfo except PausedBlocks, which grows every block. ExitsOpen is also
29// height-derived: it is read fresh at proposal and execution time and never
30// enters the commitment (escrowStateJSON), so a pause ageing past its blocking
31// window cannot make a frozen proposal stale.
32type EscrowState struct {
33 Owner, PendingOwner address
34 FeeRecipient, PendingFeeRecipient address
35 Paused bool
36 PausedAt, ExitsReopenAt int64
37 CooldownUntil int64
38 ExitsOpen bool
39 Contract EscrowContract
40 Fees EscrowFeeTerms
41}
42type escrowProposal struct {
43 action EscrowAction
44 before EscrowState
45}
46
47func escrowCommitment(a EscrowAction, s EscrowState) string {
48 return a.Bytes() + "|before|" + escrowStateJSON(s)
49}
50func (h *Host) validateEscrowAuthority(s EscrowState) {
51 if h.escrowPolicy == nil {
52 panic("escrow actions are not configured")
53 }
54 if !s.Owner.IsValid() || (s.PendingOwner != "" && !s.PendingOwner.IsValid()) || !s.FeeRecipient.IsValid() || (s.PendingFeeRecipient != "" && !s.PendingFeeRecipient.IsValid()) {
55 panic("invalid escrow authority pre-state")
56 }
57 if s.CooldownUntil < 0 || (!s.Paused && (s.PausedAt != 0 || s.ExitsReopenAt != 0 || !s.ExitsOpen)) || (s.Paused && (s.PausedAt <= 0 || s.ExitsReopenAt <= s.PausedAt)) {
58 panic("invalid escrow pause pre-state")
59 }
60}
61func (h *Host) validateEscrow(a EscrowAction, s EscrowState) {
62 h.validateEscrowAuthority(s)
63 a.Bytes()
64 p := h.escrowPolicy
65 if !a.IsResolution() && (s.Contract != (EscrowContract{}) || s.Fees != (EscrowFeeTerms{})) {
66 panic("unexpected escrow dispute pre-state")
67 }
68 if a.Kind() == EscrowAcceptOwner {
69 if s.Owner == p.DAO() || s.PendingOwner != p.DAO() {
70 panic("DAO is not pending escrow owner")
71 }
72 return
73 }
74 if s.Owner != p.DAO() {
75 panic("DAO is not escrow owner")
76 }
77 if a.Kind() == EscrowAbortReturn {
78 if a.Recipient() != p.Successor() || s.PendingOwner != p.Successor() {
79 panic("escrow return does not match policy")
80 }
81 return
82 }
83 if s.PendingOwner != "" {
84 panic("escrow authority transfer is pending")
85 }
86 switch a.Kind() {
87 case EscrowReturnOwner:
88 if a.Recipient() != p.Successor() {
89 panic("escrow successor does not match policy")
90 }
91 case EscrowUnpause:
92 if !s.Paused {
93 panic("escrow unpause is a no-op")
94 }
95 case EscrowSetFeeRecipient:
96 r := a.Recipient()
97 if r == p.DAO() || r == chain.PackageAddress(EscrowPath) {
98 panic("escrow fee recipient cannot be governance or escrow")
99 }
100 if r == s.FeeRecipient || r == s.PendingFeeRecipient {
101 panic("escrow fee recipient change is a no-op")
102 }
103 case EscrowRefundClient, EscrowPayFreelancer:
104 // escrow_v4 refuses ResolveDispute inside a pause's blocking window;
105 // refuse the proposal then too, at creation and again at execution.
106 if s.Paused && !s.ExitsOpen {
107 panic("escrow exits are paused")
108 }
109 c := s.Contract
110 if !c.Exists || c.ID != a.ContractID() || c.Count < 1 || c.Count > 20 || a.Milestone() >= c.Count || len(c.ContentHash) != 64 || !c.Client.IsValid() {
111 panic("invalid escrow contract pre-state")
112 }
113 for i, m := range c.Milestones {
114 if i >= c.Count {
115 if m != (EscrowMilestone{}) {
116 panic("unexpected escrow milestone pre-state")
117 }
118 continue
119 }
120 if m.Amount <= 0 {
121 panic("invalid escrow milestone amount")
122 }
123 }
124 m := c.Milestones[a.Milestone()]
125 if c.Status != "disputed" || m.Status != "disputed" || (m.PreDisputeStatus != "funded" && m.PreDisputeStatus != "completed") {
126 panic("escrow milestone is not disputed")
127 }
128 if a.Kind() == EscrowRefundClient {
129 if c.Client == p.DAO() || c.Client == chain.PackageAddress(EscrowPath) {
130 panic("escrow payout cannot be trapped in governance or escrow")
131 }
132 if s.Fees != (EscrowFeeTerms{}) {
133 panic("unexpected escrow refund fee terms")
134 }
135 return
136 }
137 if !c.Freelancer.IsValid() || s.Fees.EffectiveBPS < 0 || s.Fees.EffectiveBPS > 500 {
138 panic("invalid escrow payout terms")
139 }
140 if c.Freelancer == p.DAO() || c.Freelancer == chain.PackageAddress(EscrowPath) {
141 panic("escrow payout cannot be trapped in governance or escrow")
142 }
143 fee := (m.Amount/10000)*s.Fees.EffectiveBPS + ((m.Amount%10000)*s.Fees.EffectiveBPS)/10000
144 if fee > 0 && (s.Fees.EffectiveTreasury == p.DAO() || s.Fees.EffectiveTreasury == chain.PackageAddress(EscrowPath)) {
145 panic("escrow payout cannot be trapped in governance or escrow")
146 }
147 if fee > 0 && !s.Fees.EffectiveTreasury.IsValid() {
148 panic("invalid escrow fee recipient")
149 }
150 default:
151 panic("unsupported escrow action")
152 }
153}
154func (h *Host) ProposeEscrow(a EscrowAction, s EscrowState, rlm realm) uint64 {
155 who := assertCaller(h, rlm)
156 h.validateEscrow(a, s)
157 p := &roleProposal{proposer: who, escrow: &escrowProposal{action: a, before: s}}
158 p.ballot = h.policy.NewProposal(a.Category(), escrowCommitment(a, s))
159 h.addProposal(p)
160 return uint64(len(h.proposals))
161}
162func (h *Host) invalidateEscrowPeers(cause string, id uint64) {
163 members := []memba_weighted_policy.Member{}
164 for _, s := range h.seats {
165 members = append(members, s.Member)
166 }
167 h.policy.Reconfigure(members)
168 h.recordInvalidation(cause, id, EscrowPath)
169}
170func (h *Host) ConsumeEscrow(id uint64, s EscrowState, rlm realm) EscrowAction {
171 assertCaller(h, rlm)
172 p := h.proposal(id)
173 if p.escrow == nil {
174 panic("proposal is not an escrow action")
175 }
176 h.validateEscrow(p.escrow.action, s)
177 p.ballot.Consume(escrowCommitment(p.escrow.action, s))
178 h.invalidateEscrowPeers(InvalidatedByExecution, id)
179 return p.escrow.action
180}
181func (h *Host) PrepareEscrowEmergencyPause(s EscrowState, rlm realm) {
182 assertCaller(h, rlm)
183 h.validateEscrowAuthority(s)
184 if s.Owner != h.escrowPolicy.DAO() {
185 panic("DAO is not escrow owner")
186 }
187 if s.Paused {
188 panic("escrow is already paused")
189 }
190 // escrow_v4 refuses a Pause before its cooldown ends. Refuse here first,
191 // before invalidating any proposal.
192 if runtime.ChainHeight() < s.CooldownUntil {
193 panic("escrow pause cooldown")
194 }
195 if s.Contract != (EscrowContract{}) || s.Fees != (EscrowFeeTerms{}) {
196 panic("unexpected escrow dispute pre-state")
197 }
198 h.invalidateEscrowPeers(InvalidatedByPause, 0)
199}