Smart Contract
FRC20Votes
A.d2abb5dbf5e08666.FRC20Votes
1/**
2> Author: Fixes Lab <https://github.com/fixes-world/>
3
4# FRC20Votes
5
6This contract is used to manage the FRC20 votes.
7
8*/
9import MetadataViews from 0x1d7e57aa55817448
10import ViewResolver from 0x1d7e57aa55817448
11import NonFungibleToken from 0x1d7e57aa55817448
12import Burner from 0xf233dcee88fe0abe
13// Fixes Imports
14import Fixes from 0xd2abb5dbf5e08666
15import FixesHeartbeat from 0xd2abb5dbf5e08666
16import FRC20Indexer from 0xd2abb5dbf5e08666
17import FRC20FTShared from 0xd2abb5dbf5e08666
18import FRC20AccountsPool from 0xd2abb5dbf5e08666
19import FRC20SemiNFT from 0xd2abb5dbf5e08666
20import FRC20Staking from 0xd2abb5dbf5e08666
21import FRC20StakingManager from 0xd2abb5dbf5e08666
22import FRC20VoteCommands from 0xd2abb5dbf5e08666
23
24access(all) contract FRC20Votes {
25
26 access(all) entitlement Admin
27
28 /* --- Events --- */
29 /// Event emitted when the contract is initialized
30 access(all) event ContractInitialized()
31
32 /// Event emitted when a proposal is created
33 access(all) event ProposalCreated(
34 proposer: Address,
35 tick: String,
36 proposalId: UInt64,
37 title: String,
38 description: String,
39 discussionLink: String?,
40 executableThreshold: UFix64,
41 beginningTime: UFix64,
42 endingTime: UFix64,
43 slotCommands: [UInt8],
44 slotMessages: [String],
45 )
46 /// Event emitted when a proposal status is changed
47 access(all) event ProposalStatusChanged(
48 proposalId: UInt64,
49 tick: String,
50 prevStatus: UInt8?,
51 newStatus: UInt8,
52 timestamp: UFix64
53 )
54 /// Event emitted when a proposal is updated
55 access(all) event ProposalInfoUpdated(
56 proposalId: UInt64,
57 tick: String,
58 title: String?,
59 description: String?,
60 discussionLink: String?
61 )
62 /// Event emitted when a proposal is cancelled
63 access(all) event ProposalCancelled(
64 proposalId: UInt64,
65 tick: String
66 )
67
68 /// Event emitted when a proposal is voted
69 access(all) event ProposalVoted(
70 tick: String,
71 proposalId: UInt64,
72 voter: Address,
73 choice: Int,
74 points: UFix64,
75 )
76
77 /// Event emitted when a proposal is failed
78 access(all) event ProposalFailed(
79 tick: String,
80 proposalId: UInt64,
81 votedPoints: UFix64,
82 failedAt: UFix64
83 )
84
85 /// Event emitted when a proposal is executed
86 access(all) event ProposalExecuted(
87 tick: String,
88 proposalId: UInt64,
89 choice: Int,
90 executedAt: UFix64
91 )
92
93 /// Event emitted when the voter whitelist is updated
94 access(all) event VotesManagerWhitelistUpdated(
95 voter: Address,
96 isWhitelisted: Bool
97 )
98
99 /* --- Variable, Enums and Structs --- */
100
101 access(all)
102 let VoterStoragePath: StoragePath
103 access(all)
104 let VoterPublicPath: PublicPath
105 access(all)
106 let FRC20VotesManagerStoragePath: StoragePath
107 access(all)
108 let FRC20VotesManagerPublicPath: PublicPath
109
110 /* --- Interfaces & Resources --- */
111
112 /// The Proposal status.
113 ///
114 access(all) enum ProposalStatus: UInt8 {
115 access(all) case Created;
116 access(all) case Activated;
117 access(all) case Cancelled;
118 access(all) case Failed;
119 access(all) case Successed;
120 access(all) case Executed;
121 }
122
123 access(all) resource interface VoterPublic {
124 /// ---- Read: contract level ----
125 access(all)
126 view fun getVoterAddress(): Address
127 /// Get the voting power.
128 access(all)
129 view fun getVotingPower(): UFix64
130 /// Check whether the proposal is voted.
131 access(all)
132 view fun hasVoted(_ proposalId: UInt64): Bool
133 /// Get the voted points.
134 access(all)
135 view fun getVotedPoints(_ proposalId: UInt64): UFix64?
136 /// Get the voted choice.
137 access(all)
138 view fun getVotedChoice(_ proposalId: UInt64): Int?
139 /// Get the voted proposals.
140 access(all)
141 view fun getVotedProposals(tick: String): [UInt64]
142 /// ---- Write: contract level ----
143 access(contract)
144 fun onVote(choice: Int, proposal: &Proposal)
145 access(contract)
146 fun onProposalFinalized(proposal: &Proposal)
147 }
148
149 /// The resource of the FixesVotes voter identifier.
150 ///
151 access(all) resource VoterIdentity: VoterPublic, FRC20SemiNFT.FRC20SemiNFTCollectionPublic, NonFungibleToken.Provider, NonFungibleToken.Receiver, NonFungibleToken.CollectionPublic {
152 access(self)
153 let semiNFTColCap: Capability<auth(NonFungibleToken.Update, NonFungibleToken.Withdraw) &FRC20SemiNFT.Collection>
154 access(self)
155 let lockedSemiNFTCollection: @FRC20SemiNFT.Collection
156 // ----- Voting Info ----
157 /// Voted proposal id -> Points
158 access(self)
159 let voted: {UInt64: UFix64}
160 /// ProposalID -> Choice ID
161 access(self)
162 let votedChoices: {UInt64: Int}
163 /// Tick -> ProposalId[]
164 access(self)
165 let votedTicksMapping: {String: [UInt64]}
166 /// ProposalID -> EndedAt
167 access(self)
168 let activeProposals: {UInt64: UFix64}
169
170 init(
171 _ cap: Capability<auth(NonFungibleToken.Update, NonFungibleToken.Withdraw) &FRC20SemiNFT.Collection>
172 ) {
173 pre {
174 cap.check(): "The capability is invalid"
175 }
176 self.semiNFTColCap = cap
177 self.lockedSemiNFTCollection <- (FRC20SemiNFT.createEmptyCollection(nftType: Type<@FRC20SemiNFT.NFT>()) as! @FRC20SemiNFT.Collection)
178 self.voted = {}
179 self.votedChoices = {}
180 self.votedTicksMapping = {}
181 self.activeProposals = {}
182 }
183
184 /** ----- Implement NFT Standard ----- */
185
186 access(all)
187 fun deposit(token: @{NonFungibleToken.NFT}) {
188 self.lockedSemiNFTCollection.deposit(token: <- token)
189 }
190
191 access(NonFungibleToken.Withdraw)
192 fun withdraw(withdrawID: UInt64): @{NonFungibleToken.NFT} {
193 return <- self.lockedSemiNFTCollection.withdraw(withdrawID: withdrawID)
194 }
195
196 access(all)
197 view fun getSupportedNFTTypes(): {Type: Bool} {
198 return self.lockedSemiNFTCollection.getSupportedNFTTypes()
199 }
200
201 access(all)
202 view fun isSupportedNFTType(type: Type): Bool {
203 return self.lockedSemiNFTCollection.isSupportedNFTType(type: type)
204 }
205
206 access(all)
207 view fun getIDs(): [UInt64] {
208 return self.lockedSemiNFTCollection.getIDs()
209 }
210
211 access(all)
212 view fun getLength(): Int {
213 return self.lockedSemiNFTCollection.getLength()
214 }
215
216 access(all)
217 fun forEachID(_ f: fun (UInt64): Bool): Void {
218 self.lockedSemiNFTCollection.forEachID(f)
219 }
220
221 access(all)
222 view fun borrowNFT(_ id: UInt64): &{NonFungibleToken.NFT}? {
223 return self.lockedSemiNFTCollection.borrowNFT(id)
224 }
225
226 access(all)
227 view fun getIDsByTick(tick: String): [UInt64] {
228 return self.lockedSemiNFTCollection.getIDsByTick(tick: tick)
229 }
230
231 access(all)
232 view fun getStakedBalance(tick: String): UFix64 {
233 return self.lockedSemiNFTCollection.getStakedBalance(tick: tick)
234 }
235
236 access(all)
237 view fun borrowFRC20SemiNFTPublic(id: UInt64): &FRC20SemiNFT.NFT? {
238 return self.lockedSemiNFTCollection.borrowFRC20SemiNFTPublic(id: id)
239 }
240
241 /** ----- Read ----- */
242
243 /// Get the voter address.
244 ///
245 access(all)
246 view fun getVoterAddress(): Address {
247 return self.owner?.address ?? panic("Voter's owner is not found")
248 }
249
250 /// Get the voting power.
251 ///
252 access(all)
253 view fun getVotingPower(): UFix64 {
254 let stakeTick = FRC20FTShared.getPlatformStakingTickerName()
255 let selfAddr = self.getVoterAddress()
256
257 var power = 0.0
258 if let semiColRef = self.semiNFTColCap.borrow() {
259 // Get the staking pool address
260 power = semiColRef.getStakedBalance(tick: stakeTick)
261 }
262 return power + self.getStakedBalance(tick: stakeTick)
263 }
264
265 /// Check whether the proposal is voted.
266 ///
267 access(all)
268 view fun hasVoted(_ proposalId: UInt64): Bool {
269 return self.voted[proposalId] != nil
270 }
271
272 /// Get the voted points.
273 ///
274 access(all)
275 view fun getVotedPoints(_ proposalId: UInt64): UFix64? {
276 return self.voted[proposalId]
277 }
278
279 /// Get the voted choice.
280 ///
281 access(all)
282 view fun getVotedChoice(_ proposalId: UInt64): Int? {
283 return self.votedChoices[proposalId]
284 }
285
286 /// Get the voted proposals.
287 ///
288 access(all)
289 view fun getVotedProposals(tick: String): [UInt64] {
290 if let voted = self.votedTicksMapping[tick] {
291 return voted
292 } else {
293 return []
294 }
295 }
296
297 /** ----- Write ----- */
298
299 access(contract)
300 fun onVote(choice: Int, proposal: &Proposal) {
301 pre {
302 !self.hasVoted(proposal.uuid): "Proposal is already voted"
303 }
304 post {
305 self.hasVoted(proposal.uuid): "Proposal is not voted"
306 }
307 let details = proposal.getDetails()
308
309 // check the staked balance
310 let stakeTick = FRC20FTShared.getPlatformStakingTickerName()
311
312 let stakedNFTColRef = self.semiNFTColCap.borrow() ?? panic("The staked NFT collection is not found")
313 let unlockingStakedBalance = stakedNFTColRef.getStakedBalance(tick: stakeTick)
314 if unlockingStakedBalance > 0.0 {
315 // move the staked NFTs to the locked collection
316 let ids = stakedNFTColRef.getIDsByTick(tick: stakeTick)
317 for id in ids {
318 self.lockedSemiNFTCollection.deposit(token: <- stakedNFTColRef.withdraw(withdrawID: id))
319 }
320 }
321
322 // check staked balance
323 let votingPower = self.getVotingPower()
324 assert(
325 votingPower > 0.0,
326 message: "The voting power is zero"
327 )
328 log("Voting Power:".concat(votingPower.toString()))
329
330 // vote on the proposal
331 let lockedNFTIds = self.lockedSemiNFTCollection.getIDsByTick(tick: stakeTick)
332 log("Locked NFTs Amount:".concat(lockedNFTIds.length.toString()))
333 var voted = false
334 for id in lockedNFTIds {
335 log("Vote on the proposal, ID:".concat(id.toString()))
336 let semiNFT = self.lockedSemiNFTCollection.borrowFRC20SemiNFTPublic(id: id)
337 ?? panic("The semiNFT is not found")
338 if !proposal.isVoted(semiNFT) {
339 proposal.vote(choice: choice, semiNFT: semiNFT)
340 voted = true
341 }
342 }
343 assert(voted, message: "You have not voted on the proposal")
344
345 // update the local voting status
346 self.voted[proposal.uuid] = votingPower
347 self.votedChoices[proposal.uuid] = choice
348 if self.votedTicksMapping[details.tick] == nil {
349 self.votedTicksMapping[details.tick] = [proposal.uuid]
350 } else {
351 self.votedTicksMapping[details.tick]?.append(proposal.uuid)
352 }
353 // add the proposal to the locking queue
354 self.activeProposals[proposal.uuid] = details.endingTime
355 }
356
357 access(contract)
358 fun onProposalFinalized(proposal: &Proposal) {
359 if !proposal.isFinalized() {
360 return
361 }
362 // remove the proposal from the locking queue
363 self.activeProposals.remove(key: proposal.uuid)
364
365 // check is no more active proposals
366 if self.activeProposals.length == 0 {
367 // return all the staked NFTs to the staked collection
368 let stakeTick = FRC20FTShared.getPlatformStakingTickerName()
369 let lockedNFTIds = self.lockedSemiNFTCollection.getIDsByTick(tick: stakeTick)
370 if let semiNFTColRef = self.semiNFTColCap.borrow() {
371 for id in lockedNFTIds {
372 semiNFTColRef.deposit(token: <- self.lockedSemiNFTCollection.withdraw(withdrawID: id))
373 }
374 }
375 }
376 }
377 }
378 /// The struct of the FixesVotes proposal choice slot details.
379 ///
380 access(all) struct ChoiceSlotDetails {
381 access(all)
382 let message: String
383 access(all)
384 let command: {FRC20VoteCommands.IVoteCommand}
385
386 view init(
387 message: String,
388 command: {FRC20VoteCommands.IVoteCommand},
389 ) {
390 self.message = message
391 self.command = command
392 }
393 }
394
395 /// The struct of the FixesVotes proposal details.
396 ///
397 access(all) struct ProposalDetails {
398 access(all)
399 let proposer: Address
400 access(all)
401 let tick: String
402 access(all)
403 let slots: [ChoiceSlotDetails]
404 access(all)
405 let beginningTime: UFix64
406 access(all)
407 let endingTime: UFix64
408 access(all)
409 let executableThreshold: UFix64
410 // ------ vars ------
411 access(all)
412 var title: String
413 access(all)
414 var description: String
415 access(all)
416 var discussionLink: String?
417 access(all)
418 var isCancelled: Bool
419
420 view init(
421 proposer: Address,
422 tick: String,
423 title: String,
424 description: String,
425 discussionLink: String?,
426 executableThreshold: UFix64,
427 beginningTime: UFix64,
428 endingTime: UFix64,
429 slots: [ChoiceSlotDetails]
430 ) {
431 pre {
432 slots.length > 0: "Slots must be greater than 0"
433 executableThreshold >= 0.1 && executableThreshold <= 0.8: "Executable threshold must be between 0.1 and 0.8"
434 beginningTime < endingTime: "Beginning time must be less than ending time"
435 }
436 self.proposer = proposer
437 self.tick = tick
438 self.title = title
439 self.description = description
440 self.discussionLink = discussionLink
441 self.slots = slots
442 self.beginningTime = beginningTime
443 self.endingTime = endingTime
444 self.executableThreshold = executableThreshold
445 self.isCancelled = false
446 }
447
448 /** ----- Read ----- */
449
450 access(all)
451 view fun isStarted(): Bool {
452 return self.beginningTime <= getCurrentBlock().timestamp
453 }
454
455 /// The Proposal is ended if the endAt is not nil.
456 ///
457 access(all)
458 view fun isEnded(): Bool {
459 return self.endingTime <= getCurrentBlock().timestamp
460 }
461
462 /** ----- Write ----- */
463
464 access(contract)
465 fun updateProposal(title: String?, description: String?, discussionLink: String?) {
466 if title != nil {
467 self.title = title!
468 }
469 if description != nil {
470 self.description = description!
471 }
472 if discussionLink != nil {
473 self.discussionLink = discussionLink!
474 }
475 }
476
477 access(contract)
478 fun cancelProposal() {
479 pre {
480 self.isCancelled == false: "Proposal is already cancelled"
481 }
482 self.isCancelled = true
483 }
484 }
485
486 access(all) struct StatusLog {
487 access(all)
488 let status: ProposalStatus
489 access(all)
490 let timestamp: UFix64
491
492 view init(
493 _ status: ProposalStatus,
494 _ timestamp: UFix64
495 ) {
496 self.status = status
497 self.timestamp = timestamp
498 }
499 }
500
501 access(all) resource interface ProposalPublic {
502 // --- Read Methods ---
503 access(all)
504 view fun getProposer(): Address
505 access(all)
506 view fun isEditable(): Bool
507 access(all)
508 view fun isFinalized(): Bool
509 access(all)
510 view fun getStatus(): ProposalStatus
511 access(all)
512 view fun getDetails(): ProposalDetails
513 access(all)
514 view fun getLogs(): [StatusLog]
515 access(all)
516 view fun getVotersAmount(): Int
517 access(all)
518 view fun getVoters(): [Address]
519 access(all)
520 view fun getTotalVotedPoints(): UFix64
521 access(all)
522 view fun getVotingChoices(): {Int: UFix64}
523 access(all)
524 view fun getWinningChoice(): Int?
525 access(all)
526 view fun isValidateForThreshold(): Bool
527 access(all)
528 view fun isVotingAllowed(): Bool
529 access(all)
530 view fun isVoteCommandsExecutable(): Bool
531 access(all)
532 view fun isWinningInscriptionsExecuted(): Bool
533 access(all)
534 view fun isChoiceInscriptionsExtracted(choice: Int): Bool
535 access(all)
536 view fun isVoted(_ semiNFT: &FRC20SemiNFT.NFT): Bool
537 // --- Write Methods ---
538 access(contract)
539 fun vote(choice: Int, semiNFT: &FRC20SemiNFT.NFT)
540 }
541
542 /// The struct of the FixesVotes proposal.
543 ///
544 access(all) resource Proposal: ProposalPublic, FixesHeartbeat.IHeartbeatHook {
545 access(self)
546 let proposer: Address
547 access(self)
548 let statusLog: [StatusLog]
549 access(self)
550 let details: ProposalDetails
551 // ----- Voting status -----
552 /// Address -> Points
553 access(self)
554 var votedAccounts: {Address: UFix64}
555 /// NFTId -> Bool
556 access(self)
557 var votedNFTs: {UInt64: Bool}
558 /// Vote choice -> Points
559 access(self)
560 var votes: {Int: UFix64}
561
562 init(
563 voter: Address,
564 tick: String,
565 title: String,
566 description: String,
567 discussionLink: String?,
568 executableThreshold: UFix64,
569 beginningTime: UFix64,
570 endingTime: UFix64,
571 slots: [ChoiceSlotDetails],
572 ) {
573 self.proposer = voter
574 self.statusLog = [StatusLog(ProposalStatus.Created, getCurrentBlock().timestamp)]
575 self.details = ProposalDetails(
576 proposer: voter,
577 tick: tick,
578 title: title,
579 description: description,
580 discussionLink: discussionLink,
581 executableThreshold: executableThreshold,
582 beginningTime: beginningTime,
583 endingTime: endingTime,
584 slots: slots,
585 )
586 self.votedAccounts = {}
587 self.votedNFTs = {}
588 // init votes
589 self.votes = {}
590
591 let messages: [String] = []
592 let commands: [UInt8] = []
593 var i = 0
594 while i < slots.length {
595 self.votes[i] = 0.0
596 messages.append(slots[i].message)
597 commands.append(slots[i].command.getCommandType().rawValue)
598 i = i + 1
599 }
600
601 emit ProposalCreated(
602 proposer: voter,
603 tick: tick,
604 proposalId: self.uuid,
605 title: title,
606 description: description,
607 discussionLink: discussionLink,
608 executableThreshold: executableThreshold,
609 beginningTime: beginningTime,
610 endingTime: endingTime,
611 slotCommands: commands,
612 slotMessages: messages
613 )
614 }
615
616 /** ------ Public Methods ------ */
617
618 access(all)
619 view fun getProposer(): Address {
620 return self.proposer
621 }
622
623 access(all)
624 view fun isEditable(): Bool {
625 let status = self.getStatus()
626 return status == ProposalStatus.Created || status == ProposalStatus.Activated ||
627 (status == ProposalStatus.Successed && !self.isWinningInscriptionsExecuted())
628 }
629
630 access(all)
631 view fun isFinalized(): Bool {
632 let status = self.getStatus()
633 return status == ProposalStatus.Failed || status == ProposalStatus.Cancelled || status == ProposalStatus.Executed
634 }
635
636 /// Get current status.
637 ///
638 access(all)
639 view fun getStatus(): ProposalStatus {
640 let now = getCurrentBlock().timestamp
641 if self.details.isCancelled {
642 return ProposalStatus.Cancelled
643 } else if now < self.details.beginningTime {
644 return ProposalStatus.Created
645 } else if now < self.details.endingTime {
646 return ProposalStatus.Activated
647 } else {
648 // ended
649 if !self.isValidateForThreshold() {
650 return ProposalStatus.Failed
651 } else if !self.isWinningInscriptionsExecuted() {
652 return ProposalStatus.Successed
653 } else {
654 return ProposalStatus.Executed
655 }
656 }
657 }
658
659 access(all)
660 view fun getDetails(): ProposalDetails {
661 return self.details
662 }
663
664 access(all)
665 view fun getLogs(): [StatusLog] {
666 return self.statusLog
667 }
668
669 /// Get the voters amount.
670 ///
671 access(all)
672 view fun getVotersAmount(): Int {
673 return self.votedAccounts.length
674 }
675
676 /// Get the voters.
677 ///
678 access(all)
679 view fun getVoters(): [Address] {
680 return self.votedAccounts.keys
681 }
682
683 /// Get the total voted points.
684 ///
685 access(all)
686 view fun getTotalVotedPoints(): UFix64 {
687 var total = 0.0
688 for k in self.votes.keys {
689 total = total + self.votes[k]!
690 }
691 return total
692 }
693
694 /// Get the voting choices.
695 ///
696 access(all)
697 view fun getVotingChoices(): {Int: UFix64} {
698 return self.votes
699 }
700
701 /// Get the winning choice.
702 ///
703 access(all)
704 view fun getWinningChoice(): Int? {
705 if !self.details.isEnded() {
706 return nil
707 }
708 // get the winning choice
709 var winningChoice = 0
710 var winningVotes = 0.0
711 for k in self.votes.keys {
712 if let points = self.votes[k] {
713 if points > winningVotes {
714 winningChoice = k
715 winningVotes = points
716 }
717 }
718 }
719 return winningChoice
720 }
721
722 /// WHether the proposal is validate for the threshold.
723 ///
724 access(all)
725 view fun isValidateForThreshold(): Bool {
726 if !self.details.isEnded() {
727 return false
728 }
729 let totalStaked = FRC20Votes.getTotalStakedAmount()
730 let votedAmount = self.getTotalVotedPoints()
731 return totalStaked * self.details.executableThreshold <= votedAmount
732 }
733
734 /// Check whether the inscriptions are all executed
735 ///
736 access(all)
737 view fun isWinningInscriptionsExecuted(): Bool {
738 if !self.details.isEnded() {
739 return false
740 }
741 if let winningChoice = self.getWinningChoice() {
742 return self.isChoiceInscriptionsExtracted(choice: winningChoice)
743 }
744 return false
745 }
746
747 /// check whether the choice inscriptions are all executed
748 ///
749 access(all)
750 view fun isChoiceInscriptionsExtracted(choice: Int): Bool {
751 let slotInfoRef = self.details.slots[choice]
752 return slotInfoRef.command.isAllInscriptionsExtracted()
753 }
754
755 /// Check whether the voting is allowed.
756 ///
757 access(all)
758 view fun isVotingAllowed(): Bool {
759 return self.details.isStarted() && !self.details.isEnded()
760 }
761
762 /// Check whether the proposal is executable.
763 ///
764 access(all)
765 view fun isVoteCommandsExecutable(): Bool {
766 if self.getStatus() != ProposalStatus.Successed {
767 return false
768 }
769 var allExecutable = true
770 for i, slot in self.details.slots {
771 allExecutable = allExecutable && !self.isChoiceInscriptionsExtracted(choice: i)
772 if !allExecutable {
773 break
774 }
775 }
776 return allExecutable
777 }
778
779 /// Check whether the NFT is voted.
780 ///
781 access(all)
782 view fun isVoted(_ semiNFT: &FRC20SemiNFT.NFT): Bool {
783 return self.votedNFTs[semiNFT.id] != nil
784 }
785
786 /** ------ Write Methods: Contract ------ */
787
788 /// Vote on a proposal.
789 ///
790 access(contract)
791 fun vote(choice: Int, semiNFT: &FRC20SemiNFT.NFT) {
792 pre {
793 self.isVotingAllowed(): "Voting is not allowed for now"
794 choice < self.details.slots.length: "Choice is out of range"
795 semiNFT.getOriginalTick() == FRC20FTShared.getPlatformStakingTickerName(): "The ticker is not the staking ticker"
796 semiNFT.isStakedTick(): "The ticker is not staked"
797 semiNFT.getBalance() > 0.0: "The NFT balance is zero"
798 self.votedNFTs[semiNFT.id] == nil: "NFT is already voted"
799 }
800 post {
801 self.votes[choice]! == before(self.votes[choice]!) + semiNFT.getBalance(): "Votes are not added"
802 self.votedNFTs[semiNFT.id] == true: "NFT is not added to the votedNFTs"
803 }
804
805 let points = semiNFT.getBalance()
806 self.votes[choice] = (self.votes[choice] ?? 0.0) + points
807 // add the voter
808 let voterAddr = semiNFT.owner?.address ?? panic("Voter's owner is not found")
809 self.votedAccounts[voterAddr] = (self.votedAccounts[voterAddr] ?? 0.0) + points
810 self.votedNFTs[semiNFT.id] = true
811
812 emit ProposalVoted(
813 tick: self.details.tick,
814 proposalId: self.uuid,
815 voter: semiNFT.owner?.address ?? panic("Voter's owner is not found"),
816 choice: choice,
817 points: semiNFT.getBalance()
818 )
819 }
820
821 /// Execute the proposal without panic
822 ///
823 access(contract)
824 fun executeSafe(): Bool {
825 if self.getStatus() != ProposalStatus.Successed {
826 return false
827 }
828
829 if let winningChoice = self.getWinningChoice() {
830 for i, slot in self.details.slots {
831 var result = false
832 // execute the winning choice
833 if i == winningChoice {
834 // run the commands
835 result = slot.command.safeRunVoteCommands()
836 } else {
837 // refund the unexecuted inscriptions
838 result = slot.command.refundFailedVoteCommands(receiver: self.proposer)
839 }
840 if !result {
841 return false
842 }
843 }
844
845 // emit event
846 emit ProposalExecuted(
847 tick: self.details.tick,
848 proposalId: self.uuid,
849 choice: winningChoice,
850 executedAt: getCurrentBlock().timestamp
851 )
852 return true
853 }
854 return false
855 }
856
857 /// Execute the proposal.
858 ///
859 access(contract)
860 fun onExecute() {
861 pre {
862 self.getStatus() == ProposalStatus.Successed: "Proposal is not successed"
863 }
864 let result = self.executeSafe()
865 assert(result, message: "The proposal is not executed")
866 }
867
868 /// Refund the inscriptions cost without panic
869 ///
870 access(contract)
871 fun refundInscriptionCostSafe(): Bool {
872 let status = self.getStatus()
873 if status == ProposalStatus.Successed || status == ProposalStatus.Executed {
874 return false
875 }
876
877 for i, slot in self.details.slots {
878 // refund the unexecuted inscriptions
879 let result = slot.command.refundFailedVoteCommands(receiver: self.proposer)
880 if !result {
881 return false
882 }
883 }
884 return true
885 }
886
887 /// Refund the inscriptions cost.
888 ///
889 access(contract)
890 fun refundInscriptionCost() {
891 let result = self.refundInscriptionCostSafe()
892 assert(result, message: "The proposal cannot be not refunded")
893 }
894
895 /** ------ Internal Methods: Implement Heartbeat ------ */
896
897 /// Implement the heartbeat hook.
898 ///
899 access(account)
900 fun onHeartbeat(_ deltaTime: UFix64) {
901 let status = self.getStatus()
902 let lastIdx = self.statusLog.length - 1
903 let lastStatus = lastIdx >= 0
904 ? self.statusLog[lastIdx].status
905 : nil
906
907 if status != lastStatus {
908 let now = getCurrentBlock().timestamp
909 self.statusLog.append(StatusLog(status, now))
910
911 // emit event
912 emit ProposalStatusChanged(
913 proposalId: self.uuid,
914 tick: self.details.tick,
915 prevStatus: lastStatus?.rawValue,
916 newStatus: status.rawValue,
917 timestamp: now
918 )
919
920 // check the status and do the action
921 switch status {
922 case ProposalStatus.Failed:
923 self.refundInscriptionCostSafe()
924 // emit event
925 emit ProposalFailed(
926 tick: self.details.tick,
927 proposalId: self.uuid,
928 votedPoints: self.getTotalVotedPoints(),
929 failedAt: now
930 )
931 break
932 case ProposalStatus.Successed:
933 // Excute the inscriptions
934 self.executeSafe()
935 break
936 }
937
938 // invoke onProposalFinalized in voters
939 if self.isFinalized() {
940 let allVoters = self.getVoters()
941 for addr in allVoters {
942 if let voterRef = FRC20Votes.borrowVoterPublic(addr) {
943 voterRef.onProposalFinalized(proposal: &self as &Proposal)
944 }
945 }
946 } // end finalized
947 }
948 }
949
950 access(contract)
951 view fun borrowDetails(): &ProposalDetails {
952 return &self.details
953 }
954 }
955
956 /// The public interface of the FixesVotes manager.
957 ///
958 access(all) resource interface VotesManagerPublic {
959 /// Check whether the proposer is valid.
960 access(all)
961 view fun isValidProposer(_ voterAddr: Address): Bool
962 /** ------ Proposal Getter ------ */
963 access(all)
964 view fun getProposalLength(): Int
965 access(all)
966 view fun getProposalIds(): [UInt64]
967 access(all)
968 view fun getActiveProposalIds(): [UInt64]
969 access(all)
970 view fun getProposalIdsByTick(tick: String): [UInt64]
971 /// Borrow the proposal.
972 access(all)
973 view fun borrowProposal(_ proposalId: UInt64): &Proposal?
974 /** ------ Write Methods ------ */
975 /// Create a new proposal.
976 access(all)
977 fun createProposal(
978 voter: auth(NonFungibleToken.Withdraw) &VoterIdentity,
979 tick: String,
980 title: String,
981 description: String,
982 discussionLink: String?,
983 executableThreshold: UFix64,
984 beginningTime: UFix64,
985 endingTime: UFix64,
986 commands: [FRC20VoteCommands.CommandType],
987 messages: [String],
988 inscriptions: @[[Fixes.Inscription]]
989 )
990 /// Vote on a proposal.
991 access(all)
992 fun vote(
993 voter: auth(NonFungibleToken.Withdraw) &VoterIdentity,
994 proposalId: UInt64,
995 choice: Int,
996 )
997 // --- Write Methods: Proposer ---
998 access(all)
999 fun updateProposal(
1000 voter: auth(NonFungibleToken.Withdraw) &VoterIdentity,
1001 proposalId: UInt64,
1002 title: String?,
1003 description: String?,
1004 discussionLink: String?,
1005 )
1006 access(all)
1007 fun cancelProposal(
1008 voter: auth(NonFungibleToken.Withdraw) &VoterIdentity,
1009 proposalId: UInt64,
1010 )
1011 access(all)
1012 fun executeProposal(
1013 voter: auth(NonFungibleToken.Withdraw) &VoterIdentity,
1014 proposalId: UInt64,
1015 )
1016 }
1017
1018 /// The resource of the FixesVotes manager.
1019 ///
1020 access(all) resource VotesManager: VotesManagerPublic, FixesHeartbeat.IHeartbeatHook {
1021 /// Voter -> Bool
1022 access(self)
1023 let whitelisted: {Address: Bool}
1024 /// ProposalId -> Proposal
1025 access(self)
1026 let proposals: @{UInt64: Proposal}
1027 /// active proposal ids
1028 access(self)
1029 let activeProposalIds: [UInt64]
1030 /// Ticker -> ProposalId[]
1031 access(self)
1032 let proposalIdsByTick: {String: [UInt64]}
1033
1034 init() {
1035 self.whitelisted = {}
1036 self.proposals <- {}
1037 self.activeProposalIds = []
1038 self.proposalIdsByTick = {}
1039 }
1040
1041 /** ----- Read ----- */
1042
1043 /// Check whether the proposer is valid.
1044 ///
1045 access(all)
1046 view fun isValidProposer(_ voterAddr: Address): Bool {
1047 if let whitelist = self.whitelisted[voterAddr] {
1048 if whitelist {
1049 return true
1050 }
1051 }
1052 // check the staked amount
1053 let proposerThreshold = FRC20Votes.getProposerStakingThreshold()
1054 let totalStaked = FRC20Votes.getTotalStakedAmount()
1055 let thresholdPower = totalStaked * proposerThreshold
1056 if let voter = FRC20Votes.borrowVoterPublic(voterAddr) {
1057 // ensure the staked amount is enough
1058 return voter.getVotingPower() >= thresholdPower
1059 } else if let delegatorRef = FRC20Staking.borrowDelegator(voterAddr) {
1060 let stakeTick = FRC20FTShared.getPlatformStakingTickerName()
1061 return delegatorRef.getStakedBalance(tick: stakeTick) >= thresholdPower
1062 }
1063 return false
1064 }
1065
1066 access(all)
1067 view fun getProposalLength(): Int {
1068 return self.proposals.length
1069 }
1070
1071 access(all)
1072 view fun getProposalIds(): [UInt64] {
1073 return self.proposals.keys
1074 }
1075
1076 access(all)
1077 view fun getActiveProposalIds(): [UInt64] {
1078 return self.activeProposalIds
1079 }
1080
1081 access(all)
1082 view fun getProposalIdsByTick(tick: String): [UInt64] {
1083 return self.proposalIdsByTick[tick] ?? []
1084 }
1085
1086 access(all)
1087 view fun borrowProposal(_ proposalId: UInt64): &Proposal? {
1088 return self.borrowProposalRef(proposalId)
1089 }
1090
1091 /** ----- Write ----- */
1092
1093 /// Create a new proposal.
1094 ///
1095 access(all)
1096 fun createProposal(
1097 voter: auth(NonFungibleToken.Withdraw) &VoterIdentity,
1098 tick: String,
1099 title: String,
1100 description: String,
1101 discussionLink: String?,
1102 executableThreshold: UFix64,
1103 beginningTime: UFix64,
1104 endingTime: UFix64,
1105 commands: [FRC20VoteCommands.CommandType],
1106 messages: [String],
1107 inscriptions: @[[Fixes.Inscription]]
1108 ) {
1109 pre {
1110 beginningTime < endingTime: "Beginning time must be less than ending time"
1111 messages.length > 0: "Messages must be greater than 0"
1112 messages.length == inscriptions.length: "Messages and inscriptions must be the same length"
1113 messages.length == commands.length: "Messages and commands must be the same length"
1114 }
1115 let voterAddr = voter.owner?.address ?? panic("Voter's owner is not found")
1116 assert(
1117 self.isValidProposer(voterAddr),
1118 message: "The staked amount is not enough"
1119 )
1120
1121 // Save the inscriptions
1122 let inscriptionsStore = FRC20Votes.borrowSystemInscriptionsStore()
1123 let slots: [ChoiceSlotDetails] = []
1124
1125 let slotsLen = inscriptions.length
1126 var i = 0
1127 while i < slotsLen {
1128 let insList <- inscriptions.removeFirst()
1129 let insIds: [UInt64] = []
1130
1131 let insListLen = insList.length
1132 var j = 0
1133 while j < insListLen {
1134 let ins <- insList.removeFirst()
1135 insIds.append(ins.getId())
1136 inscriptionsStore.store(<- ins)
1137 j = j + 1
1138 }
1139 Burner.burn(<- insList)
1140
1141 slots.append(ChoiceSlotDetails(
1142 message: messages[i],
1143 command: FRC20VoteCommands.createByCommandType(commands[i], insIds),
1144 ))
1145 i = i + 1
1146 }
1147 Burner.burn(<- inscriptions)
1148
1149 let proposal <- create Proposal(
1150 voter: voterAddr,
1151 tick: tick,
1152 title: title,
1153 description: description,
1154 discussionLink: discussionLink,
1155 executableThreshold: executableThreshold,
1156 beginningTime: beginningTime,
1157 endingTime: endingTime,
1158 slots: slots
1159 )
1160 let proposalId = proposal.uuid
1161 self.proposals[proposalId] <-! proposal
1162 // update proposalIds by tick
1163 if self.proposalIdsByTick[tick] == nil {
1164 self.proposalIdsByTick[tick] = [proposalId]
1165 } else {
1166 self.proposalIdsByTick[tick]?.append(proposalId)
1167 }
1168 // insert activeProposalIds
1169 self.activeProposalIds.insert(at: 0, proposalId)
1170 }
1171
1172 access(all)
1173 fun vote(
1174 voter: auth(NonFungibleToken.Withdraw) &VoterIdentity,
1175 proposalId: UInt64,
1176 choice: Int,
1177 ) {
1178 let proposalRef = self.borrowProposal(proposalId)
1179 ?? panic("The proposal is not found")
1180 assert(
1181 proposalRef.isVotingAllowed(),
1182 message: "Voting is not allowed for now"
1183 )
1184 voter.onVote(choice: choice, proposal: proposalRef)
1185 }
1186
1187 /** ------ Write Methods: Proposer ------- */
1188
1189 /// Update the proposal.
1190 ///
1191 access(all)
1192 fun updateProposal(
1193 voter: auth(NonFungibleToken.Withdraw) &VoterIdentity,
1194 proposalId: UInt64,
1195 title: String?,
1196 description: String?,
1197 discussionLink: String?
1198 ) {
1199 let proposalRef = self.borrowProposalRef(proposalId)
1200 ?? panic("The proposal is not found")
1201 let detailsRef = proposalRef.borrowDetails()
1202 assert(
1203 detailsRef.proposer == voter.owner?.address,
1204 message: "The voter is not the proposer"
1205 )
1206 assert(
1207 proposalRef.isEditable(),
1208 message: "The proposal is not editable"
1209 )
1210 detailsRef.updateProposal(title: title, description: description, discussionLink: discussionLink)
1211
1212 emit ProposalInfoUpdated(
1213 proposalId: proposalRef.uuid,
1214 tick: detailsRef.tick,
1215 title: title,
1216 description: description,
1217 discussionLink: discussionLink
1218 )
1219 }
1220
1221 /// Cancel the proposal.
1222 ///
1223 access(all)
1224 fun cancelProposal(
1225 voter: auth(NonFungibleToken.Withdraw) &VoterIdentity,
1226 proposalId: UInt64
1227 ) {
1228 let proposalRef = self.borrowProposalRef(proposalId)
1229 ?? panic("The proposal is not found")
1230 let detailsRef = proposalRef.borrowDetails()
1231 assert(
1232 detailsRef.proposer == voter.owner?.address,
1233 message: "The voter is not the proposer"
1234 )
1235 assert(
1236 proposalRef.isEditable(),
1237 message: "The proposal is not editable"
1238 )
1239 detailsRef.cancelProposal()
1240 // refund the inscriptions
1241 proposalRef.refundInscriptionCost()
1242
1243 emit ProposalCancelled(
1244 proposalId: self.uuid,
1245 tick: detailsRef.tick
1246 )
1247 }
1248
1249 /// Manual execute the proposal.
1250 ///
1251 access(all)
1252 fun executeProposal(
1253 voter: auth(NonFungibleToken.Withdraw) &VoterIdentity,
1254 proposalId: UInt64
1255 ) {
1256 let proposalRef = self.borrowProposalRef(proposalId)
1257 ?? panic("The proposal is not found")
1258 let detailsRef = proposalRef.borrowDetails()
1259 assert(
1260 detailsRef.proposer == voter.owner?.address,
1261 message: "The voter is not the proposer"
1262 )
1263 let status = proposalRef.getStatus()
1264 assert(
1265 status == ProposalStatus.Successed,
1266 message: "The proposal is not successed"
1267 )
1268 proposalRef.onExecute()
1269 }
1270
1271 /** ----- Write: Private ----- */
1272
1273 access(Admin)
1274 fun updateWhitelist(voter: Address, isWhitelisted: Bool) {
1275 self.whitelisted[voter] = isWhitelisted
1276 emit VotesManagerWhitelistUpdated(voter: voter, isWhitelisted: isWhitelisted)
1277 }
1278
1279 access(Admin)
1280 fun forceHeartbeat() {
1281 let proposalIds = self.proposals.keys
1282 for proposalId in proposalIds {
1283 if let proposal = self.borrowProposalRef(proposalId) {
1284 proposal.onHeartbeat(0.0)
1285 }
1286 }
1287 }
1288
1289 /** ------ Internal Methods ------ */
1290
1291 access(account)
1292 fun onHeartbeat(_ deltaTime: UFix64) {
1293 // update the active proposal ids
1294 var i = 0
1295 while i < self.activeProposalIds.length {
1296 let proposalId = self.activeProposalIds.removeFirst()
1297 if let proposal = self.borrowProposalRef(proposalId) {
1298 // call the proposal heartbeat
1299 proposal.onHeartbeat(deltaTime)
1300
1301 // check if finalized
1302 if !proposal.isFinalized() {
1303 // re-insert the proposal id
1304 self.activeProposalIds.append(proposalId)
1305 }
1306 }
1307 i = i + 1
1308 }
1309 }
1310
1311 /** ----- Internal ----- */
1312
1313 access(self)
1314 view fun borrowProposalRef(_ proposalId: UInt64): &Proposal? {
1315 return &self.proposals[proposalId]
1316 }
1317 }
1318
1319 /// Borrow the system inscriptions store.
1320 ///
1321 access(contract)
1322 view fun borrowSystemInscriptionsStore(): auth(Fixes.Manage) &Fixes.InscriptionsStore {
1323 let storePubPath = Fixes.getFixesStoreStoragePath()
1324 return self.account.storage
1325 .borrow<auth(Fixes.Manage) &Fixes.InscriptionsStore>(from: storePubPath)
1326 ?? panic("Fixes.InscriptionsStore is not found")
1327 }
1328
1329 /* --- Public Functions --- */
1330
1331 access(all)
1332 fun createVoter(
1333 _ cap: Capability<auth(NonFungibleToken.Update, NonFungibleToken.Withdraw) &FRC20SemiNFT.Collection>
1334 ): @VoterIdentity {
1335 return <- create VoterIdentity(cap)
1336 }
1337
1338 /// Get the proposer staking threshold.
1339 ///
1340 access(all)
1341 view fun getProposerStakingThreshold(): UFix64 {
1342 return 0.15
1343 }
1344
1345 /// Get the total staked amount.
1346 ///
1347 access(all)
1348 view fun getTotalStakedAmount(): UFix64 {
1349 let pool = FRC20StakingManager.borrowPlatformStakingPool()
1350 return pool.getDetails().totalStaked
1351 }
1352
1353 /// Borrow the voter resource.
1354 ///
1355 access(all)
1356 view fun borrowVoterPublic(_ addr: Address): &VoterIdentity? {
1357 return getAccount(addr)
1358 .capabilities.get<&VoterIdentity>(self.VoterPublicPath)
1359 .borrow()
1360 }
1361
1362 /// Borrow the VotesManager resource.
1363 ///
1364 access(all)
1365 view fun borrowVotesManager(): &VotesManager {
1366 return self.account
1367 .capabilities.get<&VotesManager>(self.FRC20VotesManagerPublicPath)
1368 .borrow() ?? panic("VotesManager is not found")
1369 }
1370
1371 init() {
1372 let votesIdentifier = "FRC20VotesManager_".concat(self.account.address.toString())
1373 self.FRC20VotesManagerStoragePath = StoragePath(identifier: votesIdentifier)!
1374 self.FRC20VotesManagerPublicPath = PublicPath(identifier: votesIdentifier)!
1375
1376 // create the resource
1377 self.account.storage.save(<- create VotesManager(), to: self.FRC20VotesManagerStoragePath)
1378 self.account.capabilities.publish(
1379 self.account.capabilities.storage.issue<&VotesManager>(self.FRC20VotesManagerStoragePath),
1380 at: self.FRC20VotesManagerPublicPath
1381 )
1382
1383 // Register to FixesHeartbeat
1384 let heartbeatScope = "FRC20Votes"
1385 let accountAddr = self.account.address
1386 if !FixesHeartbeat.hasHook(scope: heartbeatScope, hookAddr: accountAddr) {
1387 FixesHeartbeat.addHook(
1388 scope: heartbeatScope,
1389 hookAddr: accountAddr,
1390 hookPath: self.FRC20VotesManagerPublicPath
1391 )
1392 }
1393
1394 // Ensure InscriptionsStore resource
1395 let insStoreStoragePath = Fixes.getFixesStoreStoragePath()
1396 if self.account.storage.borrow<&AnyResource>(from: insStoreStoragePath) == nil {
1397 self.account.storage.save<@Fixes.InscriptionsStore>(<- Fixes.createInscriptionsStore(), to: insStoreStoragePath)
1398 }
1399 let insStorePubPath = Fixes.getFixesStorePublicPath()
1400
1401 if !self.account
1402 .capabilities.get<&Fixes.InscriptionsStore>(insStorePubPath)
1403 .check() {
1404 self.account.capabilities.unpublish(insStorePubPath)
1405 self.account.capabilities.publish(
1406 self.account.capabilities.storage.issue<&Fixes.InscriptionsStore>(insStoreStoragePath),
1407 at: insStorePubPath
1408 )
1409 }
1410
1411 let voterIdentifier = "FRC20Voter_".concat(self.account.address.toString())
1412 self.VoterStoragePath = StoragePath(identifier: voterIdentifier)!
1413 self.VoterPublicPath = PublicPath(identifier: voterIdentifier)!
1414
1415 emit ContractInitialized()
1416 }
1417}
1418