diff --git a/Cargo.lock b/Cargo.lock index 232c102310..8f24c1d188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,7 +131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d0864d84b8e07b145449be9a8537db86bf9de5ce03b913214694643b4743502" dependencies = [ "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -159,9 +159,9 @@ checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" [[package]] name = "backtrace" -version = "0.3.45" +version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad235dabf00f36301792cfe82499880ba54c6486be094d1047b02bacb67c14e8" +checksum = "b1e692897359247cc6bb902933361652380af0f1b7651ae5c5013407f30e109e" dependencies = [ "backtrace-sys", "cfg-if", @@ -171,9 +171,9 @@ dependencies = [ [[package]] name = "backtrace-sys" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca797db0057bae1a7aa2eef3283a874695455cecf08a43bfb8507ee0ebc1ed69" +checksum = "7de8aba10a69c8e8d7622c5710229485ec32e9d55fdad160ea559c086fdcd118" dependencies = [ "cc", "libc", @@ -230,9 +230,13 @@ checksum = "5da9b3d9f6f585199287a473f4f8dfab6566cf827d15c00c219f53c645687ead" [[package]] name = "bitvec" -version = "0.15.2" +version = "0.17.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993f74b4c99c1908d156b8d2e0fb6277736b0ecbd833982fd1241d39b2766a6" +checksum = "41262f11d771fd4a61aa3ce019fca363b4b6c282fca9da2a31186d3965a47a5c" +dependencies = [ + "either", + "radium", +] [[package]] name = "blake2" @@ -309,9 +313,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.2.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f359dc14ff8911330a51ef78022d376f25ed00248912803b58f00cb1c27f742" +checksum = "12ae9db68ad7fac5fe51304d20f016c911539251075a214f8e663babefa35187" [[package]] name = "byte-slice-cast" @@ -459,7 +463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f1af9ac737b2dd2d577701e59fd09ba34822f6f2ebdb30a7647405d9e55e16a" dependencies = [ "const-random-macro", - "proc-macro-hack 0.5.12", + "proc-macro-hack 0.5.15", ] [[package]] @@ -469,7 +473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25e4c606eb459dd29f7c57b2e0879f2b6f14ee130918c2b78ccb58a9624e6c7a" dependencies = [ "getrandom", - "proc-macro-hack 0.5.12", + "proc-macro-hack 0.5.15", ] [[package]] @@ -640,6 +644,17 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11c0346158a19b3627234e15596f5e465c360fcdb97d817bcb255e0510f5a788" +[[package]] +name = "derivative" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eae4d76b7cefedd1b4f8cc24378b2fbd1ac1b66e3bbebe8e2192d3be81cb355" +dependencies = [ + "proc-macro2 1.0.10", + "quote 1.0.3", + "syn 1.0.17", +] + [[package]] name = "derive_more" version = "0.14.1" @@ -776,9 +791,9 @@ checksum = "516aa8d7a71cb00a1c4146f0798549b93d083d4f189b3ced8f3de6b8f11ee6c4" [[package]] name = "erased-serde" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd7d80305c9bd8cd78e3c753eb9fb110f83621e5211f1a3afffcc812b104daf9" +checksum = "d88b6d1705e16a4d62e05ea61cc0496c2bd190f4fa8e5c1f11ce747be6bcf3d1" dependencies = [ "serde", ] @@ -809,9 +824,9 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "030a733c8287d6213886dd487564ff5c8f6aae10278b3588ed177f9d18f8d231" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", "synstructure", ] @@ -1045,10 +1060,10 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a5081aa3de1f7542a794a397cde100ed903b0630152d0973479018fd85423a7" dependencies = [ - "proc-macro-hack 0.5.12", - "proc-macro2 1.0.9", + "proc-macro-hack 0.5.15", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -1108,7 +1123,7 @@ dependencies = [ "futures-task", "memchr", "pin-utils", - "proc-macro-hack 0.5.12", + "proc-macro-hack 0.5.15", "proc-macro-nested", "slab", ] @@ -1269,9 +1284,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1010591b26bbfe835e9faeabeb11866061cc7dcebffd56ad7d0942d0e61aefd8" +checksum = "725cf19794cf90aa94e65050cb4191ff5d8fa87a498383774c47b332e3af952e" dependencies = [ "libc", ] @@ -1305,7 +1320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "961de220ec9a91af2e1e5bd80d02109155695e516771762381ef8581317066e0" dependencies = [ "hex-literal-impl 0.2.1", - "proc-macro-hack 0.5.12", + "proc-macro-hack 0.5.15", ] [[package]] @@ -1323,7 +1338,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d4c5c844e2fee0bf673d54c2c177f1713b3d2af2ff6e666b49cb7572e6cf42d" dependencies = [ - "proc-macro-hack 0.5.12", + "proc-macro-hack 0.5.15", ] [[package]] @@ -1487,9 +1502,9 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef5550a42e3740a0e71f909d4c861056a284060af885ae7aa6242820f920d9d" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -1641,6 +1656,9 @@ dependencies = [ "substrate-memo-module", "substrate-offchain-primitives", "substrate-primitives", + "substrate-proposals-codex-module", + "substrate-proposals-discussion-module", + "substrate-proposals-engine-module", "substrate-recurring-reward-module", "substrate-roles-module", "substrate-service-discovery-module", @@ -1655,9 +1673,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.36" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb931d43e71f560c81badb0191596562bafad2be06a3f9025b845c847c60df5" +checksum = "6a27d435371a2fa5b6d2b028a74bbdb1234f308da363226a2854ca3ff8ba7055" dependencies = [ "wasm-bindgen", ] @@ -1720,9 +1738,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8609af8f63b626e8e211f52441fcdb6ec54f1a446606b10d5c89ae9bf8a20058" dependencies = [ "proc-macro-crate", - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -2357,8 +2375,8 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e37c5d4cd9473c5f4c9c111f033f15d4df9bd378fdf615944e360a4f55a05f0b" dependencies = [ - "proc-macro2 1.0.9", - "syn 1.0.16", + "proc-macro2 1.0.10", + "syn 1.0.17", "synstructure", ] @@ -2504,9 +2522,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a615a1ad92048ad5d9633251edb7492b8abc057d7a679a9898476aef173935" dependencies = [ "cfg-if", - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -2642,6 +2660,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca565a7df06f3d4b485494f25ba05da1435950f4dc263440eda7a6fa9b8e36e4" +dependencies = [ + "derivative", + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa5a33ddddfee04c0283a7653987d634e880347e96b5b2ed64de07efb59db9d" +dependencies = [ + "proc-macro-crate", + "proc-macro2 1.0.10", + "quote 1.0.3", + "syn 1.0.17", +] + [[package]] name = "ole32-sys" version = "0.2.0" @@ -2755,9 +2795,9 @@ dependencies = [ [[package]] name = "parity-scale-codec" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f509c5e67ca0605ee17dcd3f91ef41cadd685c75a298fb6261b781a5acb3f910" +checksum = "329c8f7f4244ddb5c37c103641027a76c530e65e8e4b8240b29f81ea40508b17" dependencies = [ "arrayvec 0.5.1", "bitvec", @@ -2773,9 +2813,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a0ec292e92e8ec7c58e576adacc1e3f399c597c8f263c42f18420abe58e7245" dependencies = [ "proc-macro-crate", - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -2926,24 +2966,24 @@ dependencies = [ [[package]] name = "paste" -version = "0.1.7" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e1afe738d71b1ebab5f1207c055054015427dbfc7bbe9ee1266894156ec046" +checksum = "ab4fb1930692d1b6a9cfabdde3d06ea0a7d186518e2f4d67660d8970e2fa647a" dependencies = [ "paste-impl", - "proc-macro-hack 0.5.12", + "proc-macro-hack 0.5.15", ] [[package]] name = "paste-impl" -version = "0.1.7" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d4dc4a7f6f743211c5aab239640a65091535d97d43d92a52bca435a640892bb" +checksum = "a62486e111e571b1e93b710b61e8f493c0013be39629b714cb166bdb06aa5a8a" dependencies = [ - "proc-macro-hack 0.5.12", - "proc-macro2 1.0.9", + "proc-macro-hack 0.5.15", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -3063,9 +3103,9 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeccfe4d5d8ea175d5f0e4a2ad0637e0f4121d63bd99d356fb1f39ab2e7c6097" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -3079,14 +3119,9 @@ dependencies = [ [[package]] name = "proc-macro-hack" -version = "0.5.12" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f918f2b601f93baa836c1c2945faef682ba5b6d4828ecb45eeb7cc3c71b811b4" -dependencies = [ - "proc-macro2 1.0.9", - "quote 1.0.3", - "syn 1.0.16", -] +checksum = "0d659fe7c6d27f25e9d80a1a094c223f5246f6a6596453e09d7229bf42750b63" [[package]] name = "proc-macro-hack-impl" @@ -3111,9 +3146,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c09721c6781493a2a492a96b5a5bf19b65917fe6728884e7c44dd0c60ca3435" +checksum = "df246d292ff63439fea9bc8c0a270bed0e390d5ebd4db4ba15aba81111b5abe3" dependencies = [ "unicode-xid 0.2.0", ] @@ -3197,9 +3232,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bdc6c187c65bca4260c9011c9e3132efe4909da44726bad24cf7572ae338d7f" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", ] +[[package]] +name = "radium" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def50a86306165861203e7f84ecffbbdfdea79f0e51039b33de1e952358c47ac" + [[package]] name = "rand" version = "0.3.23" @@ -3424,9 +3465,9 @@ checksum = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84" [[package]] name = "regex" -version = "1.3.5" +version = "1.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8900ebc1363efa7ea1c399ccc32daed870b4002651e0bed86e72d501ebbe0048" +checksum = "7f6946991529684867e47d86474e3a6d0c0ab9b82d5821e314b1ede31fa3a4b3" dependencies = [ "aho-corasick", "memchr", @@ -3451,9 +3492,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.16.11" +version = "0.16.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "741ba1704ae21999c00942f9f5944f801e977f54302af346b596287599ad1862" +checksum = "1ba5a8ec64ee89a76c98c549af81ff14813df09c3e6dc4766c3856da48597a0c" dependencies = [ "cc", "lazy_static", @@ -3606,29 +3647,29 @@ checksum = "a0eddf2e8f50ced781f288c19f18621fa72a3779e3cb58dbf23b07469b0abeb4" [[package]] name = "serde" -version = "1.0.104" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "414115f25f818d7dfccec8ee535d76949ae78584fc4f79a6f45a904bf8ab4449" +checksum = "36df6ac6412072f67cf767ebbde4133a5b2e88e76dc6187fa7104cd16f783399" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.104" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128f9e303a5a29922045a830221b8f78ec74a5f544944f3d5984f8ec3895ef64" +checksum = "9e549e3abf4fb8621bd1609f11dfc9f5e50320802273b12f3811a67e6716ea6c" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] name = "serde_json" -version = "1.0.48" +version = "1.0.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9371ade75d4c2d6cb154141b9752cf3781ec9c05e0e5cf35060e1e70ee7b9c25" +checksum = "da07b57ee2623368351e9a0488bb0b261322a15a6e0ae53e243cbdc0f4208da9" dependencies = [ "itoa", "ryu", @@ -3706,7 +3747,7 @@ dependencies = [ [[package]] name = "slog-async" version = "2.3.0" -source = "git+https://github.com/paritytech/slog-async#107848e7ded5e80dc43f6296c2b96039eb92c0a5" +source = "git+https://github.com/paritytech/slog-async#0329dc74feb3afe93d0cd2533a472b7ceab44aaf" dependencies = [ "crossbeam-channel", "slog", @@ -3810,9 +3851,9 @@ source = "git+https://github.com/paritytech/substrate.git?rev=c37bb08535c49a1232 dependencies = [ "blake2-rfc", "proc-macro-crate", - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -4129,9 +4170,9 @@ version = "2.0.0" source = "git+https://github.com/paritytech/substrate.git?rev=c37bb08535c49a12320af7facfd555ce05cce2e8#c37bb08535c49a12320af7facfd555ce05cce2e8" dependencies = [ "proc-macro-crate", - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -4174,11 +4215,11 @@ name = "srml-support-procedural" version = "2.0.0" source = "git+https://github.com/paritytech/substrate.git?rev=c37bb08535c49a12320af7facfd555ce05cce2e8#c37bb08535c49a12320af7facfd555ce05cce2e8" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", "sr-api-macros", "srml-support-procedural-tools", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -4187,10 +4228,10 @@ version = "2.0.0" source = "git+https://github.com/paritytech/substrate.git?rev=c37bb08535c49a12320af7facfd555ce05cce2e8#c37bb08535c49a12320af7facfd555ce05cce2e8" dependencies = [ "proc-macro-crate", - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", "srml-support-procedural-tools-derive", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -4198,9 +4239,9 @@ name = "srml-support-procedural-tools-derive" version = "2.0.0" source = "git+https://github.com/paritytech/substrate.git?rev=c37bb08535c49a12320af7facfd555ce05cce2e8#c37bb08535c49a12320af7facfd555ce05cce2e8" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -4323,9 +4364,9 @@ checksum = "ea692d40005b3ceba90a9fe7a78fa8d4b82b0ce627eebbffc329aab850f3410e" dependencies = [ "heck", "proc-macro-error", - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -4441,9 +4482,9 @@ version = "2.0.0" source = "git+https://github.com/paritytech/substrate.git?rev=c37bb08535c49a12320af7facfd555ce05cce2e8#c37bb08535c49a12320af7facfd555ce05cce2e8" dependencies = [ "proc-macro-crate", - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -4678,9 +4719,9 @@ name = "substrate-debug-derive" version = "2.0.0" source = "git+https://github.com/paritytech/substrate.git?rev=c37bb08535c49a12320af7facfd555ce05cce2e8#c37bb08535c49a12320af7facfd555ce05cce2e8" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", ] [[package]] @@ -4871,7 +4912,7 @@ dependencies = [ [[package]] name = "substrate-membership-module" -version = "1.0.0" +version = "1.0.1" dependencies = [ "parity-scale-codec", "serde", @@ -5058,6 +5099,79 @@ dependencies = [ "substrate-debug-derive", ] +[[package]] +name = "substrate-proposals-codex-module" +version = "2.0.0" +dependencies = [ + "num_enum", + "parity-scale-codec", + "serde", + "sr-io", + "sr-primitives", + "sr-staking-primitives", + "sr-std", + "srml-balances", + "srml-staking", + "srml-staking-reward-curve", + "srml-support", + "srml-system", + "srml-timestamp", + "substrate-common-module", + "substrate-content-working-group-module", + "substrate-governance-module", + "substrate-hiring-module", + "substrate-membership-module", + "substrate-primitives", + "substrate-proposals-discussion-module", + "substrate-proposals-engine-module", + "substrate-recurring-reward-module", + "substrate-roles-module", + "substrate-stake-module", + "substrate-token-mint-module", + "substrate-versioned-store", + "substrate-versioned-store-permissions-module", +] + +[[package]] +name = "substrate-proposals-discussion-module" +version = "2.0.0" +dependencies = [ + "num_enum", + "parity-scale-codec", + "serde", + "sr-io", + "sr-primitives", + "sr-std", + "srml-balances", + "srml-support", + "srml-system", + "srml-timestamp", + "substrate-common-module", + "substrate-membership-module", + "substrate-primitives", +] + +[[package]] +name = "substrate-proposals-engine-module" +version = "2.0.0" +dependencies = [ + "mockall", + "num_enum", + "parity-scale-codec", + "serde", + "sr-io", + "sr-primitives", + "sr-std", + "srml-balances", + "srml-support", + "srml-system", + "srml-timestamp", + "substrate-common-module", + "substrate-membership-module", + "substrate-primitives", + "substrate-stake-module", +] + [[package]] name = "substrate-recurring-reward-module" version = "1.0.1" @@ -5081,7 +5195,7 @@ dependencies = [ [[package]] name = "substrate-roles-module" -version = "1.0.0" +version = "1.0.1" dependencies = [ "parity-scale-codec", "serde", @@ -5478,11 +5592,11 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "123bd9499cfb380418d509322d7a6d52e5315f064fe4b3ad18a53d6b92c07859" +checksum = "0df0eb663f387145cab623dea85b09c2c5b4b0aef44e945d928e682fce71bb03" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", "unicode-xid 0.2.0", ] @@ -5493,9 +5607,9 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67656ea1dc1b41b1451851562ea232ec2e5a80242139f7e679ceccfb5d61f545" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", "unicode-xid 0.2.0", ] @@ -6072,9 +6186,9 @@ checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" [[package]] name = "wasm-bindgen" -version = "0.2.59" +version = "0.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3557c397ab5a8e347d434782bcd31fc1483d927a6826804cec05cc792ee2519d" +checksum = "2cc57ce05287f8376e998cbddfb4c8cb43b84a7ec55cf4551d7c00eef317a47f" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -6082,16 +6196,16 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.59" +version = "0.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0da9c9a19850d3af6df1cb9574970b566d617ecfaf36eb0b706b6f3ef9bd2f8" +checksum = "d967d37bf6c16cca2973ca3af071d0a2523392e4a594548155d89a678f4237cd" dependencies = [ "bumpalo", "lazy_static", "log", - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", "wasm-bindgen-shared", ] @@ -6110,9 +6224,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.59" +version = "0.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f6fde1d36e75a714b5fe0cffbb78978f222ea6baebb726af13c78869fdb4205" +checksum = "8bd151b63e1ea881bb742cd20e1d6127cef28399558f3b5d415289bc41eee3a4" dependencies = [ "quote 1.0.3", "wasm-bindgen-macro-support", @@ -6120,22 +6234,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.59" +version = "0.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25bda4168030a6412ea8a047e27238cadf56f0e53516e1e83fec0a8b7c786f6d" +checksum = "d68a5b36eef1be7868f668632863292e37739656a80fc4b9acec7b0bd35a4931" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.59" +version = "0.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc9f36ad51f25b0219a3d4d13b90eb44cd075dff8b6280cca015775d7acaddd8" +checksum = "daf76fe7d25ac79748a37538b7daeed1c7a6867c92d3245c12c6222e4a20d639" [[package]] name = "wasm-timer" @@ -6176,9 +6290,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.36" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721c6263e2c66fd44501cc5efbfa2b7dfa775d13e4ea38c46299646ed1f9c70a" +checksum = "2d6f51648d8c56c366144378a33290049eafdd784071077f6fe37dae64c1c4cb" dependencies = [ "js-sys", "wasm-bindgen", @@ -6252,9 +6366,9 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ccfbf554c6ad11084fb7517daca16cfdcaccbdadba4fc336f032a8b12c2ad80" +checksum = "fa515c5163a99cc82bab70fd3bfdd36d827be85de63737b40fcef2ce084a436e" dependencies = [ "winapi 0.3.8", ] @@ -6354,8 +6468,8 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de251eec69fc7c1bc3923403d18ececb929380e016afe103da75f396704f8ca2" dependencies = [ - "proc-macro2 1.0.9", + "proc-macro2 1.0.10", "quote 1.0.3", - "syn 1.0.16", + "syn 1.0.17", "synstructure", ] diff --git a/Cargo.toml b/Cargo.toml index f2195200f3..9651f9333e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,9 @@ [workspace] members = [ "runtime", + "runtime-modules/proposals/engine", + "runtime-modules/proposals/codex", + "runtime-modules/proposals/discussion", "runtime-modules/common", "runtime-modules/content-working-group", "runtime-modules/forum", diff --git a/node/src/chain_spec.rs b/node/src/chain_spec.rs index 99271c8c83..c11a033d4c 100644 --- a/node/src/chain_spec.rs +++ b/node/src/chain_spec.rs @@ -19,8 +19,8 @@ use node_runtime::{ AuthorityDiscoveryConfig, BabeConfig, Balance, BalancesConfig, ContentWorkingGroupConfig, CouncilConfig, CouncilElectionConfig, DataObjectStorageRegistryConfig, DataObjectTypeRegistryConfig, ElectionParameters, GrandpaConfig, ImOnlineConfig, IndicesConfig, - MembersConfig, Perbill, ProposalsConfig, SessionConfig, SessionKeys, Signature, StakerStatus, - StakingConfig, SudoConfig, SystemConfig, VersionedStoreConfig, DAYS, WASM_BINARY, + MembersConfig, Perbill, SessionConfig, SessionKeys, Signature, StakerStatus, StakingConfig, + SudoConfig, SystemConfig, VersionedStoreConfig, DAYS, WASM_BINARY, }; pub use node_runtime::{AccountId, GenesisConfig}; use primitives::{sr25519, Pair, Public}; @@ -246,16 +246,6 @@ pub fn testnet_genesis( min_voting_stake: 1 * DOLLARS, }, }), - proposals: Some(ProposalsConfig { - approval_quorum: 66, - min_stake: 2 * DOLLARS, - cancellation_fee: 10 * CENTS, - rejection_fee: 1 * DOLLARS, - voting_period: 2 * DAYS, - name_max_len: 512, - description_max_len: 10_000, - wasm_code_max_len: 2_000_000, - }), members: Some(MembersConfig { default_paid_membership_fee: 100u128, members: crate::members_config::initial_members(), diff --git a/runtime-modules/common/src/lib.rs b/runtime-modules/common/src/lib.rs index 23177ac457..e48bf36060 100644 --- a/runtime-modules/common/src/lib.rs +++ b/runtime-modules/common/src/lib.rs @@ -2,3 +2,4 @@ #![cfg_attr(not(feature = "std"), no_std)] pub mod currency; +pub mod origin_validator; diff --git a/runtime-modules/common/src/origin_validator.rs b/runtime-modules/common/src/origin_validator.rs new file mode 100644 index 0000000000..336331dda1 --- /dev/null +++ b/runtime-modules/common/src/origin_validator.rs @@ -0,0 +1,5 @@ +/// Abstract validator for the origin(account_id) and actor_id (eg.: thread author id). +pub trait ActorOriginValidator { + /// Check for valid combination of origin and actor_id + fn ensure_actor_origin(origin: Origin, actor_id: ActorId) -> Result; +} diff --git a/runtime-modules/content-working-group/src/lib.rs b/runtime-modules/content-working-group/src/lib.rs index 9972ef0b55..61d17d04de 100755 --- a/runtime-modules/content-working-group/src/lib.rs +++ b/runtime-modules/content-working-group/src/lib.rs @@ -1195,7 +1195,7 @@ decl_module! { // Increment NextChannelId NextChannelId::::mutate(|id| *id += as One>::one()); - /// CREDENTIAL STUFF /// + // CREDENTIAL STUFF // // Dial out to membership module and inform about new role as channe owner. let registered_role = >::register_role_on_member(owner, &member_in_role).is_ok(); diff --git a/runtime-modules/governance/src/council.rs b/runtime-modules/governance/src/council.rs index 21b84c60ed..e419d3ebdd 100644 --- a/runtime-modules/governance/src/council.rs +++ b/runtime-modules/governance/src/council.rs @@ -91,7 +91,7 @@ decl_module! { // Privileged methods /// Force set a zero staked council. Stakes in existing council will vanish into thin air! - fn set_council(origin, accounts: Vec) { + pub fn set_council(origin, accounts: Vec) { ensure_root(origin)?; let new_council: Seats> = accounts.into_iter().map(|account| { Seat { diff --git a/runtime-modules/governance/src/election.rs b/runtime-modules/governance/src/election.rs index ff3345b93a..c8de43c912 100644 --- a/runtime-modules/governance/src/election.rs +++ b/runtime-modules/governance/src/election.rs @@ -99,6 +99,19 @@ impl> CouncilElected, + Y: CouncilElected, + > CouncilElected for (X, Y) +{ + fn council_elected(new_council: Elected, term: Term) { + X::council_elected(new_council.clone(), term.clone()); + Y::council_elected(new_council, term); + } +} #[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] #[derive(Clone, Copy, Encode, Decode, Default)] diff --git a/runtime-modules/governance/src/lib.rs b/runtime-modules/governance/src/lib.rs index 9b39780d8a..de98f9a5d4 100644 --- a/runtime-modules/governance/src/lib.rs +++ b/runtime-modules/governance/src/lib.rs @@ -4,7 +4,6 @@ pub mod council; pub mod election; pub mod election_params; -pub mod proposals; mod sealed_vote; mod stake; diff --git a/runtime-modules/governance/src/mock.rs b/runtime-modules/governance/src/mock.rs index 8d13c511fe..6a3976a65a 100644 --- a/runtime-modules/governance/src/mock.rs +++ b/runtime-modules/governance/src/mock.rs @@ -1,6 +1,6 @@ #![cfg(test)] -pub use super::{council, election, proposals}; +pub use super::{council, election}; pub use common::currency::GovernanceCurrency; pub use system; diff --git a/runtime-modules/governance/src/proposals.rs b/runtime-modules/governance/src/proposals.rs deleted file mode 100644 index 64a177a6fd..0000000000 --- a/runtime-modules/governance/src/proposals.rs +++ /dev/null @@ -1,1578 +0,0 @@ -use codec::{Decode, Encode}; -use rstd::prelude::*; -use sr_primitives::{ - print, - traits::{Hash, SaturatedConversion, Zero}, -}; -use srml_support::traits::{Currency, Get, ReservableCurrency}; -use srml_support::{decl_event, decl_module, decl_storage, dispatch, ensure}; -use system::{self, ensure_root, ensure_signed}; - -#[cfg(feature = "std")] -use serde::{Deserialize, Serialize}; - -#[cfg(test)] -use primitives::storage::well_known_keys; - -use super::council; -pub use common::currency::{BalanceOf, GovernanceCurrency}; - -const DEFAULT_APPROVAL_QUORUM: u32 = 60; -const DEFAULT_MIN_STAKE: u32 = 100; -const DEFAULT_CANCELLATION_FEE: u32 = 5; -const DEFAULT_REJECTION_FEE: u32 = 10; - -const DEFAULT_VOTING_PERIOD_IN_DAYS: u32 = 10; -const DEFAULT_VOTING_PERIOD_IN_SECS: u32 = DEFAULT_VOTING_PERIOD_IN_DAYS * 24 * 60 * 60; - -const DEFAULT_NAME_MAX_LEN: u32 = 100; -const DEFAULT_DESCRIPTION_MAX_LEN: u32 = 10_000; -const DEFAULT_WASM_CODE_MAX_LEN: u32 = 2_000_000; - -const MSG_STAKE_IS_TOO_LOW: &str = "Stake is too low"; -const MSG_STAKE_IS_GREATER_THAN_BALANCE: &str = "Balance is too low to be staked"; -const MSG_ONLY_MEMBERS_CAN_PROPOSE: &str = "Only members can make a proposal"; -const MSG_ONLY_COUNCILORS_CAN_VOTE: &str = "Only councilors can vote on proposals"; -const MSG_PROPOSAL_NOT_FOUND: &str = "This proposal does not exist"; -const MSG_PROPOSAL_EXPIRED: &str = "Voting period is expired for this proposal"; -const MSG_PROPOSAL_FINALIZED: &str = "Proposal is finalized already"; -const MSG_YOU_ALREADY_VOTED: &str = "You have already voted on this proposal"; -const MSG_YOU_DONT_OWN_THIS_PROPOSAL: &str = "You do not own this proposal"; -const MSG_PROPOSAL_STATUS_ALREADY_UPDATED: &str = "Proposal status has been updated already"; -const MSG_EMPTY_NAME_PROVIDED: &str = "Proposal cannot have an empty name"; -const MSG_EMPTY_DESCRIPTION_PROVIDED: &str = "Proposal cannot have an empty description"; -const MSG_EMPTY_WASM_CODE_PROVIDED: &str = "Proposal cannot have an empty WASM code"; -const MSG_TOO_LONG_NAME: &str = "Name is too long"; -const MSG_TOO_LONG_DESCRIPTION: &str = "Description is too long"; -const MSG_TOO_LONG_WASM_CODE: &str = "WASM code is too big"; - -#[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] -#[derive(Encode, Decode, Clone, PartialEq, Eq)] -pub enum ProposalStatus { - /// A new proposal that is available for voting. - Active, - /// If cancelled by a proposer. - Cancelled, - /// Not enough votes and voting period expired. - Expired, - /// To clear the quorum requirement, the percentage of council members with revealed votes - /// must be no less than the quorum value for the given proposal type. - Approved, - Rejected, - /// If all revealed votes are slashes, then the proposal is rejected, - /// and the proposal stake is slashed. - Slashed, -} - -impl Default for ProposalStatus { - fn default() -> Self { - ProposalStatus::Active - } -} - -use self::ProposalStatus::*; - -#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] -#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug)] -pub enum VoteKind { - /// Signals presence, but unwillingness to cast judgment on substance of vote. - Abstain, - /// Pass, an alternative or a ranking, for binary, multiple choice - /// and ranked choice propositions, respectively. - Approve, - /// Against proposal. - Reject, - /// Against the proposal, and slash proposal stake. - Slash, -} - -impl Default for VoteKind { - fn default() -> Self { - VoteKind::Abstain - } -} - -use self::VoteKind::*; - -#[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] -#[derive(Encode, Decode, Default, Clone, PartialEq, Eq)] -/// Proposal for node runtime update. -pub struct RuntimeUpgradeProposal { - id: u32, - proposer: AccountId, - stake: Balance, - name: Vec, - description: Vec, - wasm_hash: Hash, - proposed_at: BlockNumber, - status: ProposalStatus, -} - -#[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] -#[derive(Encode, Decode, Default, Clone, PartialEq, Eq)] -pub struct TallyResult { - proposal_id: u32, - abstentions: u32, - approvals: u32, - rejections: u32, - slashes: u32, - status: ProposalStatus, - finalized_at: BlockNumber, -} - -pub trait Trait: - timestamp::Trait + council::Trait + GovernanceCurrency + membership::members::Trait -{ - /// The overarching event type. - type Event: From> + Into<::Event>; -} - -decl_event!( - pub enum Event - where - ::Hash, - ::BlockNumber, - ::AccountId - { - // New events - - /// Params: - /// * Account id of a member who proposed. - /// * Id of a newly created proposal after it was saved in storage. - ProposalCreated(AccountId, u32), - ProposalCanceled(AccountId, u32), - ProposalStatusUpdated(u32, ProposalStatus), - - /// Params: - /// * Voter - an account id of a councilor. - /// * Id of a proposal. - /// * Kind of vote. - Voted(AccountId, u32, VoteKind), - - TallyFinalized(TallyResult), - - /// * Hash - hash of wasm code of runtime update. - RuntimeUpdated(u32, Hash), - - /// Root cancelled proposal - ProposalVetoed(u32), - } -); - -decl_storage! { - trait Store for Module as Proposals { - - // Parameters (defaut values could be exported to config): - - // TODO rename 'approval_quorum' -> 'quorum_percent' ?! - /// A percent (up to 100) of the council participants - /// that must vote affirmatively in order to pass. - ApprovalQuorum get(approval_quorum) config(): u32 = DEFAULT_APPROVAL_QUORUM; - - /// Minimum amount of a balance to be staked in order to make a proposal. - MinStake get(min_stake) config(): BalanceOf = - BalanceOf::::from(DEFAULT_MIN_STAKE); - - /// A fee to be slashed (burn) in case a proposer decides to cancel a proposal. - CancellationFee get(cancellation_fee) config(): BalanceOf = - BalanceOf::::from(DEFAULT_CANCELLATION_FEE); - - /// A fee to be slashed (burn) in case a proposal was rejected. - RejectionFee get(rejection_fee) config(): BalanceOf = - BalanceOf::::from(DEFAULT_REJECTION_FEE); - - /// Max duration of proposal in blocks until it will be expired if not enough votes. - VotingPeriod get(voting_period) config(): T::BlockNumber = - T::BlockNumber::from(DEFAULT_VOTING_PERIOD_IN_SECS / - (::MinimumPeriod::get().saturated_into::() * 2)); - - NameMaxLen get(name_max_len) config(): u32 = DEFAULT_NAME_MAX_LEN; - DescriptionMaxLen get(description_max_len) config(): u32 = DEFAULT_DESCRIPTION_MAX_LEN; - WasmCodeMaxLen get(wasm_code_max_len) config(): u32 = DEFAULT_WASM_CODE_MAX_LEN; - - // Persistent state (always relevant, changes constantly): - - /// Count of all proposals that have been created. - ProposalCount get(proposal_count): u32; - - /// Get proposal details by its id. - Proposals get(proposals): map u32 => RuntimeUpgradeProposal, T::BlockNumber, T::Hash>; - - /// Ids of proposals that are open for voting (have not been finalized yet). - ActiveProposalIds get(active_proposal_ids): Vec = vec![]; - - /// Get WASM code of runtime upgrade by hash of its content. - WasmCodeByHash get(wasm_code_by_hash): map T::Hash => Vec; - - VotesByProposal get(votes_by_proposal): map u32 => Vec<(T::AccountId, VoteKind)>; - - // TODO Rethink: this can be replaced with: votes_by_proposal.find(|vote| vote.0 == proposer) - VoteByAccountAndProposal get(vote_by_account_and_proposal): map (T::AccountId, u32) => VoteKind; - - TallyResults get(tally_results): map u32 => TallyResult; - } -} - -decl_module! { - pub struct Module for enum Call where origin: T::Origin { - - fn deposit_event() = default; - - /// Use next code to create a proposal from Substrate UI's web console: - /// ```js - /// post({ sender: runtime.indices.ss58Decode('F7Gh'), call: calls.proposals.createProposal(2500, "0x123", "0x456", "0x789") }).tie(console.log) - /// ``` - fn create_proposal( - origin, - stake: BalanceOf, - name: Vec, - description: Vec, - wasm_code: Vec - ) { - - let proposer = ensure_signed(origin)?; - ensure!(Self::can_participate(&proposer), MSG_ONLY_MEMBERS_CAN_PROPOSE); - ensure!(stake >= Self::min_stake(), MSG_STAKE_IS_TOO_LOW); - - ensure!(!name.is_empty(), MSG_EMPTY_NAME_PROVIDED); - ensure!(name.len() as u32 <= Self::name_max_len(), MSG_TOO_LONG_NAME); - - ensure!(!description.is_empty(), MSG_EMPTY_DESCRIPTION_PROVIDED); - ensure!(description.len() as u32 <= Self::description_max_len(), MSG_TOO_LONG_DESCRIPTION); - - ensure!(!wasm_code.is_empty(), MSG_EMPTY_WASM_CODE_PROVIDED); - ensure!(wasm_code.len() as u32 <= Self::wasm_code_max_len(), MSG_TOO_LONG_WASM_CODE); - - // Lock proposer's stake: - ::Currency::reserve(&proposer, stake) - .map_err(|_| MSG_STAKE_IS_GREATER_THAN_BALANCE)?; - - let proposal_id = Self::proposal_count() + 1; - ProposalCount::put(proposal_id); - - // See in substrate repo @ srml/contract/src/wasm/code_cache.rs:73 - let wasm_hash = T::Hashing::hash(&wasm_code); - - let new_proposal = RuntimeUpgradeProposal { - id: proposal_id, - proposer: proposer.clone(), - stake, - name, - description, - wasm_hash, - proposed_at: Self::current_block(), - status: Active - }; - - if !>::exists(wasm_hash) { - >::insert(wasm_hash, wasm_code); - } - >::insert(proposal_id, new_proposal); - ActiveProposalIds::mutate(|ids| ids.push(proposal_id)); - Self::deposit_event(RawEvent::ProposalCreated(proposer.clone(), proposal_id)); - - // Auto-vote with Approve if proposer is a councilor: - if Self::is_councilor(&proposer) { - Self::_process_vote(proposer, proposal_id, Approve)?; - } - } - - /// Use next code to create a proposal from Substrate UI's web console: - /// ```js - /// post({ sender: runtime.indices.ss58Decode('F7Gh'), call: calls.proposals.voteOnProposal(1, { option: "Approve", _type: "VoteKind" }) }).tie(console.log) - /// ``` - fn vote_on_proposal(origin, proposal_id: u32, vote: VoteKind) { - let voter = ensure_signed(origin)?; - ensure!(Self::is_councilor(&voter), MSG_ONLY_COUNCILORS_CAN_VOTE); - - ensure!(>::exists(proposal_id), MSG_PROPOSAL_NOT_FOUND); - let proposal = Self::proposals(proposal_id); - - ensure!(proposal.status == Active, MSG_PROPOSAL_FINALIZED); - - let not_expired = !Self::is_voting_period_expired(proposal.proposed_at); - ensure!(not_expired, MSG_PROPOSAL_EXPIRED); - - let did_not_vote_before = !>::exists((voter.clone(), proposal_id)); - ensure!(did_not_vote_before, MSG_YOU_ALREADY_VOTED); - - Self::_process_vote(voter, proposal_id, vote)?; - } - - // TODO add 'reason' why a proposer wants to cancel (UX + feedback)? - /// Cancel a proposal by its original proposer. Some fee will be withdrawn from his balance. - fn cancel_proposal(origin, proposal_id: u32) { - let proposer = ensure_signed(origin)?; - - ensure!(>::exists(proposal_id), MSG_PROPOSAL_NOT_FOUND); - let proposal = Self::proposals(proposal_id); - - ensure!(proposer == proposal.proposer, MSG_YOU_DONT_OWN_THIS_PROPOSAL); - ensure!(proposal.status == Active, MSG_PROPOSAL_FINALIZED); - - // Spend some minimum fee on proposer's balance for canceling a proposal - let fee = Self::cancellation_fee(); - let _ = ::Currency::slash_reserved(&proposer, fee); - - // Return unspent part of remaining staked deposit (after taking some fee) - let left_stake = proposal.stake - fee; - let _ = ::Currency::unreserve(&proposer, left_stake); - - Self::_update_proposal_status(proposal_id, Cancelled)?; - Self::deposit_event(RawEvent::ProposalCanceled(proposer, proposal_id)); - } - - // Called on every block - fn on_finalize(n: T::BlockNumber) { - if let Err(e) = Self::end_block(n) { - print(e); - } - } - - /// Cancel a proposal and return stake without slashing - fn veto_proposal(origin, proposal_id: u32) { - ensure_root(origin)?; - ensure!(>::exists(proposal_id), MSG_PROPOSAL_NOT_FOUND); - let proposal = Self::proposals(proposal_id); - ensure!(proposal.status == Active, MSG_PROPOSAL_FINALIZED); - - let _ = ::Currency::unreserve(&proposal.proposer, proposal.stake); - - Self::_update_proposal_status(proposal_id, Cancelled)?; - - Self::deposit_event(RawEvent::ProposalVetoed(proposal_id)); - } - - fn set_approval_quorum(origin, new_value: u32) { - ensure_root(origin)?; - ensure!(new_value > 0, "approval quorom must be greater than zero"); - ApprovalQuorum::put(new_value); - } - } -} - -impl Module { - fn current_block() -> T::BlockNumber { - >::block_number() - } - - fn can_participate(sender: &T::AccountId) -> bool { - !::Currency::free_balance(sender).is_zero() - && >::is_member_account(sender) - } - - fn is_councilor(sender: &T::AccountId) -> bool { - >::is_councilor(sender) - } - - fn councilors_count() -> u32 { - >::active_council().len() as u32 - } - - fn approval_quorum_seats() -> u32 { - (Self::approval_quorum() * Self::councilors_count()) / 100 - } - - fn is_voting_period_expired(proposed_at: T::BlockNumber) -> bool { - Self::current_block() >= proposed_at + Self::voting_period() - } - - fn _process_vote(voter: T::AccountId, proposal_id: u32, vote: VoteKind) -> dispatch::Result { - let new_vote = (voter.clone(), vote.clone()); - if >::exists(proposal_id) { - // Append a new vote to other votes on this proposal: - >::mutate(proposal_id, |votes| votes.push(new_vote)); - } else { - // This is the first vote on this proposal: - >::insert(proposal_id, vec![new_vote]); - } - >::insert((voter.clone(), proposal_id), &vote); - Self::deposit_event(RawEvent::Voted(voter, proposal_id, vote)); - Ok(()) - } - - fn end_block(_now: T::BlockNumber) -> dispatch::Result { - // TODO refactor this method - - // TODO iterate over not expired proposals and tally - - Self::tally()?; - // TODO approve or reject a proposal - - Ok(()) - } - - /// Get the voters for the current proposal. - pub fn tally() -> dispatch::Result { - let councilors: u32 = Self::councilors_count(); - let quorum: u32 = Self::approval_quorum_seats(); - - for &proposal_id in Self::active_proposal_ids().iter() { - let votes = Self::votes_by_proposal(proposal_id); - let mut abstentions: u32 = 0; - let mut approvals: u32 = 0; - let mut rejections: u32 = 0; - let mut slashes: u32 = 0; - - for (_, vote) in votes.iter() { - match vote { - Abstain => abstentions += 1, - Approve => approvals += 1, - Reject => rejections += 1, - Slash => slashes += 1, - } - } - - let proposal = Self::proposals(proposal_id); - let is_expired = Self::is_voting_period_expired(proposal.proposed_at); - - // We need to check that the council is not empty because otherwise, - // if there is no votes on a proposal it will be counted as if - // all 100% (zero) councilors voted on the proposal and should be approved. - - let non_empty_council = councilors > 0; - let all_councilors_voted = non_empty_council && votes.len() as u32 == councilors; - let all_councilors_slashed = non_empty_council && slashes == councilors; - let quorum_reached = quorum > 0 && approvals >= quorum; - - // Don't approve a proposal right after quorum reached - // if not all councilors casted their votes. - // Instead let other councilors cast their vote - // up until the proposal's expired. - - let new_status: Option = if all_councilors_slashed { - Some(Slashed) - } else if all_councilors_voted { - if quorum_reached { - Some(Approved) - } else { - Some(Rejected) - } - } else if is_expired { - if quorum_reached { - Some(Approved) - } else { - // Proposal has been expired and quorum not reached. - Some(Expired) - } - } else { - // Councilors still have time to vote on this proposal. - None - }; - - // TODO move next block outside of tally to 'end_block' - if let Some(status) = new_status { - Self::_update_proposal_status(proposal_id, status.clone())?; - let tally_result = TallyResult { - proposal_id, - abstentions, - approvals, - rejections, - slashes, - status, - finalized_at: Self::current_block(), - }; - >::insert(proposal_id, &tally_result); - Self::deposit_event(RawEvent::TallyFinalized(tally_result)); - } - } - - Ok(()) - } - - /// Updates proposal status and removes proposal from active ids. - fn _update_proposal_status(proposal_id: u32, new_status: ProposalStatus) -> dispatch::Result { - let all_active_ids = Self::active_proposal_ids(); - let all_len = all_active_ids.len(); - let other_active_ids: Vec = all_active_ids - .into_iter() - .filter(|&id| id != proposal_id) - .collect(); - - let not_found_in_active = other_active_ids.len() == all_len; - if not_found_in_active { - // Seems like this proposal's status has been updated and removed from active. - Err(MSG_PROPOSAL_STATUS_ALREADY_UPDATED) - } else { - let pid = proposal_id.clone(); - match new_status { - Slashed => Self::_slash_proposal(pid)?, - Rejected | Expired => Self::_reject_proposal(pid)?, - Approved => Self::_approve_proposal(pid)?, - Active | Cancelled => { /* nothing */ } - } - ActiveProposalIds::put(other_active_ids); - >::mutate(proposal_id, |p| p.status = new_status.clone()); - Self::deposit_event(RawEvent::ProposalStatusUpdated(proposal_id, new_status)); - Ok(()) - } - } - - /// Slash a proposal. The staked deposit will be slashed. - fn _slash_proposal(proposal_id: u32) -> dispatch::Result { - let proposal = Self::proposals(proposal_id); - - // Slash proposer's stake: - let _ = - ::Currency::slash_reserved(&proposal.proposer, proposal.stake); - - Ok(()) - } - - /// Reject a proposal. The staked deposit will be returned to a proposer. - fn _reject_proposal(proposal_id: u32) -> dispatch::Result { - let proposal = Self::proposals(proposal_id); - let proposer = proposal.proposer; - - // Spend some minimum fee on proposer's balance to prevent spamming attacks: - let fee = Self::rejection_fee(); - let _ = ::Currency::slash_reserved(&proposer, fee); - - // Return unspent part of remaining staked deposit (after taking some fee): - let left_stake = proposal.stake - fee; - let _ = ::Currency::unreserve(&proposer, left_stake); - - Ok(()) - } - - /// Approve a proposal. The staked deposit will be returned. - fn _approve_proposal(proposal_id: u32) -> dispatch::Result { - let proposal = Self::proposals(proposal_id); - let wasm_code = Self::wasm_code_by_hash(proposal.wasm_hash); - - // Return staked deposit to proposer: - let _ = ::Currency::unreserve(&proposal.proposer, proposal.stake); - - // Update wasm code of node's runtime: - >::set_code(system::RawOrigin::Root.into(), wasm_code)?; - - Self::deposit_event(RawEvent::RuntimeUpdated(proposal_id, proposal.wasm_hash)); - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - - use super::*; - use primitives::H256; - // The testing primitives are very useful for avoiding having to work with signatures - // or public keys. `u64` is used as the `AccountId` and no `Signature`s are requried. - use sr_primitives::{ - testing::Header, - traits::{BlakeTwo256, IdentityLookup}, - Perbill, - }; - use srml_support::*; - - impl_outer_origin! { - pub enum Origin for Test {} - } - - // Workaround for https://github.com/rust-lang/rust/issues/26925 . Remove when sorted. - #[derive(Clone, PartialEq, Eq, Debug)] - pub struct Test; - - parameter_types! { - pub const BlockHashCount: u64 = 250; - pub const MaximumBlockWeight: u32 = 1024; - pub const MaximumBlockLength: u32 = 2 * 1024; - pub const AvailableBlockRatio: Perbill = Perbill::one(); - pub const MinimumPeriod: u64 = 5; - } - - impl system::Trait for Test { - type Origin = Origin; - type Index = u64; - type BlockNumber = u64; - type Call = (); - type Hash = H256; - type Hashing = BlakeTwo256; - type AccountId = u64; - type Lookup = IdentityLookup; - type Header = Header; - type Event = (); - type BlockHashCount = BlockHashCount; - type MaximumBlockWeight = MaximumBlockWeight; - type MaximumBlockLength = MaximumBlockLength; - type AvailableBlockRatio = AvailableBlockRatio; - type Version = (); - } - - impl timestamp::Trait for Test { - type Moment = u64; - type OnTimestampSet = (); - type MinimumPeriod = MinimumPeriod; - } - - parameter_types! { - pub const ExistentialDeposit: u32 = 0; - pub const TransferFee: u32 = 0; - pub const CreationFee: u32 = 0; - pub const TransactionBaseFee: u32 = 1; - pub const TransactionByteFee: u32 = 0; - pub const InitialMembersBalance: u32 = 0; - } - - impl balances::Trait for Test { - /// The type for recording an account's balance. - type Balance = u64; - /// What to do if an account's free balance gets zeroed. - type OnFreeBalanceZero = (); - /// What to do if a new account is created. - type OnNewAccount = (); - /// The ubiquitous event type. - type Event = (); - - type DustRemoval = (); - type TransferPayment = (); - type ExistentialDeposit = ExistentialDeposit; - type TransferFee = TransferFee; - type CreationFee = CreationFee; - } - - impl council::Trait for Test { - type Event = (); - type CouncilTermEnded = (); - } - - impl GovernanceCurrency for Test { - type Currency = balances::Module; - } - - impl membership::members::Trait for Test { - type Event = (); - type MemberId = u32; - type PaidTermId = u32; - type SubscriptionId = u32; - type ActorId = u32; - type InitialMembersBalance = InitialMembersBalance; - } - - impl minting::Trait for Test { - type Currency = balances::Module; - type MintId = u64; - } - - impl Trait for Test { - type Event = (); - } - - type System = system::Module; - type Balances = balances::Module; - type Proposals = Module; - - const COUNCILOR1: u64 = 1; - const COUNCILOR2: u64 = 2; - const COUNCILOR3: u64 = 3; - const COUNCILOR4: u64 = 4; - const COUNCILOR5: u64 = 5; - - const PROPOSER1: u64 = 11; - const PROPOSER2: u64 = 12; - - const NOT_COUNCILOR: u64 = 22; - - const ALL_COUNCILORS: [u64; 5] = [COUNCILOR1, COUNCILOR2, COUNCILOR3, COUNCILOR4, COUNCILOR5]; - - // TODO Figure out how to test Events in test... (low priority) - // mod proposals { - // pub use ::Event; - // } - // impl_outer_event!{ - // pub enum TestEvent for Test { - // balances,system,proposals, - // } - // } - - // This function basically just builds a genesis storage key/value store according to - // our desired mockup. - fn new_test_ext() -> runtime_io::TestExternalities { - let mut t = system::GenesisConfig::default() - .build_storage::() - .unwrap(); - - // balances doesn't contain GenesisConfig anymore - // // We use default for brevity, but you can configure as desired if needed. - // balances::GenesisConfig::::default() - // .assimilate_storage(&mut t) - // .unwrap(); - - let council_mock: council::Seats = ALL_COUNCILORS - .iter() - .map(|&c| council::Seat { - member: c, - stake: 0u64, - backers: vec![], - }) - .collect(); - - council::GenesisConfig:: { - active_council: council_mock, - term_ends_at: 0, - } - .assimilate_storage(&mut t) - .unwrap(); - - membership::members::GenesisConfig:: { - default_paid_membership_fee: 0, - members: vec![ - (PROPOSER1, "alice".into(), "".into(), "".into()), - (PROPOSER2, "bobby".into(), "".into(), "".into()), - (COUNCILOR1, "councilor1".into(), "".into(), "".into()), - (COUNCILOR2, "councilor2".into(), "".into(), "".into()), - (COUNCILOR3, "councilor3".into(), "".into(), "".into()), - (COUNCILOR4, "councilor4".into(), "".into(), "".into()), - (COUNCILOR5, "councilor5".into(), "".into(), "".into()), - ], - } - .assimilate_storage(&mut t) - .unwrap(); - // t.extend(GenesisConfig::{ - // // Here we can override defaults. - // }.build_storage().unwrap().0); - - t.into() - } - - /// A shortcut to get minimum stake in tests. - fn min_stake() -> u64 { - Proposals::min_stake() - } - - /// A shortcut to get cancellation fee in tests. - fn cancellation_fee() -> u64 { - Proposals::cancellation_fee() - } - - /// A shortcut to get rejection fee in tests. - fn rejection_fee() -> u64 { - Proposals::rejection_fee() - } - - /// Initial balance of Proposer 1. - fn initial_balance() -> u64 { - (min_stake() as f64 * 2.5) as u64 - } - - fn name() -> Vec { - b"Proposal Name".to_vec() - } - - fn description() -> Vec { - b"Proposal Description".to_vec() - } - - fn wasm_code() -> Vec { - b"Proposal Wasm Code".to_vec() - } - - fn _create_default_proposal() -> dispatch::Result { - _create_proposal(None, None, None, None, None) - } - - fn _create_proposal( - origin: Option, - stake: Option, - name: Option>, - description: Option>, - wasm_code: Option>, - ) -> dispatch::Result { - Proposals::create_proposal( - Origin::signed(origin.unwrap_or(PROPOSER1)), - stake.unwrap_or(min_stake()), - name.unwrap_or(self::name()), - description.unwrap_or(self::description()), - wasm_code.unwrap_or(self::wasm_code()), - ) - } - - fn get_runtime_code() -> Option> { - storage::unhashed::get_raw(well_known_keys::CODE) - } - - macro_rules! assert_runtime_code_empty { - () => { - assert_eq!(get_runtime_code(), Some(vec![])) - }; - } - - macro_rules! assert_runtime_code { - ($code:expr) => { - assert_eq!(get_runtime_code(), Some($code)) - }; - } - - #[test] - fn check_default_values() { - new_test_ext().execute_with(|| { - assert_eq!(Proposals::approval_quorum(), DEFAULT_APPROVAL_QUORUM); - assert_eq!( - Proposals::min_stake(), - BalanceOf::::from(DEFAULT_MIN_STAKE) - ); - assert_eq!( - Proposals::cancellation_fee(), - BalanceOf::::from(DEFAULT_CANCELLATION_FEE) - ); - assert_eq!( - Proposals::rejection_fee(), - BalanceOf::::from(DEFAULT_REJECTION_FEE) - ); - assert_eq!(Proposals::name_max_len(), DEFAULT_NAME_MAX_LEN); - assert_eq!( - Proposals::description_max_len(), - DEFAULT_DESCRIPTION_MAX_LEN - ); - assert_eq!(Proposals::wasm_code_max_len(), DEFAULT_WASM_CODE_MAX_LEN); - assert_eq!(Proposals::proposal_count(), 0); - assert!(Proposals::active_proposal_ids().is_empty()); - }); - } - - #[test] - fn member_create_proposal() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - assert_eq!(Proposals::active_proposal_ids().len(), 1); - assert_eq!(Proposals::active_proposal_ids()[0], 1); - - let wasm_hash = BlakeTwo256::hash(&wasm_code()); - let expected_proposal = RuntimeUpgradeProposal { - id: 1, - proposer: PROPOSER1, - stake: min_stake(), - name: name(), - description: description(), - wasm_hash, - proposed_at: 1, - status: Active, - }; - assert_eq!(Proposals::proposals(1), expected_proposal); - - // Check that stake amount has been locked on proposer's balance: - assert_eq!( - Balances::free_balance(PROPOSER1), - initial_balance() - min_stake() - ); - assert_eq!(Balances::reserved_balance(PROPOSER1), min_stake()); - - // TODO expect event ProposalCreated(AccountId, u32) - }); - } - - #[test] - fn not_member_cannot_create_proposal() { - new_test_ext().execute_with(|| { - // In this test a proposer has an empty balance - // thus he is not considered as a member. - assert_eq!( - _create_default_proposal(), - Err(MSG_ONLY_MEMBERS_CAN_PROPOSE) - ); - }); - } - - #[test] - fn cannot_create_proposal_with_small_stake() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_eq!( - _create_proposal(None, Some(min_stake() - 1), None, None, None), - Err(MSG_STAKE_IS_TOO_LOW) - ); - - // Check that balances remain unchanged afer a failed attempt to create a proposal: - assert_eq!(Balances::free_balance(PROPOSER1), initial_balance()); - assert_eq!(Balances::reserved_balance(PROPOSER1), 0); - }); - } - - #[test] - fn cannot_create_proposal_when_stake_is_greater_than_balance() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_eq!( - _create_proposal(None, Some(initial_balance() + 1), None, None, None), - Err(MSG_STAKE_IS_GREATER_THAN_BALANCE) - ); - - // Check that balances remain unchanged afer a failed attempt to create a proposal: - assert_eq!(Balances::free_balance(PROPOSER1), initial_balance()); - assert_eq!(Balances::reserved_balance(PROPOSER1), 0); - }); - } - - #[test] - fn cannot_create_proposal_with_empty_values() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - // Empty name: - assert_eq!( - _create_proposal(None, None, Some(vec![]), None, None), - Err(MSG_EMPTY_NAME_PROVIDED) - ); - - // Empty description: - assert_eq!( - _create_proposal(None, None, None, Some(vec![]), None), - Err(MSG_EMPTY_DESCRIPTION_PROVIDED) - ); - - // Empty WASM code: - assert_eq!( - _create_proposal(None, None, None, None, Some(vec![])), - Err(MSG_EMPTY_WASM_CODE_PROVIDED) - ); - }); - } - - #[test] - fn cannot_create_proposal_with_too_long_values() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - // Too long name: - assert_eq!( - _create_proposal(None, None, Some(too_long_name()), None, None), - Err(MSG_TOO_LONG_NAME) - ); - - // Too long description: - assert_eq!( - _create_proposal(None, None, None, Some(too_long_description()), None), - Err(MSG_TOO_LONG_DESCRIPTION) - ); - - // Too long WASM code: - assert_eq!( - _create_proposal(None, None, None, None, Some(too_long_wasm_code())), - Err(MSG_TOO_LONG_WASM_CODE) - ); - }); - } - - fn too_long_name() -> Vec { - vec![65; Proposals::name_max_len() as usize + 1] - } - - fn too_long_description() -> Vec { - vec![65; Proposals::description_max_len() as usize + 1] - } - - fn too_long_wasm_code() -> Vec { - vec![65; Proposals::wasm_code_max_len() as usize + 1] - } - - // ------------------------------------------------------------------- - // Cancellation - - #[test] - fn owner_cancel_proposal() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - assert_ok!(Proposals::cancel_proposal(Origin::signed(PROPOSER1), 1)); - assert_eq!(Proposals::proposals(1).status, Cancelled); - assert!(Proposals::active_proposal_ids().is_empty()); - - // Check that proposer's balance reduced by cancellation fee and other part of his stake returned to his balance: - assert_eq!( - Balances::free_balance(PROPOSER1), - initial_balance() - cancellation_fee() - ); - assert_eq!(Balances::reserved_balance(PROPOSER1), 0); - - // TODO expect event ProposalCancelled(AccountId, u32) - }); - } - - #[test] - fn owner_cannot_cancel_proposal_if_its_finalized() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - assert_ok!(Proposals::cancel_proposal(Origin::signed(PROPOSER1), 1)); - assert_eq!(Proposals::proposals(1).status, Cancelled); - - // Get balances updated after cancelling a proposal: - let updated_free_balance = Balances::free_balance(PROPOSER1); - let updated_reserved_balance = Balances::reserved_balance(PROPOSER1); - - assert_eq!( - Proposals::cancel_proposal(Origin::signed(PROPOSER1), 1), - Err(MSG_PROPOSAL_FINALIZED) - ); - - // Check that proposer's balance and locked stake haven't been changed: - assert_eq!(Balances::free_balance(PROPOSER1), updated_free_balance); - assert_eq!( - Balances::reserved_balance(PROPOSER1), - updated_reserved_balance - ); - }); - } - - #[test] - fn not_owner_cannot_cancel_proposal() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - let _ = Balances::deposit_creating(&PROPOSER2, initial_balance()); - assert_ok!(_create_default_proposal()); - assert_eq!( - Proposals::cancel_proposal(Origin::signed(PROPOSER2), 1), - Err(MSG_YOU_DONT_OWN_THIS_PROPOSAL) - ); - }); - } - - // ------------------------------------------------------------------- - // Voting - - #[test] - fn councilor_vote_on_proposal() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - - assert_ok!(Proposals::vote_on_proposal( - Origin::signed(COUNCILOR1), - 1, - Approve - )); - - // Check that a vote has been saved: - assert_eq!(Proposals::votes_by_proposal(1), vec![(COUNCILOR1, Approve)]); - assert_eq!( - Proposals::vote_by_account_and_proposal((COUNCILOR1, 1)), - Approve - ); - - // TODO expect event Voted(PROPOSER1, 1, Approve) - }); - } - - #[test] - fn councilor_cannot_vote_on_proposal_twice() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - - assert_ok!(Proposals::vote_on_proposal( - Origin::signed(COUNCILOR1), - 1, - Approve - )); - assert_eq!( - Proposals::vote_on_proposal(Origin::signed(COUNCILOR1), 1, Approve), - Err(MSG_YOU_ALREADY_VOTED) - ); - }); - } - - #[test] - fn autovote_with_approve_when_councilor_creates_proposal() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&COUNCILOR1, initial_balance()); - - assert_ok!(_create_proposal(Some(COUNCILOR1), None, None, None, None)); - - // Check that a vote has been sent automatically, - // such as the proposer is a councilor: - assert_eq!(Proposals::votes_by_proposal(1), vec![(COUNCILOR1, Approve)]); - assert_eq!( - Proposals::vote_by_account_and_proposal((COUNCILOR1, 1)), - Approve - ); - }); - } - - #[test] - fn not_councilor_cannot_vote_on_proposal() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - assert_eq!( - Proposals::vote_on_proposal(Origin::signed(NOT_COUNCILOR), 1, Approve), - Err(MSG_ONLY_COUNCILORS_CAN_VOTE) - ); - }); - } - - #[test] - fn councilor_cannot_vote_on_proposal_if_it_has_been_cancelled() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - assert_ok!(Proposals::cancel_proposal(Origin::signed(PROPOSER1), 1)); - assert_eq!( - Proposals::vote_on_proposal(Origin::signed(COUNCILOR1), 1, Approve), - Err(MSG_PROPOSAL_FINALIZED) - ); - }); - } - - #[test] - fn councilor_cannot_vote_on_proposal_if_tally_has_been_finalized() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - - // All councilors vote with 'Approve' on proposal: - let mut expected_votes: Vec<(u64, VoteKind)> = vec![]; - for &councilor in ALL_COUNCILORS.iter() { - expected_votes.push((councilor, Approve)); - assert_ok!(Proposals::vote_on_proposal( - Origin::signed(councilor), - 1, - Approve - )); - assert_eq!( - Proposals::vote_by_account_and_proposal((councilor, 1)), - Approve - ); - } - assert_eq!(Proposals::votes_by_proposal(1), expected_votes); - - System::set_block_number(2); - let _ = Proposals::end_block(2); - - assert!(Proposals::active_proposal_ids().is_empty()); - assert_eq!(Proposals::proposals(1).status, Approved); - - // Try to vote on finalized proposal: - assert_eq!( - Proposals::vote_on_proposal(Origin::signed(COUNCILOR1), 1, Reject), - Err(MSG_PROPOSAL_FINALIZED) - ); - }); - } - - // ------------------------------------------------------------------- - // Tally + Outcome: - - #[test] - fn approve_proposal_when_all_councilors_approved_it() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - - // All councilors approved: - let mut expected_votes: Vec<(u64, VoteKind)> = vec![]; - for &councilor in ALL_COUNCILORS.iter() { - expected_votes.push((councilor, Approve)); - assert_ok!(Proposals::vote_on_proposal( - Origin::signed(councilor), - 1, - Approve - )); - assert_eq!( - Proposals::vote_by_account_and_proposal((councilor, 1)), - Approve - ); - } - assert_eq!(Proposals::votes_by_proposal(1), expected_votes); - - assert_runtime_code_empty!(); - - System::set_block_number(2); - let _ = Proposals::end_block(2); - - // Check that runtime code has been updated after proposal approved. - assert_runtime_code!(wasm_code()); - - assert!(Proposals::active_proposal_ids().is_empty()); - assert_eq!(Proposals::proposals(1).status, Approved); - assert_eq!( - Proposals::tally_results(1), - TallyResult { - proposal_id: 1, - abstentions: 0, - approvals: ALL_COUNCILORS.len() as u32, - rejections: 0, - slashes: 0, - status: Approved, - finalized_at: 2 - } - ); - - // Check that proposer's stake has been added back to his balance: - assert_eq!(Balances::free_balance(PROPOSER1), initial_balance()); - assert_eq!(Balances::reserved_balance(PROPOSER1), 0); - - // TODO expect event ProposalStatusUpdated(1, Approved) - }); - } - - #[test] - fn approve_proposal_when_all_councilors_voted_and_only_quorum_approved() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - - // Only a quorum of councilors approved, others rejected: - let councilors = Proposals::councilors_count(); - let approvals = Proposals::approval_quorum_seats(); - let rejections = councilors - approvals; - for i in 0..councilors as usize { - let vote = if (i as u32) < approvals { - Approve - } else { - Reject - }; - assert_ok!(Proposals::vote_on_proposal( - Origin::signed(ALL_COUNCILORS[i]), - 1, - vote - )); - } - assert_eq!(Proposals::votes_by_proposal(1).len() as u32, councilors); - - assert_runtime_code_empty!(); - - System::set_block_number(2); - let _ = Proposals::end_block(2); - - // Check that runtime code has been updated after proposal approved. - assert_runtime_code!(wasm_code()); - - assert!(Proposals::active_proposal_ids().is_empty()); - assert_eq!(Proposals::proposals(1).status, Approved); - assert_eq!( - Proposals::tally_results(1), - TallyResult { - proposal_id: 1, - abstentions: 0, - approvals: approvals, - rejections: rejections, - slashes: 0, - status: Approved, - finalized_at: 2 - } - ); - - // Check that proposer's stake has been added back to his balance: - assert_eq!(Balances::free_balance(PROPOSER1), initial_balance()); - assert_eq!(Balances::reserved_balance(PROPOSER1), 0); - - // TODO expect event ProposalStatusUpdated(1, Approved) - }); - } - - #[test] - fn approve_proposal_when_voting_period_expired_if_only_quorum_voted() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - - // Only quorum of councilors approved, other councilors didn't vote: - let approvals = Proposals::approval_quorum_seats(); - for i in 0..approvals as usize { - let vote = if (i as u32) < approvals { - Approve - } else { - Slash - }; - assert_ok!(Proposals::vote_on_proposal( - Origin::signed(ALL_COUNCILORS[i]), - 1, - vote - )); - } - assert_eq!(Proposals::votes_by_proposal(1).len() as u32, approvals); - - assert_runtime_code_empty!(); - - let expiration_block = System::block_number() + Proposals::voting_period(); - System::set_block_number(2); - let _ = Proposals::end_block(2); - - // Check that runtime code has NOT been updated yet, - // because not all councilors voted and voting period is not expired yet. - assert_runtime_code_empty!(); - - System::set_block_number(expiration_block); - let _ = Proposals::end_block(expiration_block); - - // Check that runtime code has been updated after proposal approved. - assert_runtime_code!(wasm_code()); - - assert!(Proposals::active_proposal_ids().is_empty()); - assert_eq!(Proposals::proposals(1).status, Approved); - assert_eq!( - Proposals::tally_results(1), - TallyResult { - proposal_id: 1, - abstentions: 0, - approvals: approvals, - rejections: 0, - slashes: 0, - status: Approved, - finalized_at: expiration_block - } - ); - - // Check that proposer's stake has been added back to his balance: - assert_eq!(Balances::free_balance(PROPOSER1), initial_balance()); - assert_eq!(Balances::reserved_balance(PROPOSER1), 0); - - // TODO expect event ProposalStatusUpdated(1, Approved) - }); - } - - #[test] - fn reject_proposal_when_all_councilors_voted_and_quorum_not_reached() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - - // Less than a quorum of councilors approved, while others abstained: - let councilors = Proposals::councilors_count(); - let approvals = Proposals::approval_quorum_seats() - 1; - let abstentions = councilors - approvals; - for i in 0..councilors as usize { - let vote = if (i as u32) < approvals { - Approve - } else { - Abstain - }; - assert_ok!(Proposals::vote_on_proposal( - Origin::signed(ALL_COUNCILORS[i]), - 1, - vote - )); - } - assert_eq!(Proposals::votes_by_proposal(1).len() as u32, councilors); - - assert_runtime_code_empty!(); - - System::set_block_number(2); - let _ = Proposals::end_block(2); - - // Check that runtime code has NOT been updated after proposal slashed. - assert_runtime_code_empty!(); - - assert!(Proposals::active_proposal_ids().is_empty()); - assert_eq!(Proposals::proposals(1).status, Rejected); - assert_eq!( - Proposals::tally_results(1), - TallyResult { - proposal_id: 1, - abstentions: abstentions, - approvals: approvals, - rejections: 0, - slashes: 0, - status: Rejected, - finalized_at: 2 - } - ); - - // Check that proposer's balance reduced by burnt stake: - assert_eq!( - Balances::free_balance(PROPOSER1), - initial_balance() - rejection_fee() - ); - assert_eq!(Balances::reserved_balance(PROPOSER1), 0); - - // TODO expect event ProposalStatusUpdated(1, Rejected) - }); - } - - #[test] - fn reject_proposal_when_all_councilors_rejected_it() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - - // All councilors rejected: - let mut expected_votes: Vec<(u64, VoteKind)> = vec![]; - for &councilor in ALL_COUNCILORS.iter() { - expected_votes.push((councilor, Reject)); - assert_ok!(Proposals::vote_on_proposal( - Origin::signed(councilor), - 1, - Reject - )); - assert_eq!( - Proposals::vote_by_account_and_proposal((councilor, 1)), - Reject - ); - } - assert_eq!(Proposals::votes_by_proposal(1), expected_votes); - - assert_runtime_code_empty!(); - - System::set_block_number(2); - let _ = Proposals::end_block(2); - - // Check that runtime code has NOT been updated after proposal rejected. - assert_runtime_code_empty!(); - - assert!(Proposals::active_proposal_ids().is_empty()); - assert_eq!(Proposals::proposals(1).status, Rejected); - assert_eq!( - Proposals::tally_results(1), - TallyResult { - proposal_id: 1, - abstentions: 0, - approvals: 0, - rejections: ALL_COUNCILORS.len() as u32, - slashes: 0, - status: Rejected, - finalized_at: 2 - } - ); - - // Check that proposer's balance reduced by burnt stake: - assert_eq!( - Balances::free_balance(PROPOSER1), - initial_balance() - rejection_fee() - ); - assert_eq!(Balances::reserved_balance(PROPOSER1), 0); - - // TODO expect event ProposalStatusUpdated(1, Rejected) - }); - } - - #[test] - fn slash_proposal_when_all_councilors_slashed_it() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - - // All councilors slashed: - let mut expected_votes: Vec<(u64, VoteKind)> = vec![]; - for &councilor in ALL_COUNCILORS.iter() { - expected_votes.push((councilor, Slash)); - assert_ok!(Proposals::vote_on_proposal( - Origin::signed(councilor), - 1, - Slash - )); - assert_eq!( - Proposals::vote_by_account_and_proposal((councilor, 1)), - Slash - ); - } - assert_eq!(Proposals::votes_by_proposal(1), expected_votes); - - assert_runtime_code_empty!(); - - System::set_block_number(2); - let _ = Proposals::end_block(2); - - // Check that runtime code has NOT been updated after proposal slashed. - assert_runtime_code_empty!(); - - assert!(Proposals::active_proposal_ids().is_empty()); - assert_eq!(Proposals::proposals(1).status, Slashed); - assert_eq!( - Proposals::tally_results(1), - TallyResult { - proposal_id: 1, - abstentions: 0, - approvals: 0, - rejections: 0, - slashes: ALL_COUNCILORS.len() as u32, - status: Slashed, - finalized_at: 2 - } - ); - - // Check that proposer's balance reduced by burnt stake: - assert_eq!( - Balances::free_balance(PROPOSER1), - initial_balance() - min_stake() - ); - assert_eq!(Balances::reserved_balance(PROPOSER1), 0); - - // TODO expect event ProposalStatusUpdated(1, Slashed) - // TODO fix: event log assertion doesn't work and return empty event in every record - // assert_eq!(*System::events().last().unwrap(), - // EventRecord { - // phase: Phase::ApplyExtrinsic(0), - // event: RawEvent::ProposalStatusUpdated(1, Slashed), - // } - // ); - }); - } - - // In this case a proposal will be marked as 'Expired' - // and it will be processed in the same way as if it has been rejected. - #[test] - fn expire_proposal_when_not_all_councilors_voted_and_quorum_not_reached() { - new_test_ext().execute_with(|| { - let _ = Balances::deposit_creating(&PROPOSER1, initial_balance()); - - assert_ok!(_create_default_proposal()); - - // Less than a quorum of councilors approved: - let approvals = Proposals::approval_quorum_seats() - 1; - for i in 0..approvals as usize { - let vote = if (i as u32) < approvals { - Approve - } else { - Slash - }; - assert_ok!(Proposals::vote_on_proposal( - Origin::signed(ALL_COUNCILORS[i]), - 1, - vote - )); - } - assert_eq!(Proposals::votes_by_proposal(1).len() as u32, approvals); - - assert_runtime_code_empty!(); - - let expiration_block = System::block_number() + Proposals::voting_period(); - System::set_block_number(expiration_block); - let _ = Proposals::end_block(expiration_block); - - // Check that runtime code has NOT been updated after proposal slashed. - assert_runtime_code_empty!(); - - assert!(Proposals::active_proposal_ids().is_empty()); - assert_eq!(Proposals::proposals(1).status, Expired); - assert_eq!( - Proposals::tally_results(1), - TallyResult { - proposal_id: 1, - abstentions: 0, - approvals: approvals, - rejections: 0, - slashes: 0, - status: Expired, - finalized_at: expiration_block - } - ); - - // Check that proposer's balance reduced by burnt stake: - assert_eq!( - Balances::free_balance(PROPOSER1), - initial_balance() - rejection_fee() - ); - assert_eq!(Balances::reserved_balance(PROPOSER1), 0); - - // TODO expect event ProposalStatusUpdated(1, Rejected) - }); - } -} diff --git a/runtime-modules/membership/Cargo.toml b/runtime-modules/membership/Cargo.toml index 8fc8e9adc5..9961e03f1d 100644 --- a/runtime-modules/membership/Cargo.toml +++ b/runtime-modules/membership/Cargo.toml @@ -1,6 +1,6 @@ [package] name = 'substrate-membership-module' -version = '1.0.0' +version = '1.0.1' authors = ['Joystream contributors'] edition = '2018' diff --git a/runtime-modules/membership/src/lib.rs b/runtime-modules/membership/src/lib.rs index a777802fd3..f3259534d5 100644 --- a/runtime-modules/membership/src/lib.rs +++ b/runtime-modules/membership/src/lib.rs @@ -5,5 +5,5 @@ pub mod genesis; pub mod members; pub mod role_types; -mod mock; +pub(crate) mod mock; mod tests; diff --git a/runtime-modules/membership/src/role_types.rs b/runtime-modules/membership/src/role_types.rs index 803e048b8e..25dd8c9938 100644 --- a/runtime-modules/membership/src/role_types.rs +++ b/runtime-modules/membership/src/role_types.rs @@ -2,6 +2,10 @@ use codec::{Decode, Encode}; use rstd::collections::btree_set::BTreeSet; use rstd::prelude::*; +#[cfg(feature = "std")] +use serde::{Deserialize, Serialize}; + +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] #[derive(Encode, Decode, Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Debug)] pub enum Role { StorageProvider, diff --git a/runtime-modules/proposals/codex/Cargo.toml b/runtime-modules/proposals/codex/Cargo.toml new file mode 100644 index 0000000000..9369a6daf9 --- /dev/null +++ b/runtime-modules/proposals/codex/Cargo.toml @@ -0,0 +1,182 @@ +[package] +name = 'substrate-proposals-codex-module' +version = '2.0.0' +authors = ['Joystream contributors'] +edition = '2018' + +[features] +default = ['std'] +no_std = [] +std = [ + 'codec/std', + 'rstd/std', + 'srml-support/std', + 'primitives/std', + 'sr-primitives/std', + 'system/std', + 'timestamp/std', + 'staking/std', + 'serde', + 'proposal_engine/std', + 'proposal_discussion/std', + 'stake/std', + 'balances/std', + 'membership/std', + 'governance/std', + 'mint/std', + 'roles/std', +] + + +[dependencies.num_enum] +default_features = false +version = "0.4.2" + +[dependencies.serde] +features = ['derive'] +optional = true +version = '1.0.101' + +[dependencies.codec] +default-features = false +features = ['derive'] +package = 'parity-scale-codec' +version = '1.0.0' + +[dependencies.primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'substrate-primitives' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.rstd] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-std' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.sr-primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-primitives' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.srml-support] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-support' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.system] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-system' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.timestamp] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-timestamp' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.balances] +package = 'srml-balances' +default-features = false +git = 'https://github.com/paritytech/substrate.git' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.staking] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-staking' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.runtime-io] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-io' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.stake] +default_features = false +package = 'substrate-stake-module' +path = '../../stake' + +[dependencies.membership] +default_features = false +package = 'substrate-membership-module' +path = '../../membership' + +[dependencies.governance] +default_features = false +package = 'substrate-governance-module' +path = '../../governance' + +[dependencies.mint] +default_features = false +package = 'substrate-token-mint-module' +path = '../../token-minting' + +[dependencies.proposal_engine] +default_features = false +package = 'substrate-proposals-engine-module' +path = '../engine' + +[dependencies.proposal_discussion] +default_features = false +package = 'substrate-proposals-discussion-module' +path = '../discussion' + +[dependencies.common] +default_features = false +package = 'substrate-common-module' +path = '../../common' + +[dependencies.content_working_group] +default_features = false +package = 'substrate-content-working-group-module' +path = '../../content-working-group' + +[dependencies.roles] +default_features = false +package = 'substrate-roles-module' +path = '../../roles' + +[dev-dependencies.hiring] +default_features = false +package = 'substrate-hiring-module' +path = '../../hiring' + +[dev-dependencies.versioned_store] +default_features = false +package ='substrate-versioned-store' +path = '../../versioned-store' + +[dependencies.versioned_store] +default_features = false +package ='substrate-versioned-store' +path = '../../versioned-store' + +[dev-dependencies.versioned_store_permissions] +default_features = false +package = 'substrate-versioned-store-permissions-module' +path = '../../versioned-store-permissions' + +[dev-dependencies.recurring_rewards] +default_features = false +package = 'substrate-recurring-reward-module' +path = '../../recurring-reward' + +[dev-dependencies.sr-staking-primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-staking-primitives' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +# don't rename the dependency it is causing some strange compiler error: +# https://github.com/rust-lang/rust/issues/64450 +[dev-dependencies.srml-staking-reward-curve] +package = 'srml-staking-reward-curve' +git = 'https://github.com/paritytech/substrate.git' +default_features = false +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' \ No newline at end of file diff --git a/runtime-modules/proposals/codex/src/lib.rs b/runtime-modules/proposals/codex/src/lib.rs new file mode 100644 index 0000000000..d56c47466d --- /dev/null +++ b/runtime-modules/proposals/codex/src/lib.rs @@ -0,0 +1,904 @@ +//! # Proposals codex module +//! Proposals `codex` module for the Joystream platform. Version 2. +//! Component of the proposals system. It contains preset proposal types. +//! +//! ## Overview +//! +//! The proposals codex module serves as a facade and entry point of the proposals system. It uses +//! proposals `engine` module to maintain a lifecycle of the proposal and to execute proposals. +//! During the proposal creation, `codex` also create a discussion thread using the `discussion` +//! proposals module. `Codex` uses predefined parameters (eg.:`voting_period`) for each proposal and +//! encodes extrinsic calls from dependency modules in order to create proposals inside the `engine` +//! module. For each proposal, [its crucial details](./enum.ProposalDetails.html) are saved to the +//! `ProposalDetailsByProposalId` map. +//! +//! ### Supported extrinsics (proposal types) +//! - [create_text_proposal](./struct.Module.html#method.create_text_proposal) +//! - [create_runtime_upgrade_proposal](./struct.Module.html#method.create_runtime_upgrade_proposal) +//! - [create_set_election_parameters_proposal](./struct.Module.html#method.create_set_election_parameters_proposal) +//! - [create_set_content_working_group_mint_capacity_proposal](./struct.Module.html#method.create_set_content_working_group_mint_capacity_proposal) +//! - [create_spending_proposal](./struct.Module.html#method.create_spending_proposal) +//! - [create_set_lead_proposal](./struct.Module.html#method.create_set_lead_proposal) +//! - [create_evict_storage_provider_proposal](./struct.Module.html#method.create_evict_storage_provider_proposal) +//! - [create_set_validator_count_proposal](./struct.Module.html#method.create_set_validator_count_proposal) +//! - [create_set_storage_role_parameters_proposal](./struct.Module.html#method.create_set_storage_role_parameters_proposal) +//! +//! ### Proposal implementations of this module +//! - execute_text_proposal - prints the proposal to the log +//! - execute_runtime_upgrade_proposal - sets the runtime code +//! +//! ### Dependencies: +//! - [proposals engine](../substrate_proposals_engine_module/index.html) +//! - [proposals discussion](../substrate_proposals_discussion_module/index.html) +//! - [membership](../substrate_membership_module/index.html) +//! - [governance](../substrate_governance_module/index.html) +//! - [content_working_group](../substrate_content_working_group_module/index.html) +//! + +// Ensure we're `no_std` when compiling for Wasm. +#![cfg_attr(not(feature = "std"), no_std)] + +// Do not delete! Cannot be uncommented by default, because of Parity decl_module! issue. +// #![warn(missing_docs)] + +mod proposal_types; +#[cfg(test)] +mod tests; + +use codec::Encode; +use common::origin_validator::ActorOriginValidator; +use governance::election_params::ElectionParameters; +use proposal_engine::ProposalParameters; +use roles::actors::{Role, RoleParameters}; +use rstd::clone::Clone; +use rstd::convert::TryInto; +use rstd::prelude::*; +use rstd::str::from_utf8; +use rstd::vec::Vec; +use runtime_io::blake2_256; +use sr_primitives::traits::SaturatedConversion; +use sr_primitives::traits::{One, Zero}; +use sr_primitives::Perbill; +use srml_support::dispatch::DispatchResult; +use srml_support::traits::{Currency, Get}; +use srml_support::{decl_error, decl_module, decl_storage, ensure, print}; +use system::{ensure_root, RawOrigin}; + +pub use proposal_types::ProposalDetails; + +// Percentage of the total token issue as max mint balance value. Shared with spending +// proposal max balance percentage. +const COUNCIL_MINT_MAX_BALANCE_PERCENT: u32 = 2; + +/// 'Proposals codex' substrate module Trait +pub trait Trait: + system::Trait + + proposal_engine::Trait + + proposal_discussion::Trait + + membership::members::Trait + + governance::election::Trait + + content_working_group::Trait + + roles::actors::Trait + + staking::Trait +{ + /// Defines max allowed text proposal length. + type TextProposalMaxLength: Get; + + /// Defines max wasm code length of the runtime upgrade proposal. + type RuntimeUpgradeWasmProposalMaxLength: Get; + + /// Validates member id and origin combination + type MembershipOriginValidator: ActorOriginValidator< + Self::Origin, + MemberId, + Self::AccountId, + >; +} + +/// Balance alias for `stake` module +pub type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + +/// Currency alias for `stake` module +pub type CurrencyOf = ::Currency; + +/// Balance alias for GovernanceCurrency from `common` module. TODO: replace with BalanceOf +pub type BalanceOfGovernanceCurrency = + <::Currency as Currency< + ::AccountId, + >>::Balance; + +/// Balance alias for token mint balance from `token mint` module. TODO: replace with BalanceOf +pub type BalanceOfMint = + <::Currency as Currency<::AccountId>>::Balance; + +/// Negative imbalance alias for staking +pub type NegativeImbalance = + <::Currency as Currency<::AccountId>>::NegativeImbalance; + +type MemberId = ::MemberId; + +decl_error! { + /// Codex module predefined errors + pub enum Error { + /// The size of the provided text for text proposal exceeded the limit + TextProposalSizeExceeded, + + /// Provided text for text proposal is empty + TextProposalIsEmpty, + + /// The size of the provided WASM code for the runtime upgrade proposal exceeded the limit + RuntimeProposalSizeExceeded, + + /// Provided WASM code for the runtime upgrade proposal is empty + RuntimeProposalIsEmpty, + + /// Invalid balance value for the spending proposal + InvalidSpendingProposalBalance, + + /// Invalid validator count for the 'set validator count' proposal + InvalidValidatorCount, + + /// Require root origin in extrinsics + RequireRootOrigin, + + /// Invalid storage role parameter - min_actors + InvalidStorageRoleParameterMinActors, + + /// Invalid storage role parameter - max_actors + InvalidStorageRoleParameterMaxActors, + + /// Invalid storage role parameter - reward_period + InvalidStorageRoleParameterRewardPeriod, + + /// Invalid storage role parameter - bonding_period + InvalidStorageRoleParameterBondingPeriod, + + /// Invalid storage role parameter - unbonding_period + InvalidStorageRoleParameterUnbondingPeriod, + + /// Invalid storage role parameter - min_service_period + InvalidStorageRoleParameterMinServicePeriod, + + /// Invalid storage role parameter - startup_grace_period + InvalidStorageRoleParameterStartupGracePeriod, + + /// Invalid council election parameter - council_size + InvalidCouncilElectionParameterCouncilSize, + + /// Invalid council election parameter - candidacy-limit + InvalidCouncilElectionParameterCandidacyLimit, + + /// Invalid council election parameter - min-voting_stake + InvalidCouncilElectionParameterMinVotingStake, + + /// Invalid council election parameter - new_term_duration + InvalidCouncilElectionParameterNewTermDuration, + + /// Invalid council election parameter - min_council_stake + InvalidCouncilElectionParameterMinCouncilStake, + + /// Invalid council election parameter - revealing_period + InvalidCouncilElectionParameterRevealingPeriod, + + /// Invalid council election parameter - voting_period + InvalidCouncilElectionParameterVotingPeriod, + + /// Invalid council election parameter - announcing_period + InvalidCouncilElectionParameterAnnouncingPeriod, + + /// Invalid council election parameter - min_stake + InvalidStorageRoleParameterMinStake, + + /// Invalid council election parameter - reward + InvalidStorageRoleParameterReward, + + /// Invalid council election parameter - entry_request_fee + InvalidStorageRoleParameterEntryRequestFee, + + /// Invalid working group mint capacity parameter + InvalidStorageWorkingGroupMintCapacity, + + /// Invalid 'set lead proposal' parameter - proposed lead cannot be a councilor + InvalidSetLeadParameterCannotBeCouncilor + } +} + +impl From for Error { + fn from(error: system::Error) -> Self { + match error { + system::Error::Other(msg) => Error::Other(msg), + system::Error::RequireRootOrigin => Error::RequireRootOrigin, + _ => Error::Other(error.into()), + } + } +} + +impl From for Error { + fn from(error: proposal_engine::Error) -> Self { + match error { + proposal_engine::Error::Other(msg) => Error::Other(msg), + proposal_engine::Error::RequireRootOrigin => Error::RequireRootOrigin, + _ => Error::Other(error.into()), + } + } +} + +impl From for Error { + fn from(error: proposal_discussion::Error) -> Self { + match error { + proposal_discussion::Error::Other(msg) => Error::Other(msg), + proposal_discussion::Error::RequireRootOrigin => Error::RequireRootOrigin, + _ => Error::Other(error.into()), + } + } +} + +// Storage for the proposals codex module +decl_storage! { + pub trait Store for Module as ProposalCodex{ + /// Map proposal id to its discussion thread id + pub ThreadIdByProposalId get(fn thread_id_by_proposal_id): + map T::ProposalId => T::ThreadId; + + /// Map proposal id to proposal details + pub ProposalDetailsByProposalId get(fn proposal_details_by_proposal_id): + map T::ProposalId => ProposalDetails< + BalanceOfMint, + BalanceOfGovernanceCurrency, + T::BlockNumber, + T::AccountId, + T::MemberId + >; + } +} + +decl_module! { + /// Proposal codex substrate module Call + pub struct Module for enum Call where origin: T::Origin { + /// Predefined errors + type Error = Error; + + /// Create 'Text (signal)' proposal type. + pub fn create_text_proposal( + origin, + member_id: MemberId, + title: Vec, + description: Vec, + stake_balance: Option>, + text: Vec, + ) { + ensure!(!text.is_empty(), Error::TextProposalIsEmpty); + ensure!(text.len() as u32 <= T::TextProposalMaxLength::get(), + Error::TextProposalSizeExceeded); + + let proposal_parameters = proposal_types::parameters::text_proposal::(); + let proposal_code = + >::execute_text_proposal(title.clone(), description.clone(), text.clone()); + + Self::create_proposal( + origin, + member_id, + title, + description, + stake_balance, + proposal_code.encode(), + proposal_parameters, + ProposalDetails::Text(text), + )?; + } + + /// Create 'Runtime upgrade' proposal type. Runtime upgrade can be initiated only by + /// members from the hardcoded list `RuntimeUpgradeProposalAllowedProposers` + pub fn create_runtime_upgrade_proposal( + origin, + member_id: MemberId, + title: Vec, + description: Vec, + stake_balance: Option>, + wasm: Vec, + ) { + ensure!(!wasm.is_empty(), Error::RuntimeProposalIsEmpty); + ensure!(wasm.len() as u32 <= T::RuntimeUpgradeWasmProposalMaxLength::get(), + Error::RuntimeProposalSizeExceeded); + + let wasm_hash = blake2_256(&wasm); + + let proposal_code = + >::execute_runtime_upgrade_proposal(title.clone(), description.clone(), wasm); + + let proposal_parameters = proposal_types::parameters::runtime_upgrade_proposal::(); + + Self::create_proposal( + origin, + member_id, + title, + description, + stake_balance, + proposal_code.encode(), + proposal_parameters, + ProposalDetails::RuntimeUpgrade(wasm_hash.to_vec()), + )?; + } + + /// Create 'Set election parameters' proposal type. This proposal uses `set_election_parameters()` + /// extrinsic from the `governance::election module`. + pub fn create_set_election_parameters_proposal( + origin, + member_id: MemberId, + title: Vec, + description: Vec, + stake_balance: Option>, + election_parameters: ElectionParameters, T::BlockNumber>, + ) { + election_parameters.ensure_valid()?; + + Self::ensure_council_election_parameters_valid(&election_parameters)?; + + let proposal_code = + >::set_election_parameters(election_parameters.clone()); + + let proposal_parameters = + proposal_types::parameters::set_election_parameters_proposal::(); + + Self::create_proposal( + origin, + member_id, + title, + description, + stake_balance, + proposal_code.encode(), + proposal_parameters, + ProposalDetails::SetElectionParameters(election_parameters), + )?; + } + + /// Create 'Set content working group mint capacity' proposal type. + /// This proposal uses `set_mint_capacity()` extrinsic from the `content-working-group` module. + pub fn create_set_content_working_group_mint_capacity_proposal( + origin, + member_id: MemberId, + title: Vec, + description: Vec, + stake_balance: Option>, + mint_balance: BalanceOfMint, + ) { + + let max_mint_capacity: u32 = get_required_stake_by_fraction::(1, 100) + .try_into() + .unwrap_or_default() as u32; + ensure!( + mint_balance < >::from(max_mint_capacity), + Error::InvalidStorageWorkingGroupMintCapacity + ); + + let proposal_code = + >::set_mint_capacity(mint_balance.clone()); + + let proposal_parameters = + proposal_types::parameters::set_content_working_group_mint_capacity_proposal::(); + + Self::create_proposal( + origin, + member_id, + title, + description, + stake_balance, + proposal_code.encode(), + proposal_parameters, + ProposalDetails::SetContentWorkingGroupMintCapacity(mint_balance), + )?; + } + + /// Create 'Spending' proposal type. + /// This proposal uses `spend_from_council_mint()` extrinsic from the `governance::council` module. + pub fn create_spending_proposal( + origin, + member_id: MemberId, + title: Vec, + description: Vec, + stake_balance: Option>, + balance: BalanceOfMint, + destination: T::AccountId, + ) { + ensure!(balance != BalanceOfMint::::zero(), Error::InvalidSpendingProposalBalance); + + let max_balance: u32 = get_required_stake_by_fraction::( + COUNCIL_MINT_MAX_BALANCE_PERCENT, + 100 + ) + .try_into() + .unwrap_or_default() as u32; + + ensure!( + balance < >::from(max_balance), + Error::InvalidSpendingProposalBalance + ); + + let proposal_code = >::spend_from_council_mint( + balance.clone(), + destination.clone() + ); + + let proposal_parameters = + proposal_types::parameters::spending_proposal::(); + + Self::create_proposal( + origin, + member_id, + title, + description, + stake_balance, + proposal_code.encode(), + proposal_parameters, + ProposalDetails::Spending(balance, destination), + )?; + } + + + /// Create 'Set lead' proposal type. + /// This proposal uses `replace_lead()` extrinsic from the `content_working_group` module. + pub fn create_set_lead_proposal( + origin, + member_id: MemberId, + title: Vec, + description: Vec, + stake_balance: Option>, + new_lead: Option<(T::MemberId, T::AccountId)> + ) { + if let Some(lead) = new_lead.clone() { + let account_id = lead.1; + ensure!( + !>::is_councilor(&account_id), + Error::InvalidSetLeadParameterCannotBeCouncilor + ); + } + + let proposal_code = + >::replace_lead(new_lead.clone()); + + let proposal_parameters = + proposal_types::parameters::set_lead_proposal::(); + + Self::create_proposal( + origin, + member_id, + title, + description, + stake_balance, + proposal_code.encode(), + proposal_parameters, + ProposalDetails::SetLead(new_lead), + )?; + } + + /// Create 'Evict storage provider' proposal type. + /// This proposal uses `remove_actor()` extrinsic from the `roles::actors` module. + pub fn create_evict_storage_provider_proposal( + origin, + member_id: MemberId, + title: Vec, + description: Vec, + stake_balance: Option>, + actor_account: T::AccountId, + ) { + let proposal_code = + >::remove_actor(actor_account.clone()); + + let proposal_parameters = + proposal_types::parameters::evict_storage_provider_proposal::(); + + Self::create_proposal( + origin, + member_id, + title, + description, + stake_balance, + proposal_code.encode(), + proposal_parameters, + ProposalDetails::EvictStorageProvider(actor_account), + )?; + } + + /// Create 'Evict storage provider' proposal type. + /// This proposal uses `set_validator_count()` extrinsic from the Substrate `staking` module. + pub fn create_set_validator_count_proposal( + origin, + member_id: MemberId, + title: Vec, + description: Vec, + stake_balance: Option>, + new_validator_count: u32, + ) { + ensure!( + new_validator_count >= >::minimum_validator_count(), + Error::InvalidValidatorCount + ); + + ensure!( + new_validator_count <= 1000, // max validator count + Error::InvalidValidatorCount + ); + + let proposal_code = + >::set_validator_count(new_validator_count); + + let proposal_parameters = + proposal_types::parameters::set_validator_count_proposal::(); + + Self::create_proposal( + origin, + member_id, + title, + description, + stake_balance, + proposal_code.encode(), + proposal_parameters, + ProposalDetails::SetValidatorCount(new_validator_count), + )?; + } + + /// Create 'Set storage roles parameters' proposal type. + /// This proposal uses `set_role_parameters()` extrinsic from the Substrate `roles::actors` module. + pub fn create_set_storage_role_parameters_proposal( + origin, + member_id: MemberId, + title: Vec, + description: Vec, + stake_balance: Option>, + role_parameters: RoleParameters, T::BlockNumber> + ) { + Self::ensure_storage_role_parameters_valid(&role_parameters)?; + + let proposal_code = >::set_role_parameters( + Role::StorageProvider, + role_parameters.clone() + ); + + let proposal_parameters = + proposal_types::parameters::set_storage_role_parameters_proposal::(); + + Self::create_proposal( + origin, + member_id, + title, + description, + stake_balance, + proposal_code.encode(), + proposal_parameters, + ProposalDetails::SetStorageRoleParameters(role_parameters), + )?; + } + +// *************** Extrinsic to execute + + /// Text proposal extrinsic. Should be used as callable object to pass to the `engine` module. + fn execute_text_proposal( + origin, + title: Vec, + _description: Vec, + _text: Vec, + ) { + ensure_root(origin)?; + print("Text proposal: "); + let title_string_result = from_utf8(title.as_slice()); + if let Ok(title_string) = title_string_result{ + print(title_string); + } + } + + /// Runtime upgrade proposal extrinsic. + /// Should be used as callable object to pass to the `engine` module. + fn execute_runtime_upgrade_proposal( + origin, + title: Vec, + _description: Vec, + wasm: Vec, + ) { + let (cloned_origin1, cloned_origin2) = Self::double_origin(origin); + ensure_root(cloned_origin1)?; + + print("Runtime upgrade proposal: "); + let title_string_result = from_utf8(title.as_slice()); + if let Ok(title_string) = title_string_result{ + print(title_string); + } + + >::set_code(cloned_origin2, wasm)?; + } + } +} + +impl Module { + // Multiplies the T::Origin. + // In our current substrate version system::Origin doesn't support clone(), + // but it will be supported in latest up-to-date substrate version. + // TODO: delete when T::Origin will support the clone() + fn double_origin(origin: T::Origin) -> (T::Origin, T::Origin) { + let coerced_origin = origin.into().ok().unwrap_or(RawOrigin::None); + + let (cloned_origin1, cloned_origin2) = match coerced_origin { + RawOrigin::None => (RawOrigin::None, RawOrigin::None), + RawOrigin::Root => (RawOrigin::Root, RawOrigin::Root), + RawOrigin::Signed(account_id) => ( + RawOrigin::Signed(account_id.clone()), + RawOrigin::Signed(account_id), + ), + }; + + (cloned_origin1.into(), cloned_origin2.into()) + } + + // Generic template proposal builder + fn create_proposal( + origin: T::Origin, + member_id: MemberId, + title: Vec, + description: Vec, + stake_balance: Option>, + proposal_code: Vec, + proposal_parameters: ProposalParameters>, + proposal_details: ProposalDetails< + BalanceOfMint, + BalanceOfGovernanceCurrency, + T::BlockNumber, + T::AccountId, + T::MemberId, + >, + ) -> DispatchResult { + let account_id = + T::MembershipOriginValidator::ensure_actor_origin(origin, member_id.clone())?; + + >::ensure_create_proposal_parameters_are_valid( + &proposal_parameters, + &title, + &description, + stake_balance, + )?; + + >::ensure_can_create_thread(member_id.clone(), &title)?; + + let discussion_thread_id = + >::create_thread(member_id, title.clone())?; + + let proposal_id = >::create_proposal( + account_id, + member_id, + proposal_parameters, + title, + description, + stake_balance, + proposal_code, + )?; + + >::insert(proposal_id, discussion_thread_id); + >::insert(proposal_id, proposal_details); + + Ok(()) + } + + // validates storage role parameters for the 'Set storage role parameters' proposal + fn ensure_storage_role_parameters_valid( + role_parameters: &RoleParameters, T::BlockNumber>, + ) -> Result<(), Error> { + ensure!( + role_parameters.min_actors <= 5, + Error::InvalidStorageRoleParameterMinActors + ); + + ensure!( + role_parameters.max_actors >= 5, + Error::InvalidStorageRoleParameterMaxActors + ); + + ensure!( + role_parameters.max_actors < 100, + Error::InvalidStorageRoleParameterMaxActors + ); + + ensure!( + role_parameters.reward_period >= T::BlockNumber::from(600), + Error::InvalidStorageRoleParameterRewardPeriod + ); + + ensure!( + role_parameters.reward_period <= T::BlockNumber::from(3600), + Error::InvalidStorageRoleParameterRewardPeriod + ); + + ensure!( + role_parameters.bonding_period >= T::BlockNumber::from(600), + Error::InvalidStorageRoleParameterBondingPeriod + ); + + ensure!( + role_parameters.bonding_period <= T::BlockNumber::from(28800), + Error::InvalidStorageRoleParameterBondingPeriod + ); + + ensure!( + role_parameters.unbonding_period >= T::BlockNumber::from(600), + Error::InvalidStorageRoleParameterUnbondingPeriod + ); + + ensure!( + role_parameters.unbonding_period <= T::BlockNumber::from(28800), + Error::InvalidStorageRoleParameterUnbondingPeriod + ); + + ensure!( + role_parameters.min_service_period >= T::BlockNumber::from(600), + Error::InvalidStorageRoleParameterMinServicePeriod + ); + + ensure!( + role_parameters.min_service_period <= T::BlockNumber::from(28800), + Error::InvalidStorageRoleParameterMinServicePeriod + ); + + ensure!( + role_parameters.startup_grace_period >= T::BlockNumber::from(600), + Error::InvalidStorageRoleParameterStartupGracePeriod + ); + + ensure!( + role_parameters.startup_grace_period <= T::BlockNumber::from(28800), + Error::InvalidStorageRoleParameterStartupGracePeriod + ); + + ensure!( + role_parameters.min_stake > >::from(0u32), + Error::InvalidStorageRoleParameterMinStake + ); + + let max_min_stake: u32 = get_required_stake_by_fraction::(1, 100) + .try_into() + .unwrap_or_default() as u32; + + ensure!( + role_parameters.min_stake < >::from(max_min_stake), + Error::InvalidStorageRoleParameterMinStake + ); + + ensure!( + role_parameters.entry_request_fee > >::from(0u32), + Error::InvalidStorageRoleParameterEntryRequestFee + ); + + let max_entry_request_fee: u32 = get_required_stake_by_fraction::(1, 100) + .try_into() + .unwrap_or_default() as u32; + + ensure!( + role_parameters.entry_request_fee + < >::from(max_entry_request_fee), + Error::InvalidStorageRoleParameterEntryRequestFee + ); + + ensure!( + role_parameters.reward > >::from(0u32), + Error::InvalidStorageRoleParameterReward + ); + + let max_reward: u32 = get_required_stake_by_fraction::(1, 1000) + .try_into() + .unwrap_or_default() as u32; + + ensure!( + role_parameters.reward < >::from(max_reward), + Error::InvalidStorageRoleParameterReward + ); + + Ok(()) + } + + /* + entry_request_fee [tJOY] >0 <1% NA + * Not enforced by runtime. Should not be displayed in the UI, or at least grayed out. + ** Should not be displayed in the UI, or at least grayed out. + */ + + // validates council election parameters for the 'Set election parameters' proposal + pub(crate) fn ensure_council_election_parameters_valid( + election_parameters: &ElectionParameters, T::BlockNumber>, + ) -> Result<(), Error> { + ensure!( + election_parameters.council_size >= 4, + Error::InvalidCouncilElectionParameterCouncilSize + ); + + ensure!( + election_parameters.council_size <= 20, + Error::InvalidCouncilElectionParameterCouncilSize + ); + + ensure!( + election_parameters.candidacy_limit >= 25, + Error::InvalidCouncilElectionParameterCandidacyLimit + ); + + ensure!( + election_parameters.candidacy_limit <= 100, + Error::InvalidCouncilElectionParameterCandidacyLimit + ); + + ensure!( + election_parameters.min_voting_stake >= >::one(), + Error::InvalidCouncilElectionParameterMinVotingStake + ); + + ensure!( + election_parameters.min_voting_stake + <= >::from(100000u32), + Error::InvalidCouncilElectionParameterMinVotingStake + ); + + ensure!( + election_parameters.new_term_duration >= T::BlockNumber::from(14400), + Error::InvalidCouncilElectionParameterNewTermDuration + ); + + ensure!( + election_parameters.new_term_duration <= T::BlockNumber::from(432000), + Error::InvalidCouncilElectionParameterNewTermDuration + ); + + ensure!( + election_parameters.revealing_period >= T::BlockNumber::from(14400), + Error::InvalidCouncilElectionParameterRevealingPeriod + ); + + ensure!( + election_parameters.revealing_period <= T::BlockNumber::from(43200), + Error::InvalidCouncilElectionParameterRevealingPeriod + ); + + ensure!( + election_parameters.voting_period >= T::BlockNumber::from(14400), + Error::InvalidCouncilElectionParameterVotingPeriod + ); + + ensure!( + election_parameters.voting_period <= T::BlockNumber::from(43200), + Error::InvalidCouncilElectionParameterVotingPeriod + ); + + ensure!( + election_parameters.announcing_period >= T::BlockNumber::from(14400), + Error::InvalidCouncilElectionParameterAnnouncingPeriod + ); + + ensure!( + election_parameters.announcing_period <= T::BlockNumber::from(43200), + Error::InvalidCouncilElectionParameterAnnouncingPeriod + ); + + ensure!( + election_parameters.min_council_stake >= >::one(), + Error::InvalidCouncilElectionParameterMinCouncilStake + ); + + ensure!( + election_parameters.min_council_stake + <= >::from(100000u32), + Error::InvalidCouncilElectionParameterMinCouncilStake + ); + + Ok(()) + } +} + +// calculates required stake value using total issuance value and stake percentage. Truncates to +// lowest integer value. Value fraction is defined by numerator and denominator. +pub(crate) fn get_required_stake_by_fraction( + numerator: u32, + denominator: u32, +) -> BalanceOf { + let total_issuance: u128 = >::total_issuance().try_into().unwrap_or(0) as u128; + let required_stake = + Perbill::from_rational_approximation(numerator, denominator) * total_issuance; + + let balance: BalanceOf = required_stake.saturated_into(); + + balance +} diff --git a/runtime-modules/proposals/codex/src/proposal_types/mod.rs b/runtime-modules/proposals/codex/src/proposal_types/mod.rs new file mode 100644 index 0000000000..e9e7bc93dc --- /dev/null +++ b/runtime-modules/proposals/codex/src/proposal_types/mod.rs @@ -0,0 +1,49 @@ +pub(crate) mod parameters; + +use codec::{Decode, Encode}; +use rstd::vec::Vec; +#[cfg(feature = "std")] +use serde::{Deserialize, Serialize}; + +use crate::ElectionParameters; +use roles::actors::RoleParameters; + +/// Proposal details provide voters the information required for the perceived voting. +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Clone, PartialEq, Debug)] +pub enum ProposalDetails { + /// The text of the `text` proposal + Text(Vec), + + /// The hash of wasm code for the `runtime upgrade` proposal + RuntimeUpgrade(Vec), + + /// Election parameters for the `set election parameters` proposal + SetElectionParameters(ElectionParameters), + + /// Balance and destination account for the `spending` proposal + Spending(MintedBalance, AccountId), + + /// New leader memberId and account_id for the `set lead` proposal + SetLead(Option<(MemberId, AccountId)>), + + /// Balance for the `set content working group mint capacity` proposal + SetContentWorkingGroupMintCapacity(MintedBalance), + + /// AccountId for the `evict storage provider` proposal + EvictStorageProvider(AccountId), + + /// Validator count for the `set validator count` proposal + SetValidatorCount(u32), + + /// Role parameters for the `set storage role parameters` proposal + SetStorageRoleParameters(RoleParameters), +} + +impl Default + for ProposalDetails +{ + fn default() -> Self { + ProposalDetails::Text(b"invalid proposal details".to_vec()) + } +} diff --git a/runtime-modules/proposals/codex/src/proposal_types/parameters.rs b/runtime-modules/proposals/codex/src/proposal_types/parameters.rs new file mode 100644 index 0000000000..031c17de09 --- /dev/null +++ b/runtime-modules/proposals/codex/src/proposal_types/parameters.rs @@ -0,0 +1,156 @@ +use crate::{get_required_stake_by_fraction, BalanceOf, ProposalParameters}; + +// Proposal parameters for the 'Set validator count' proposal +pub(crate) fn set_validator_count_proposal( +) -> ProposalParameters> { + ProposalParameters { + voting_period: T::BlockNumber::from(43200u32), + grace_period: T::BlockNumber::from(0u32), + approval_quorum_percentage: 66, + approval_threshold_percentage: 80, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 80, + required_stake: Some(get_required_stake_by_fraction::(25, 10000)), + } +} + +// Proposal parameters for the upgrade runtime proposal +pub(crate) fn runtime_upgrade_proposal( +) -> ProposalParameters> { + ProposalParameters { + voting_period: T::BlockNumber::from(72000u32), + grace_period: T::BlockNumber::from(72000u32), + approval_quorum_percentage: 80, + approval_threshold_percentage: 100, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 80, + required_stake: Some(get_required_stake_by_fraction::(1, 100)), + } +} + +// Proposal parameters for the text proposal +pub(crate) fn text_proposal() -> ProposalParameters> { + ProposalParameters { + voting_period: T::BlockNumber::from(72000u32), + grace_period: T::BlockNumber::from(0u32), + approval_quorum_percentage: 66, + approval_threshold_percentage: 80, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 80, + required_stake: Some(get_required_stake_by_fraction::(25, 10000)), + } +} + +// Proposal parameters for the 'Set Election Parameters' proposal +pub(crate) fn set_election_parameters_proposal( +) -> ProposalParameters> { + ProposalParameters { + voting_period: T::BlockNumber::from(72000u32), + grace_period: T::BlockNumber::from(201601u32), + approval_quorum_percentage: 66, + approval_threshold_percentage: 80, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 80, + required_stake: Some(get_required_stake_by_fraction::(75, 10000)), + } +} + +// Proposal parameters for the 'Set content working group mint capacity' proposal +pub(crate) fn set_content_working_group_mint_capacity_proposal( +) -> ProposalParameters> { + ProposalParameters { + voting_period: T::BlockNumber::from(43200u32), + grace_period: T::BlockNumber::from(0u32), + approval_quorum_percentage: 50, + approval_threshold_percentage: 75, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 80, + required_stake: Some(get_required_stake_by_fraction::(25, 10000)), + } +} + +// Proposal parameters for the 'Spending' proposal +pub(crate) fn spending_proposal( +) -> ProposalParameters> { + ProposalParameters { + voting_period: T::BlockNumber::from(72000u32), + grace_period: T::BlockNumber::from(14400u32), + approval_quorum_percentage: 66, + approval_threshold_percentage: 80, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 80, + required_stake: Some(get_required_stake_by_fraction::(25, 10000)), + } +} + +// Proposal parameters for the 'Set lead' proposal +pub(crate) fn set_lead_proposal( +) -> ProposalParameters> { + ProposalParameters { + voting_period: T::BlockNumber::from(43200u32), + grace_period: T::BlockNumber::from(0u32), + approval_quorum_percentage: 66, + approval_threshold_percentage: 80, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 80, + required_stake: Some(get_required_stake_by_fraction::(25, 10000)), + } +} + +// Proposal parameters for the 'Evict storage provider' proposal +pub(crate) fn evict_storage_provider_proposal( +) -> ProposalParameters> { + ProposalParameters { + voting_period: T::BlockNumber::from(43200u32), + grace_period: T::BlockNumber::from(0u32), + approval_quorum_percentage: 50, + approval_threshold_percentage: 75, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 80, + required_stake: Some(get_required_stake_by_fraction::(1, 1000)), + } +} + +// Proposal parameters for the 'Set storage role parameters' proposal +pub(crate) fn set_storage_role_parameters_proposal( +) -> ProposalParameters> { + ProposalParameters { + voting_period: T::BlockNumber::from(43200u32), + grace_period: T::BlockNumber::from(14400u32), + approval_quorum_percentage: 75, + approval_threshold_percentage: 80, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 80, + required_stake: Some(get_required_stake_by_fraction::(25, 10000)), + } +} + +#[cfg(test)] +mod test { + use crate::proposal_types::parameters::get_required_stake_by_fraction; + use crate::tests::{increase_total_balance_issuance, initial_test_ext, Test}; + + pub use sr_primitives::Perbill; + + #[test] + fn calculate_get_required_stake_by_fraction_with_zero_issuance() { + initial_test_ext() + .execute_with(|| assert_eq!(get_required_stake_by_fraction::(5, 7), 0)); + } + + #[test] + fn calculate_stake_by_percentage_for_defined_issuance_succeeds() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(50000); + assert_eq!(get_required_stake_by_fraction::(1, 1000), 50) + }); + } + + #[test] + fn calculate_stake_by_percentage_for_defined_issuance_with_fraction_loss() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(1111); + assert_eq!(get_required_stake_by_fraction::(3, 1000), 3); + }); + } +} diff --git a/runtime-modules/proposals/codex/src/tests/mock.rs b/runtime-modules/proposals/codex/src/tests/mock.rs new file mode 100644 index 0000000000..7b80011871 --- /dev/null +++ b/runtime-modules/proposals/codex/src/tests/mock.rs @@ -0,0 +1,288 @@ +#![cfg(test)] +// srml_staking_reward_curve::build! - substrate macro produces a warning. +// TODO: remove after post-Rome substrate upgrade +#![allow(array_into_iter)] + +pub use primitives::{Blake2Hasher, H256}; +use proposal_engine::VotersParameters; +use sr_primitives::curve::PiecewiseLinear; +pub use sr_primitives::{ + testing::{Digest, DigestItem, Header, UintAuthorityId}, + traits::{BlakeTwo256, Convert, IdentityLookup, OnFinalize}, + weights::Weight, + BuildStorage, DispatchError, Perbill, +}; +use sr_staking_primitives::SessionIndex; +use srml_support::{impl_outer_dispatch, impl_outer_origin, parameter_types}; +pub use system; + +impl_outer_origin! { + pub enum Origin for Test {} +} + +// Workaround for https://github.com/rust-lang/rust/issues/26925 . Remove when sorted. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Test; +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const MaximumBlockWeight: u32 = 1024; + pub const MaximumBlockLength: u32 = 2 * 1024; + pub const AvailableBlockRatio: Perbill = Perbill::one(); + pub const MinimumPeriod: u64 = 5; + pub const StakePoolId: [u8; 8] = *b"joystake"; +} + +impl_outer_dispatch! { + pub enum Call for Test where origin: Origin { + codex::ProposalCodex, + proposals::ProposalsEngine, + } +} + +impl common::currency::GovernanceCurrency for Test { + type Currency = balances::Module; +} + +impl membership::members::Trait for Test { + type Event = (); + type MemberId = u64; + type PaidTermId = u64; + type SubscriptionId = u64; + type ActorId = u64; + type InitialMembersBalance = (); +} + +parameter_types! { + pub const ExistentialDeposit: u32 = 0; + pub const TransferFee: u32 = 0; + pub const CreationFee: u32 = 0; +} + +impl balances::Trait for Test { + /// The type for recording an account's balance. + type Balance = u64; + /// What to do if an account's free balance gets zeroed. + type OnFreeBalanceZero = (); + /// What to do if a new account is created. + type OnNewAccount = (); + + type Event = (); + + type DustRemoval = (); + type TransferPayment = (); + type ExistentialDeposit = ExistentialDeposit; + type TransferFee = TransferFee; + type CreationFee = CreationFee; +} + +impl stake::Trait for Test { + type Currency = Balances; + type StakePoolId = StakePoolId; + type StakingEventsHandler = (); + type StakeId = u64; + type SlashId = u64; +} + +parameter_types! { + pub const CancellationFee: u64 = 5; + pub const RejectionFee: u64 = 3; + pub const TitleMaxLength: u32 = 100; + pub const DescriptionMaxLength: u32 = 10000; + pub const MaxActiveProposalLimit: u32 = 100; +} + +impl proposal_engine::Trait for Test { + type Event = (); + type ProposerOriginValidator = (); + type VoterOriginValidator = (); + type TotalVotersCounter = MockVotersParameters; + type ProposalId = u32; + type StakeHandlerProvider = proposal_engine::DefaultStakeHandlerProvider; + type CancellationFee = CancellationFee; + type RejectionFee = RejectionFee; + type TitleMaxLength = TitleMaxLength; + type DescriptionMaxLength = DescriptionMaxLength; + type MaxActiveProposalLimit = MaxActiveProposalLimit; + type DispatchableCallCode = crate::Call; +} + +impl Default for crate::Call { + fn default() -> Self { + panic!("shouldn't call default for Call"); + } +} + +impl mint::Trait for Test { + type Currency = Balances; + type MintId = u64; +} + +impl governance::council::Trait for Test { + type Event = (); + type CouncilTermEnded = (); +} + +impl common::origin_validator::ActorOriginValidator for () { + fn ensure_actor_origin(origin: Origin, _: u64) -> Result { + let account_id = system::ensure_signed(origin)?; + + Ok(account_id) + } +} + +parameter_types! { + pub const MaxPostEditionNumber: u32 = 5; + pub const MaxThreadInARowNumber: u32 = 3; + pub const ThreadTitleLengthLimit: u32 = 200; + pub const PostLengthLimit: u32 = 2000; +} + +impl proposal_discussion::Trait for Test { + type Event = (); + type PostAuthorOriginValidator = (); + type ThreadId = u32; + type PostId = u32; + type MaxPostEditionNumber = MaxPostEditionNumber; + type ThreadTitleLengthLimit = ThreadTitleLengthLimit; + type PostLengthLimit = PostLengthLimit; + type MaxThreadInARowNumber = MaxThreadInARowNumber; +} + +pub struct MockVotersParameters; +impl VotersParameters for MockVotersParameters { + fn total_voters_count() -> u32 { + 4 + } +} + +parameter_types! { + pub const TextProposalMaxLength: u32 = 20_000; + pub const RuntimeUpgradeWasmProposalMaxLength: u32 = 20_000; +} + +impl governance::election::Trait for Test { + type Event = (); + type CouncilElected = (); +} + +impl content_working_group::Trait for Test { + type Event = (); +} + +impl recurring_rewards::Trait for Test { + type PayoutStatusHandler = (); + type RecipientId = u64; + type RewardRelationshipId = u64; +} + +impl versioned_store_permissions::Trait for Test { + type Credential = u64; + type CredentialChecker = (); + type CreateClassPermissionsChecker = (); +} + +impl versioned_store::Trait for Test { + type Event = (); +} + +impl hiring::Trait for Test { + type OpeningId = u64; + type ApplicationId = u64; + type ApplicationDeactivatedHandler = (); + type StakeHandlerProvider = hiring::Module; +} + +impl roles::actors::Trait for Test { + type Event = (); + type OnActorRemoved = (); +} + +impl roles::actors::ActorRemoved for () { + fn actor_removed(_: &u64) {} +} + +srml_staking_reward_curve::build! { + const I_NPOS: PiecewiseLinear<'static> = curve!( + min_inflation: 0_025_000, + max_inflation: 0_100_000, + ideal_stake: 0_500_000, + falloff: 0_050_000, + max_piece_count: 40, + test_precision: 0_005_000, + ); +} + +parameter_types! { + pub const SessionsPerEra: SessionIndex = 3; + pub const BondingDuration: staking::EraIndex = 3; + pub const RewardCurve: &'static PiecewiseLinear<'static> = &I_NPOS; +} +impl staking::Trait for Test { + type Currency = balances::Module; + type Time = timestamp::Module; + type CurrencyToVote = (); + type RewardRemainder = (); + type Event = (); + type Slash = (); + type Reward = (); + type SessionsPerEra = SessionsPerEra; + type BondingDuration = BondingDuration; + type SessionInterface = Self; + type RewardCurve = RewardCurve; +} + +impl staking::SessionInterface for Test { + fn disable_validator(_: &u64) -> Result { + unimplemented!() + } + + fn validators() -> Vec { + unimplemented!() + } + + fn prune_historical_up_to(_: u32) { + unimplemented!() + } +} + +impl crate::Trait for Test { + type TextProposalMaxLength = TextProposalMaxLength; + type RuntimeUpgradeWasmProposalMaxLength = RuntimeUpgradeWasmProposalMaxLength; + type MembershipOriginValidator = (); +} + +impl system::Trait for Test { + type Origin = Origin; + type Index = u64; + type BlockNumber = u64; + type Call = (); + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type Event = (); + type BlockHashCount = BlockHashCount; + type MaximumBlockWeight = MaximumBlockWeight; + type MaximumBlockLength = MaximumBlockLength; + type AvailableBlockRatio = AvailableBlockRatio; + type Version = (); +} + +impl timestamp::Trait for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = MinimumPeriod; +} + +pub fn initial_test_ext() -> runtime_io::TestExternalities { + let t = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + t.into() +} + +pub type ProposalCodex = crate::Module; +pub type ProposalsEngine = proposal_engine::Module; +pub type Balances = balances::Module; diff --git a/runtime-modules/proposals/codex/src/tests/mod.rs b/runtime-modules/proposals/codex/src/tests/mod.rs new file mode 100644 index 0000000000..d66127363f --- /dev/null +++ b/runtime-modules/proposals/codex/src/tests/mod.rs @@ -0,0 +1,1054 @@ +mod mock; + +use governance::election_params::ElectionParameters; +use srml_support::traits::Currency; +use srml_support::StorageMap; +use system::RawOrigin; + +use crate::{BalanceOf, Error, ProposalDetails}; +use proposal_engine::ProposalParameters; +use roles::actors::RoleParameters; +use runtime_io::blake2_256; +use srml_support::dispatch::DispatchResult; + +pub use mock::*; + +pub(crate) fn increase_total_balance_issuance(balance: u64) { + increase_total_balance_issuance_using_account_id(999, balance); +} + +pub(crate) fn increase_total_balance_issuance_using_account_id(account_id: u64, balance: u64) { + let initial_balance = Balances::total_issuance(); + { + let _ = ::Currency::deposit_creating(&account_id, balance); + } + assert_eq!(Balances::total_issuance(), initial_balance + balance); +} + +struct ProposalTestFixture +where + InsufficientRightsCall: Fn() -> DispatchResult, + EmptyStakeCall: Fn() -> DispatchResult, + InvalidStakeCall: Fn() -> DispatchResult, + SuccessfulCall: Fn() -> DispatchResult, +{ + insufficient_rights_call: InsufficientRightsCall, + empty_stake_call: EmptyStakeCall, + invalid_stake_call: InvalidStakeCall, + successful_call: SuccessfulCall, + proposal_parameters: ProposalParameters, + proposal_details: ProposalDetails, +} + +impl + ProposalTestFixture +where + InsufficientRightsCall: Fn() -> DispatchResult, + EmptyStakeCall: Fn() -> DispatchResult, + InvalidStakeCall: Fn() -> DispatchResult, + SuccessfulCall: Fn() -> DispatchResult, +{ + fn check_for_invalid_stakes(&self) { + assert_eq!((self.empty_stake_call)(), Err(Error::Other("EmptyStake"))); + + assert_eq!( + (self.invalid_stake_call)(), + Err(Error::Other("StakeDiffersFromRequired")) + ); + } + + fn check_call_for_insufficient_rights(&self) { + assert_eq!( + (self.insufficient_rights_call)(), + Err(Error::Other("RequireSignedOrigin")) + ); + } + + fn check_for_successful_call(&self) { + let account_id = 1; + let _imbalance = ::Currency::deposit_creating(&account_id, 50000); + + assert_eq!((self.successful_call)(), Ok(())); + + // a discussion was created + let thread_id = >::get(1); + assert_eq!(thread_id, 1); + + let proposal_id = 1; + let proposal = ProposalsEngine::proposals(proposal_id); + // check for correct proposal parameters + assert_eq!(proposal.parameters, self.proposal_parameters); + + // proposal details was set + let details = >::get(proposal_id); + assert_eq!(details, self.proposal_details); + } + + pub fn check_all(&self) { + self.check_call_for_insufficient_rights(); + self.check_for_invalid_stakes(); + self.check_for_successful_call(); + } +} + +#[test] +fn create_text_proposal_common_checks_succeed() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + let proposal_fixture = ProposalTestFixture { + insufficient_rights_call: || { + ProposalCodex::create_text_proposal( + RawOrigin::None.into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + b"text".to_vec(), + ) + }, + empty_stake_call: || { + ProposalCodex::create_text_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + b"text".to_vec(), + ) + }, + invalid_stake_call: || { + ProposalCodex::create_text_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(5000u32)), + b"text".to_vec(), + ) + }, + successful_call: || { + ProposalCodex::create_text_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1250u32)), + b"text".to_vec(), + ) + }, + proposal_parameters: crate::proposal_types::parameters::text_proposal::(), + proposal_details: ProposalDetails::Text(b"text".to_vec()), + }; + proposal_fixture.check_all(); + }); +} + +#[test] +fn create_text_proposal_codex_call_fails_with_incorrect_text_size() { + initial_test_ext().execute_with(|| { + let origin = RawOrigin::Signed(1).into(); + + let long_text = [0u8; 30000].to_vec(); + assert_eq!( + ProposalCodex::create_text_proposal( + origin, + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + long_text, + ), + Err(Error::TextProposalSizeExceeded) + ); + + assert_eq!( + ProposalCodex::create_text_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + Vec::new(), + ), + Err(Error::TextProposalIsEmpty) + ); + }); +} + +#[test] +fn create_runtime_upgrade_common_checks_succeed() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + let proposal_fixture = ProposalTestFixture { + insufficient_rights_call: || { + ProposalCodex::create_runtime_upgrade_proposal( + RawOrigin::None.into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + b"wasm".to_vec(), + ) + }, + empty_stake_call: || { + ProposalCodex::create_runtime_upgrade_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + b"wasm".to_vec(), + ) + }, + invalid_stake_call: || { + ProposalCodex::create_runtime_upgrade_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(500u32)), + b"wasm".to_vec(), + ) + }, + successful_call: || { + ProposalCodex::create_runtime_upgrade_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(5000u32)), + b"wasm".to_vec(), + ) + }, + proposal_parameters: crate::proposal_types::parameters::runtime_upgrade_proposal::(), + proposal_details: ProposalDetails::RuntimeUpgrade(blake2_256(b"wasm").to_vec()), + }; + proposal_fixture.check_all(); + }); +} + +#[test] +fn create_upgrade_runtime_proposal_codex_call_fails_with_incorrect_wasm_size() { + initial_test_ext().execute_with(|| { + let origin = RawOrigin::Signed(1).into(); + + let long_wasm = [0u8; 30000].to_vec(); + assert_eq!( + ProposalCodex::create_runtime_upgrade_proposal( + origin, + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + long_wasm, + ), + Err(Error::RuntimeProposalSizeExceeded) + ); + + assert_eq!( + ProposalCodex::create_runtime_upgrade_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + Vec::new(), + ), + Err(Error::RuntimeProposalIsEmpty) + ); + }); +} + +#[test] +fn create_set_election_parameters_proposal_common_checks_succeed() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + let proposal_fixture = ProposalTestFixture { + insufficient_rights_call: || { + ProposalCodex::create_set_election_parameters_proposal( + RawOrigin::None.into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + get_valid_election_parameters(), + ) + }, + empty_stake_call: || { + ProposalCodex::create_set_election_parameters_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + get_valid_election_parameters(), + ) + }, + invalid_stake_call: || { + ProposalCodex::create_set_election_parameters_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(50000u32)), + get_valid_election_parameters(), + ) + }, + successful_call: || { + ProposalCodex::create_set_election_parameters_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(3750u32)), + get_valid_election_parameters(), + ) + }, + proposal_parameters: + crate::proposal_types::parameters::set_election_parameters_proposal::(), + proposal_details: ProposalDetails::SetElectionParameters( + get_valid_election_parameters(), + ), + }; + proposal_fixture.check_all(); + }); +} + +fn assert_failed_election_parameters_call( + election_parameters: ElectionParameters, + error: Error, +) { + assert_eq!( + ProposalCodex::create_set_election_parameters_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(3750u32)), + election_parameters, + ), + Err(error) + ); +} + +fn get_valid_election_parameters() -> ElectionParameters { + ElectionParameters { + announcing_period: 14400, + voting_period: 14400, + revealing_period: 14400, + council_size: 4, + candidacy_limit: 25, + new_term_duration: 14400, + min_council_stake: 1, + min_voting_stake: 1, + } +} + +#[test] +fn create_set_election_parameters_call_fails_with_incorrect_parameters() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance_using_account_id(1, 500000); + + let mut election_parameters = get_valid_election_parameters(); + election_parameters.council_size = 2; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterCouncilSize, + ); + + election_parameters.council_size = 21; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterCouncilSize, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.candidacy_limit = 22; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterCandidacyLimit, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.candidacy_limit = 122; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterCandidacyLimit, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.min_voting_stake = 0; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterMinVotingStake, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.min_voting_stake = 200000; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterMinVotingStake, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.new_term_duration = 10000; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterNewTermDuration, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.new_term_duration = 500000; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterNewTermDuration, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.min_council_stake = 0; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterMinCouncilStake, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.min_council_stake = 200000; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterMinCouncilStake, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.voting_period = 10000; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterVotingPeriod, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.voting_period = 50000; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterVotingPeriod, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.revealing_period = 10000; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterRevealingPeriod, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.revealing_period = 50000; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterRevealingPeriod, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.announcing_period = 10000; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterAnnouncingPeriod, + ); + + election_parameters = get_valid_election_parameters(); + election_parameters.announcing_period = 50000; + assert_failed_election_parameters_call( + election_parameters, + Error::InvalidCouncilElectionParameterAnnouncingPeriod, + ); + }); +} + +#[test] +fn create_working_group_mint_capacity_proposal_fails_with_invalid_parameters() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + assert_eq!( + ProposalCodex::create_set_content_working_group_mint_capacity_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1250u32)), + 5001, + ), + Err(Error::InvalidStorageWorkingGroupMintCapacity) + ); + }); +} + +#[test] +fn create_set_content_working_group_mint_capacity_proposal_common_checks_succeed() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + let proposal_fixture = ProposalTestFixture { + insufficient_rights_call: || { + ProposalCodex::create_set_content_working_group_mint_capacity_proposal( + RawOrigin::None.into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + 0, + ) + }, + empty_stake_call: || { + ProposalCodex::create_set_content_working_group_mint_capacity_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + 0, + ) + }, + invalid_stake_call: || { + ProposalCodex::create_set_content_working_group_mint_capacity_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(5000u32)), + 0, + ) + }, + successful_call: || { + ProposalCodex::create_set_content_working_group_mint_capacity_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1250u32)), + 10, + ) + }, + proposal_parameters: crate::proposal_types::parameters::set_content_working_group_mint_capacity_proposal::(), + proposal_details: ProposalDetails::SetContentWorkingGroupMintCapacity(10), + }; + proposal_fixture.check_all(); + }); +} + +#[test] +fn create_spending_proposal_common_checks_succeed() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + let proposal_fixture = ProposalTestFixture { + insufficient_rights_call: || { + ProposalCodex::create_spending_proposal( + RawOrigin::None.into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + 20, + 10, + ) + }, + empty_stake_call: || { + ProposalCodex::create_spending_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + 20, + 10, + ) + }, + invalid_stake_call: || { + ProposalCodex::create_spending_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(5000u32)), + 20, + 10, + ) + }, + successful_call: || { + ProposalCodex::create_spending_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1250u32)), + 100, + 2, + ) + }, + proposal_parameters: crate::proposal_types::parameters::spending_proposal::(), + proposal_details: ProposalDetails::Spending(100, 2), + }; + proposal_fixture.check_all(); + }); +} + +#[test] +fn create_spending_proposal_call_fails_with_incorrect_balance() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance_using_account_id(500000, 1); + + assert_eq!( + ProposalCodex::create_spending_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1250u32)), + 0, + 2, + ), + Err(Error::InvalidSpendingProposalBalance) + ); + + assert_eq!( + ProposalCodex::create_spending_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1250u32)), + 1001, + 2, + ), + Err(Error::InvalidSpendingProposalBalance) + ); + }); +} + +#[test] +fn create_set_lead_proposal_fails_with_proposed_councilor() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance_using_account_id(1, 500000); + + let lead_account_id = 20; + >::set_council( + RawOrigin::Root.into(), + vec![lead_account_id], + ) + .unwrap(); + + assert_eq!( + ProposalCodex::create_set_lead_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1250u32)), + Some((20, lead_account_id)), + ), + Err(Error::InvalidSetLeadParameterCannotBeCouncilor) + ); + }); +} + +#[test] +fn create_set_lead_proposal_common_checks_succeed() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + let proposal_fixture = ProposalTestFixture { + insufficient_rights_call: || { + ProposalCodex::create_set_lead_proposal( + RawOrigin::None.into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + Some((20, 10)), + ) + }, + empty_stake_call: || { + ProposalCodex::create_set_lead_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + Some((20, 10)), + ) + }, + invalid_stake_call: || { + ProposalCodex::create_set_lead_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(5000u32)), + Some((20, 10)), + ) + }, + successful_call: || { + ProposalCodex::create_set_lead_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1250u32)), + Some((20, 10)), + ) + }, + proposal_parameters: crate::proposal_types::parameters::set_lead_proposal::(), + proposal_details: ProposalDetails::SetLead(Some((20, 10))), + }; + proposal_fixture.check_all(); + }); +} + +#[test] +fn create_evict_storage_provider_proposal_common_checks_succeed() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + let proposal_fixture = ProposalTestFixture { + insufficient_rights_call: || { + ProposalCodex::create_evict_storage_provider_proposal( + RawOrigin::None.into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + 1, + ) + }, + empty_stake_call: || { + ProposalCodex::create_evict_storage_provider_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + 1, + ) + }, + invalid_stake_call: || { + ProposalCodex::create_evict_storage_provider_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(5000u32)), + 1, + ) + }, + successful_call: || { + ProposalCodex::create_evict_storage_provider_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(500u32)), + 1, + ) + }, + proposal_parameters: crate::proposal_types::parameters::evict_storage_provider_proposal::(), + proposal_details: ProposalDetails::EvictStorageProvider(1), + }; + proposal_fixture.check_all(); + }); +} + +#[test] +fn create_set_validator_count_proposal_common_checks_succeed() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + let proposal_fixture = ProposalTestFixture { + insufficient_rights_call: || { + ProposalCodex::create_set_validator_count_proposal( + RawOrigin::None.into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + 4, + ) + }, + empty_stake_call: || { + ProposalCodex::create_set_validator_count_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + 4, + ) + }, + invalid_stake_call: || { + ProposalCodex::create_set_validator_count_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(5000u32)), + 4, + ) + }, + successful_call: || { + ProposalCodex::create_set_validator_count_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1250u32)), + 4, + ) + }, + proposal_parameters: crate::proposal_types::parameters::set_validator_count_proposal::< + Test, + >(), + proposal_details: ProposalDetails::SetValidatorCount(4), + }; + proposal_fixture.check_all(); + }); +} + +#[test] +fn create_set_validator_count_proposal_failed_with_invalid_validator_count() { + initial_test_ext().execute_with(|| { + assert_eq!( + ProposalCodex::create_set_validator_count_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(500u32)), + 3, + ), + Err(Error::InvalidValidatorCount) + ); + + assert_eq!( + ProposalCodex::create_set_validator_count_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1001u32)), + 3, + ), + Err(Error::InvalidValidatorCount) + ); + }); +} + +#[test] +fn create_set_storage_role_parameters_proposal_common_checks_succeed() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + let proposal_fixture = ProposalTestFixture { + insufficient_rights_call: || { + ProposalCodex::create_set_storage_role_parameters_proposal( + RawOrigin::None.into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + RoleParameters::default(), + ) + }, + empty_stake_call: || { + ProposalCodex::create_set_storage_role_parameters_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + None, + RoleParameters::default(), + ) + }, + invalid_stake_call: || { + ProposalCodex::create_set_storage_role_parameters_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(5000u32)), + RoleParameters::default(), + ) + }, + successful_call: || { + ProposalCodex::create_set_storage_role_parameters_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(1250u32)), + RoleParameters::default(), + ) + }, + proposal_parameters: + crate::proposal_types::parameters::set_storage_role_parameters_proposal::(), + proposal_details: ProposalDetails::SetStorageRoleParameters(RoleParameters::default()), + }; + proposal_fixture.check_all(); + }); +} + +fn assert_failed_set_storage_parameters_call( + role_parameters: RoleParameters, + error: Error, +) { + assert_eq!( + ProposalCodex::create_set_storage_role_parameters_proposal( + RawOrigin::Signed(1).into(), + 1, + b"title".to_vec(), + b"body".to_vec(), + Some(>::from(500u32)), + role_parameters, + ), + Err(error) + ); +} + +#[test] +fn create_set_storage_role_parameters_proposal_fails_with_invalid_parameters() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance(500000); + + let mut role_parameters = RoleParameters::default(); + role_parameters.min_actors = 6; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterMinActors, + ); + + role_parameters = RoleParameters::default(); + role_parameters.max_actors = 4; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterMaxActors, + ); + + role_parameters = RoleParameters::default(); + role_parameters.max_actors = 100; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterMaxActors, + ); + + role_parameters = RoleParameters::default(); + role_parameters.reward_period = 599; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterRewardPeriod, + ); + + role_parameters.reward_period = 28801; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterRewardPeriod, + ); + + role_parameters = RoleParameters::default(); + role_parameters.bonding_period = 599; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterBondingPeriod, + ); + + role_parameters = RoleParameters::default(); + role_parameters.bonding_period = 28801; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterBondingPeriod, + ); + + role_parameters = RoleParameters::default(); + role_parameters.unbonding_period = 599; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterUnbondingPeriod, + ); + + role_parameters = RoleParameters::default(); + role_parameters.unbonding_period = 28801; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterUnbondingPeriod, + ); + + role_parameters = RoleParameters::default(); + role_parameters.min_service_period = 599; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterMinServicePeriod, + ); + + role_parameters = RoleParameters::default(); + role_parameters.min_service_period = 28801; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterMinServicePeriod, + ); + + role_parameters = RoleParameters::default(); + role_parameters.startup_grace_period = 599; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterStartupGracePeriod, + ); + + role_parameters = RoleParameters::default(); + role_parameters.startup_grace_period = 28801; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterStartupGracePeriod, + ); + + role_parameters = RoleParameters::default(); + role_parameters.min_stake = 0; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterMinStake, + ); + + role_parameters = RoleParameters::default(); + role_parameters.min_stake = 5001; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterMinStake, + ); + + role_parameters = RoleParameters::default(); + role_parameters.entry_request_fee = 0; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterEntryRequestFee, + ); + + role_parameters = RoleParameters::default(); + role_parameters.entry_request_fee = 5001; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterEntryRequestFee, + ); + + role_parameters = RoleParameters::default(); + role_parameters.reward = 0; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterReward, + ); + + role_parameters = RoleParameters::default(); + role_parameters.reward = 501; + assert_failed_set_storage_parameters_call( + role_parameters, + Error::InvalidStorageRoleParameterReward, + ); + }); +} diff --git a/runtime-modules/proposals/discussion/Cargo.toml b/runtime-modules/proposals/discussion/Cargo.toml new file mode 100644 index 0000000000..c5bad3b951 --- /dev/null +++ b/runtime-modules/proposals/discussion/Cargo.toml @@ -0,0 +1,94 @@ +[package] +name = 'substrate-proposals-discussion-module' +version = '2.0.0' +authors = ['Joystream contributors'] +edition = '2018' + +[features] +default = ['std'] +no_std = [] +std = [ + 'codec/std', + 'rstd/std', + 'srml-support/std', + 'primitives/std', + 'sr-primitives/std', + 'system/std', + 'timestamp/std', + 'serde', + 'membership/std', + 'common/std', +] + +[dependencies.num_enum] +default_features = false +version = "0.4.2" + +[dependencies.serde] +features = ['derive'] +optional = true +version = '1.0.101' + +[dependencies.codec] +default-features = false +features = ['derive'] +package = 'parity-scale-codec' +version = '1.0.0' + +[dependencies.primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'substrate-primitives' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.rstd] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-std' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.sr-primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-primitives' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.srml-support] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-support' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.system] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-system' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.timestamp] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-timestamp' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.membership] +default_features = false +package = 'substrate-membership-module' +path = '../../membership' + +[dependencies.common] +default_features = false +package = 'substrate-common-module' +path = '../../common' + +[dev-dependencies.runtime-io] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-io' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dev-dependencies.balances] +package = 'srml-balances' +default-features = false +git = 'https://github.com/paritytech/substrate.git' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' \ No newline at end of file diff --git a/runtime-modules/proposals/discussion/src/lib.rs b/runtime-modules/proposals/discussion/src/lib.rs new file mode 100644 index 0000000000..19ff49ba0d --- /dev/null +++ b/runtime-modules/proposals/discussion/src/lib.rs @@ -0,0 +1,352 @@ +//! # Proposals discussion module +//! Proposals `discussion` module for the Joystream platform. Version 2. +//! It contains discussion subsystem of the proposals. +//! +//! ## Overview +//! +//! The proposals discussion module is used by the codex module to provide a platform for discussions +//! about different proposals. It allows to create discussion threads and then add and update related +//! posts. +//! +//! ## Supported extrinsics +//! - [add_post](./struct.Module.html#method.add_post) - adds a post to an existing discussion thread +//! - [update_post](./struct.Module.html#method.update_post) - updates existing post +//! +//! ## Public API methods +//! - [create_thread](./struct.Module.html#method.create_thread) - creates a discussion thread +//! - [ensure_can_create_thread](./struct.Module.html#method.ensure_can_create_thread) - ensures safe thread creation +//! +//! ## Usage +//! +//! ``` +//! use srml_support::{decl_module, dispatch::Result}; +//! use system::ensure_root; +//! use substrate_proposals_discussion_module::{self as discussions}; +//! +//! pub trait Trait: discussions::Trait + membership::members::Trait {} +//! +//! decl_module! { +//! pub struct Module for enum Call where origin: T::Origin { +//! pub fn create_discussion(origin, title: Vec, author_id : T::MemberId) -> Result { +//! ensure_root(origin)?; +//! >::ensure_can_create_thread(author_id, &title)?; +//! >::create_thread(author_id, title)?; +//! Ok(()) +//! } +//! } +//! } +//! # fn main() {} +//! ``` + +//! + +// Ensure we're `no_std` when compiling for Wasm. +#![cfg_attr(not(feature = "std"), no_std)] + +// Do not delete! Cannot be uncommented by default, because of Parity decl_module! issue. +//#![warn(missing_docs)] + +#[cfg(test)] +mod tests; +mod types; + +use rstd::clone::Clone; +use rstd::prelude::*; +use rstd::vec::Vec; +use srml_support::{decl_error, decl_event, decl_module, decl_storage, ensure, Parameter}; + +use srml_support::traits::Get; +use types::{Post, Thread, ThreadCounter}; + +use common::origin_validator::ActorOriginValidator; +use srml_support::dispatch::DispatchResult; + +type MemberId = ::MemberId; + +decl_event!( + /// Proposals engine events + pub enum Event + where + ::ThreadId, + MemberId = MemberId, + ::PostId, + { + /// Emits on thread creation. + ThreadCreated(ThreadId, MemberId), + + /// Emits on post creation. + PostCreated(PostId, MemberId), + + /// Emits on post update. + PostUpdated(PostId, MemberId), + } +); + +/// 'Proposal discussion' substrate module Trait +pub trait Trait: system::Trait + membership::members::Trait { + /// Engine event type. + type Event: From> + Into<::Event>; + + /// Validates post author id and origin combination + type PostAuthorOriginValidator: ActorOriginValidator< + Self::Origin, + MemberId, + Self::AccountId, + >; + + /// Discussion thread Id type + type ThreadId: From + Into + Parameter + Default + Copy; + + /// Post Id type + type PostId: From + Parameter + Default + Copy; + + /// Defines post edition number limit. + type MaxPostEditionNumber: Get; + + /// Defines thread title length limit. + type ThreadTitleLengthLimit: Get; + + /// Defines post length limit. + type PostLengthLimit: Get; + + /// Defines max thread by same author in a row number limit. + type MaxThreadInARowNumber: Get; +} + +decl_error! { + /// Discussion module predefined errors + pub enum Error { + /// Author should match the post creator + NotAuthor, + + /// Post edition limit reached + PostEditionNumberExceeded, + + /// Discussion cannot have an empty title + EmptyTitleProvided, + + /// Title is too long + TitleIsTooLong, + + /// Thread doesn't exist + ThreadDoesntExist, + + /// Post doesn't exist + PostDoesntExist, + + /// Post cannot be empty + EmptyPostProvided, + + /// Post is too long + PostIsTooLong, + + /// Max number of threads by same author in a row limit exceeded + MaxThreadInARowLimitExceeded, + + /// Require root origin in extrinsics + RequireRootOrigin, + } +} + +impl From for Error { + fn from(error: system::Error) -> Self { + match error { + system::Error::Other(msg) => Error::Other(msg), + system::Error::RequireRootOrigin => Error::RequireRootOrigin, + _ => Error::Other(error.into()), + } + } +} + +// Storage for the proposals discussion module +decl_storage! { + pub trait Store for Module as ProposalDiscussion { + /// Map thread identifier to corresponding thread. + pub ThreadById get(thread_by_id): map T::ThreadId => + Thread, T::BlockNumber>; + + /// Count of all threads that have been created. + pub ThreadCount get(fn thread_count): u32; + + /// Map thread id and post id to corresponding post. + pub PostThreadIdByPostId: double_map T::ThreadId, twox_128(T::PostId) => + Post, T::BlockNumber, T::ThreadId>; + + /// Count of all posts that have been created. + pub PostCount get(fn post_count): u32; + + /// Last author thread counter (part of the antispam mechanism) + pub LastThreadAuthorCounter get(fn last_thread_author_counter): + Option>>; + } +} + +decl_module! { + /// 'Proposal discussion' substrate module + pub struct Module for enum Call where origin: T::Origin { + /// Predefined errors + type Error = Error; + + /// Emits an event. Default substrate implementation. + fn deposit_event() = default; + + /// Adds a post with author origin check. + pub fn add_post( + origin, + post_author_id: MemberId, + thread_id : T::ThreadId, + text : Vec + ) { + T::PostAuthorOriginValidator::ensure_actor_origin( + origin, + post_author_id, + )?; + ensure!(>::exists(thread_id), Error::ThreadDoesntExist); + + ensure!(!text.is_empty(),Error::EmptyPostProvided); + ensure!( + text.len() as u32 <= T::PostLengthLimit::get(), + Error::PostIsTooLong + ); + + // mutation + + let next_post_count_value = Self::post_count() + 1; + let new_post_id = next_post_count_value; + + let new_post = Post { + text, + created_at: Self::current_block(), + updated_at: Self::current_block(), + author_id: post_author_id, + edition_number : 0, + thread_id, + }; + + let post_id = T::PostId::from(new_post_id); + >::insert(thread_id, post_id, new_post); + PostCount::put(next_post_count_value); + Self::deposit_event(RawEvent::PostCreated(post_id, post_author_id)); + } + + /// Updates a post with author origin check. Update attempts number is limited. + pub fn update_post( + origin, + post_author_id: MemberId, + thread_id: T::ThreadId, + post_id : T::PostId, + text : Vec + ){ + T::PostAuthorOriginValidator::ensure_actor_origin( + origin, + post_author_id, + )?; + + ensure!(>::exists(thread_id), Error::ThreadDoesntExist); + ensure!(>::exists(thread_id, post_id), Error::PostDoesntExist); + + ensure!(!text.is_empty(), Error::EmptyPostProvided); + ensure!( + text.len() as u32 <= T::PostLengthLimit::get(), + Error::PostIsTooLong + ); + + let post = >::get(&thread_id, &post_id); + + ensure!(post.author_id == post_author_id, Error::NotAuthor); + ensure!(post.edition_number < T::MaxPostEditionNumber::get(), + Error::PostEditionNumberExceeded); + + let new_post = Post { + text, + updated_at: Self::current_block(), + edition_number: post.edition_number + 1, + ..post + }; + + // mutation + + >::insert(thread_id, post_id, new_post); + Self::deposit_event(RawEvent::PostUpdated(post_id, post_author_id)); + } + } +} + +impl Module { + /// Create the discussion thread. Cannot add more threads than 'predefined limit = MaxThreadInARowNumber' + /// times in a row by the same author. + pub fn create_thread( + thread_author_id: MemberId, + title: Vec, + ) -> Result { + Self::ensure_can_create_thread(thread_author_id, &title)?; + + let next_thread_count_value = Self::thread_count() + 1; + let new_thread_id = next_thread_count_value; + + let new_thread = Thread { + title, + created_at: Self::current_block(), + author_id: thread_author_id, + }; + + // get new 'threads in a row' counter for the author + let current_thread_counter = Self::get_updated_thread_counter(thread_author_id); + + // mutation + + let thread_id = T::ThreadId::from(new_thread_id); + >::insert(thread_id, new_thread); + ThreadCount::put(next_thread_count_value); + >::put(current_thread_counter); + Self::deposit_event(RawEvent::ThreadCreated(thread_id, thread_author_id)); + + Ok(thread_id) + } + + /// Ensures thread can be created. + /// Checks: + /// - title is valid + /// - max thread in a row by the same author + pub fn ensure_can_create_thread( + thread_author_id: MemberId, + title: &[u8], + ) -> DispatchResult { + ensure!(!title.is_empty(), Error::EmptyTitleProvided); + ensure!( + title.len() as u32 <= T::ThreadTitleLengthLimit::get(), + Error::TitleIsTooLong + ); + + // get new 'threads in a row' counter for the author + let current_thread_counter = Self::get_updated_thread_counter(thread_author_id); + + ensure!( + current_thread_counter.counter as u32 <= T::MaxThreadInARowNumber::get(), + Error::MaxThreadInARowLimitExceeded + ); + + Ok(()) + } +} + +impl Module { + // Wrapper-function over system::block_number() + fn current_block() -> T::BlockNumber { + >::block_number() + } + + // returns incremented thread counter if last thread author equals with provided parameter + fn get_updated_thread_counter(author_id: MemberId) -> ThreadCounter> { + // if thread counter exists + if let Some(last_thread_author_counter) = Self::last_thread_author_counter() { + // if last(previous) author is the same as current author + if last_thread_author_counter.author_id == author_id { + return last_thread_author_counter.increment(); + } + } + + // else return new counter (set with 1 thread number) + ThreadCounter::new(author_id) + } +} diff --git a/runtime-modules/proposals/discussion/src/tests/mock.rs b/runtime-modules/proposals/discussion/src/tests/mock.rs new file mode 100644 index 0000000000..347d43a892 --- /dev/null +++ b/runtime-modules/proposals/discussion/src/tests/mock.rs @@ -0,0 +1,145 @@ +#![cfg(test)] + +pub use system; + +pub use primitives::{Blake2Hasher, H256}; +pub use sr_primitives::{ + testing::{Digest, DigestItem, Header, UintAuthorityId}, + traits::{BlakeTwo256, Convert, IdentityLookup, OnFinalize}, + weights::Weight, + BuildStorage, Perbill, +}; + +use crate::ActorOriginValidator; +use srml_support::{impl_outer_event, impl_outer_origin, parameter_types}; + +impl_outer_origin! { + pub enum Origin for Test {} +} + +// Workaround for https://github.com/rust-lang/rust/issues/26925 . Remove when sorted. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Test; +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const MaximumBlockWeight: u32 = 1024; + pub const MaximumBlockLength: u32 = 2 * 1024; + pub const AvailableBlockRatio: Perbill = Perbill::one(); + pub const MinimumPeriod: u64 = 5; + pub const StakePoolId: [u8; 8] = *b"joystake"; +} + +parameter_types! { + pub const MaxPostEditionNumber: u32 = 5; + pub const MaxThreadInARowNumber: u32 = 3; + pub const ThreadTitleLengthLimit: u32 = 200; + pub const PostLengthLimit: u32 = 2000; +} + +mod discussion { + pub use crate::Event; +} + +mod membership_mod { + pub use membership::members::Event; +} + +impl_outer_event! { + pub enum TestEvent for Test { + discussion, + balances, + membership_mod, + } +} + +parameter_types! { + pub const ExistentialDeposit: u32 = 0; + pub const TransferFee: u32 = 0; + pub const CreationFee: u32 = 0; +} + +impl balances::Trait for Test { + type Balance = u64; + /// What to do if an account's free balance gets zeroed. + type OnFreeBalanceZero = (); + type OnNewAccount = (); + type TransferPayment = (); + type DustRemoval = (); + type Event = TestEvent; + type ExistentialDeposit = ExistentialDeposit; + type TransferFee = TransferFee; + type CreationFee = CreationFee; +} + +impl common::currency::GovernanceCurrency for Test { + type Currency = balances::Module; +} + +impl membership::members::Trait for Test { + type Event = TestEvent; + type MemberId = u64; + type PaidTermId = u64; + type SubscriptionId = u64; + type ActorId = u64; + type InitialMembersBalance = (); +} + +impl crate::Trait for Test { + type Event = TestEvent; + type PostAuthorOriginValidator = (); + type ThreadId = u32; + type PostId = u32; + type MaxPostEditionNumber = MaxPostEditionNumber; + type ThreadTitleLengthLimit = ThreadTitleLengthLimit; + type PostLengthLimit = PostLengthLimit; + type MaxThreadInARowNumber = MaxThreadInARowNumber; +} + +impl ActorOriginValidator for () { + fn ensure_actor_origin(origin: Origin, actor_id: u64) -> Result { + if system::ensure_none(origin).is_ok() { + return Ok(1); + } + + if actor_id == 1 { + return Ok(1); + } + + Err("Invalid author") + } +} + +impl system::Trait for Test { + type Origin = Origin; + type Call = (); + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type Event = TestEvent; + type BlockHashCount = BlockHashCount; + type MaximumBlockWeight = MaximumBlockWeight; + type MaximumBlockLength = MaximumBlockLength; + type AvailableBlockRatio = AvailableBlockRatio; + type Version = (); +} + +impl timestamp::Trait for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = MinimumPeriod; +} + +pub fn initial_test_ext() -> runtime_io::TestExternalities { + let t = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + t.into() +} + +pub type Discussions = crate::Module; +pub type System = system::Module; diff --git a/runtime-modules/proposals/discussion/src/tests/mod.rs b/runtime-modules/proposals/discussion/src/tests/mod.rs new file mode 100644 index 0000000000..ab5edeb7f8 --- /dev/null +++ b/runtime-modules/proposals/discussion/src/tests/mod.rs @@ -0,0 +1,417 @@ +mod mock; + +use mock::*; + +use crate::*; +use system::RawOrigin; +use system::{EventRecord, Phase}; + +struct EventFixture; +impl EventFixture { + fn assert_events(expected_raw_events: Vec>) { + let expected_events = expected_raw_events + .iter() + .map(|ev| EventRecord { + phase: Phase::ApplyExtrinsic(0), + event: TestEvent::discussion(ev.clone()), + topics: vec![], + }) + .collect::>>(); + + assert_eq!(System::events(), expected_events); + } +} + +struct TestPostEntry { + pub post_id: u32, + pub text: Vec, + pub edition_number: u32, +} + +struct TestThreadEntry { + pub thread_id: u32, + pub title: Vec, +} + +fn assert_thread_content(thread_entry: TestThreadEntry, post_entries: Vec) { + assert!(>::exists(thread_entry.thread_id)); + + let actual_thread = >::get(thread_entry.thread_id); + let expected_thread = Thread { + title: thread_entry.title, + created_at: 1, + author_id: 1, + }; + assert_eq!(actual_thread, expected_thread); + + for post_entry in post_entries { + let actual_post = + >::get(thread_entry.thread_id, post_entry.post_id); + let expected_post = Post { + text: post_entry.text, + created_at: 1, + updated_at: 1, + author_id: 1, + thread_id: thread_entry.thread_id, + edition_number: post_entry.edition_number, + }; + + assert_eq!(actual_post, expected_post); + } +} + +struct DiscussionFixture { + pub title: Vec, + pub origin: RawOrigin, + pub author_id: u64, +} + +impl Default for DiscussionFixture { + fn default() -> Self { + DiscussionFixture { + title: b"title".to_vec(), + origin: RawOrigin::Signed(1), + author_id: 1, + } + } +} + +impl DiscussionFixture { + fn with_title(self, title: Vec) -> Self { + DiscussionFixture { title, ..self } + } + + fn create_discussion_and_assert(&self, result: Result) -> Option { + let create_discussion_result = + Discussions::create_thread(self.author_id, self.title.clone()); + + assert_eq!(create_discussion_result, result); + + create_discussion_result.ok() + } +} + +struct PostFixture { + pub text: Vec, + pub origin: RawOrigin, + pub thread_id: u32, + pub post_id: Option, + pub author_id: u64, +} + +impl PostFixture { + fn default_for_thread(thread_id: u32) -> Self { + PostFixture { + text: b"text".to_vec(), + author_id: 1, + thread_id, + origin: RawOrigin::Signed(1), + post_id: None, + } + } + + fn with_text(self, text: Vec) -> Self { + PostFixture { text, ..self } + } + + fn with_origin(self, origin: RawOrigin) -> Self { + PostFixture { origin, ..self } + } + + fn with_author(self, author_id: u64) -> Self { + PostFixture { author_id, ..self } + } + + fn change_thread_id(self, thread_id: u32) -> Self { + PostFixture { thread_id, ..self } + } + + fn change_post_id(self, post_id: u32) -> Self { + PostFixture { + post_id: Some(post_id), + ..self + } + } + + fn add_post_and_assert(&mut self, result: Result<(), Error>) -> Option { + let add_post_result = Discussions::add_post( + self.origin.clone().into(), + self.author_id, + self.thread_id, + self.text.clone(), + ); + + assert_eq!(add_post_result, result); + + if result.is_ok() { + self.post_id = Some(::get()); + } + + self.post_id + } + + fn update_post_with_text_and_assert(&mut self, new_text: Vec, result: Result<(), Error>) { + let add_post_result = Discussions::update_post( + self.origin.clone().into(), + self.author_id, + self.thread_id, + self.post_id.unwrap(), + new_text, + ); + + assert_eq!(add_post_result, result); + } + + fn update_post_and_assert(&mut self, result: Result<(), Error>) { + self.update_post_with_text_and_assert(self.text.clone(), result); + } +} + +#[test] +fn create_discussion_call_succeeds() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + + discussion_fixture.create_discussion_and_assert(Ok(1)); + }); +} + +#[test] +fn create_post_call_succeeds() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + + let thread_id = discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture = PostFixture::default_for_thread(thread_id); + + post_fixture.add_post_and_assert(Ok(())); + }); +} + +#[test] +fn update_post_call_succeeds() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + + let thread_id = discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture = PostFixture::default_for_thread(thread_id); + + post_fixture.add_post_and_assert(Ok(())); + post_fixture.update_post_and_assert(Ok(())); + + EventFixture::assert_events(vec![ + RawEvent::ThreadCreated(1, 1), + RawEvent::PostCreated(1, 1), + RawEvent::PostUpdated(1, 1), + ]); + }); +} + +#[test] +fn update_post_call_fails_because_of_post_edition_limit() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + + let thread_id = discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture = PostFixture::default_for_thread(thread_id); + + post_fixture.add_post_and_assert(Ok(())); + + for _ in 1..6 { + post_fixture.update_post_and_assert(Ok(())); + } + + post_fixture.update_post_and_assert(Err(Error::PostEditionNumberExceeded)); + }); +} + +#[test] +fn update_post_call_fails_because_of_the_wrong_author() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + + let thread_id = discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture = PostFixture::default_for_thread(thread_id); + + post_fixture.add_post_and_assert(Ok(())); + + post_fixture = post_fixture.with_author(2); + + post_fixture.update_post_and_assert(Err(Error::Other("Invalid author"))); + + post_fixture = post_fixture.with_origin(RawOrigin::None).with_author(2); + + post_fixture.update_post_and_assert(Err(Error::NotAuthor)); + }); +} + +#[test] +fn thread_content_check_succeeded() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + + let thread_id = discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture1 = PostFixture::default_for_thread(thread_id); + let post_id1 = post_fixture1.add_post_and_assert(Ok(())).unwrap(); + + let mut post_fixture2 = PostFixture::default_for_thread(thread_id); + let post_id2 = post_fixture2.add_post_and_assert(Ok(())).unwrap(); + post_fixture1.update_post_with_text_and_assert(b"new_text".to_vec(), Ok(())); + + assert_thread_content( + TestThreadEntry { + thread_id, + title: b"title".to_vec(), + }, + vec![ + TestPostEntry { + post_id: post_id1, + text: b"new_text".to_vec(), + edition_number: 1, + }, + TestPostEntry { + post_id: post_id2, + text: b"text".to_vec(), + edition_number: 0, + }, + ], + ); + }); +} + +#[test] +fn create_discussion_call_with_bad_title_failed() { + initial_test_ext().execute_with(|| { + let mut discussion_fixture = DiscussionFixture::default().with_title(Vec::new()); + discussion_fixture.create_discussion_and_assert(Err(Error::EmptyTitleProvided)); + + discussion_fixture = DiscussionFixture::default().with_title([0; 201].to_vec()); + discussion_fixture.create_discussion_and_assert(Err(Error::TitleIsTooLong)); + }); +} + +#[test] +fn add_post_call_with_invalid_thread_failed() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture = PostFixture::default_for_thread(2); + post_fixture.add_post_and_assert(Err(Error::ThreadDoesntExist)); + }); +} + +#[test] +fn update_post_call_with_invalid_post_failed() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + let thread_id = discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture1 = PostFixture::default_for_thread(thread_id); + post_fixture1.add_post_and_assert(Ok(())).unwrap(); + + let mut post_fixture2 = post_fixture1.change_post_id(2); + post_fixture2.update_post_and_assert(Err(Error::PostDoesntExist)); + }); +} + +#[test] +fn update_post_call_with_invalid_thread_failed() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + let thread_id = discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture1 = PostFixture::default_for_thread(thread_id); + post_fixture1.add_post_and_assert(Ok(())).unwrap(); + + let mut post_fixture2 = post_fixture1.change_thread_id(2); + post_fixture2.update_post_and_assert(Err(Error::ThreadDoesntExist)); + }); +} + +#[test] +fn add_post_call_with_invalid_text_failed() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + let thread_id = discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture1 = PostFixture::default_for_thread(thread_id).with_text(Vec::new()); + post_fixture1.add_post_and_assert(Err(Error::EmptyPostProvided)); + + let mut post_fixture2 = + PostFixture::default_for_thread(thread_id).with_text([0; 2001].to_vec()); + post_fixture2.add_post_and_assert(Err(Error::PostIsTooLong)); + }); +} + +#[test] +fn update_post_call_with_invalid_text_failed() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + let thread_id = discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture1 = PostFixture::default_for_thread(thread_id); + post_fixture1.add_post_and_assert(Ok(())); + + let mut post_fixture2 = post_fixture1.with_text(Vec::new()); + post_fixture2.update_post_and_assert(Err(Error::EmptyPostProvided)); + + let mut post_fixture3 = post_fixture2.with_text([0; 2001].to_vec()); + post_fixture3.update_post_and_assert(Err(Error::PostIsTooLong)); + }); +} + +#[test] +fn add_discussion_thread_fails_because_of_max_thread_by_same_author_in_a_row_limit_exceeded() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + for idx in 1..=3 { + discussion_fixture + .create_discussion_and_assert(Ok(idx)) + .unwrap(); + } + + discussion_fixture.create_discussion_and_assert(Err(Error::MaxThreadInARowLimitExceeded)); + }); +} + +#[test] +fn discussion_thread_and_post_counters_are_valid() { + initial_test_ext().execute_with(|| { + let discussion_fixture = DiscussionFixture::default(); + let thread_id = discussion_fixture + .create_discussion_and_assert(Ok(1)) + .unwrap(); + + let mut post_fixture1 = PostFixture::default_for_thread(thread_id); + let _ = post_fixture1.add_post_and_assert(Ok(())).unwrap(); + + assert_eq!(Discussions::thread_count(), 1); + assert_eq!(Discussions::post_count(), 1); + }); +} diff --git a/runtime-modules/proposals/discussion/src/types.rs b/runtime-modules/proposals/discussion/src/types.rs new file mode 100644 index 0000000000..5b8add6e5c --- /dev/null +++ b/runtime-modules/proposals/discussion/src/types.rs @@ -0,0 +1,102 @@ +#![warn(missing_docs)] + +use codec::{Decode, Encode}; +#[cfg(feature = "std")] +use serde::{Deserialize, Serialize}; + +use rstd::prelude::*; + +/// Represents a discussion thread +#[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] +#[derive(Encode, Decode, Default, Clone, PartialEq, Eq)] +pub struct Thread { + /// Title + pub title: Vec, + + /// When thread was established. + pub created_at: BlockNumber, + + /// Author of the thread. + pub author_id: ThreadAuthorId, +} + +/// Post for the discussion thread +#[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] +#[derive(Encode, Decode, Default, Clone, PartialEq, Eq)] +pub struct Post { + /// Text + pub text: Vec, + + /// When post was added. + pub created_at: BlockNumber, + + /// When post was updated last time. + pub updated_at: BlockNumber, + + /// Author of the post. + pub author_id: PostAuthorId, + + /// Parent thread id for this post + pub thread_id: ThreadId, + + /// Defines how many times this post was edited. Zero on creation. + pub edition_number: u32, +} + +/// Post for the discussion thread +#[cfg_attr(feature = "std", derive(Serialize, Deserialize, Debug))] +#[derive(Encode, Decode, Default, Clone, Copy, PartialEq, Eq)] +pub struct ThreadCounter { + /// Author of the threads. + pub author_id: ThreadAuthorId, + + /// ThreadCount + pub counter: u32, +} + +impl ThreadCounter { + /// Increments existing counter + pub fn increment(&self) -> Self { + ThreadCounter { + counter: self.counter + 1, + author_id: self.author_id.clone(), + } + } + + /// Creates new counter by author_id. Counter instantiated with 1. + pub fn new(author_id: ThreadAuthorId) -> Self { + ThreadCounter { + author_id, + counter: 1, + } + } +} + +#[cfg(test)] +mod tests { + use crate::types::ThreadCounter; + + #[test] + fn thread_counter_increment_works() { + let test = ThreadCounter { + author_id: 56, + counter: 56, + }; + let expected = ThreadCounter { + author_id: 56, + counter: 57, + }; + + assert_eq!(expected, test.increment()); + } + + #[test] + fn thread_counter_new_works() { + let expected = ThreadCounter { + author_id: 56, + counter: 1, + }; + + assert_eq!(expected, ThreadCounter::new(56)); + } +} diff --git a/runtime-modules/proposals/engine/Cargo.toml b/runtime-modules/proposals/engine/Cargo.toml new file mode 100644 index 0000000000..19f3027feb --- /dev/null +++ b/runtime-modules/proposals/engine/Cargo.toml @@ -0,0 +1,106 @@ +[package] +name = 'substrate-proposals-engine-module' +version = '2.0.0' +authors = ['Joystream contributors'] +edition = '2018' + +[features] +default = ['std'] +no_std = [] +std = [ + 'codec/std', + 'rstd/std', + 'srml-support/std', + 'primitives/std', + 'system/std', + 'timestamp/std', + 'serde', + 'stake/std', + 'balances/std', + 'sr-primitives/std', + 'membership/std', + 'common/std', + +] + + +[dependencies.num_enum] +default_features = false +version = "0.4.2" + +[dependencies.serde] +features = ['derive'] +optional = true +version = '1.0.101' + +[dependencies.codec] +default-features = false +features = ['derive'] +package = 'parity-scale-codec' +version = '1.0.0' + +[dependencies.primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'substrate-primitives' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.rstd] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-std' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.srml-support] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-support' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.system] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-system' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.timestamp] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'srml-timestamp' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.balances] +package = 'srml-balances' +default-features = false +git = 'https://github.com/paritytech/substrate.git' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.sr-primitives] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-primitives' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' + +[dependencies.stake] +default_features = false +package = 'substrate-stake-module' +path = '../../stake' + +[dependencies.membership] +default_features = false +package = 'substrate-membership-module' +path = '../../membership' + +[dependencies.common] +default_features = false +package = 'substrate-common-module' +path = '../../common' + +[dev-dependencies] +mockall = "0.6.0" + +[dev-dependencies.runtime-io] +default_features = false +git = 'https://github.com/paritytech/substrate.git' +package = 'sr-io' +rev = 'c37bb08535c49a12320af7facfd555ce05cce2e8' diff --git a/runtime-modules/proposals/engine/src/lib.rs b/runtime-modules/proposals/engine/src/lib.rs new file mode 100644 index 0000000000..733f83dd3e --- /dev/null +++ b/runtime-modules/proposals/engine/src/lib.rs @@ -0,0 +1,812 @@ +//! # Proposals engine module +//! Proposals `engine` module for the Joystream platform. Version 2. +//! The main component of the proposals system. Provides methods and extrinsics to create and +//! vote for proposals, inspired by Parity **Democracy module**. +//! +//! ## Overview +//! Proposals `engine` module provides an abstract mechanism to work with proposals: creation, voting, +//! execution, canceling, etc. Proposal execution demands serialized _Dispatchable_ proposal code. +//! It could be any _Dispatchable_ + _Parameter_ type, but most likely, it would be serialized (via +//! Parity _codec_ crate) extrisic call. A proposal stage can be described by its [status](./enum.ProposalStatus.html). +//! +//! ## Proposal lifecycle +//! When a proposal passes [checks](./struct.Module.html#method.ensure_create_proposal_parameters_are_valid) +//! for its [parameters](./struct.ProposalParameters.html) - it can be [created](./struct.Module.html#method.create_proposal). +//! The newly created proposal has _Active_ status. The proposal can be voted on or canceled during its +//! _voting period_. Votes can be [different](./enum.VoteKind.html). When the proposal gets enough votes +//! to be slashed or approved or _voting period_ ends - the proposal becomes _Finalized_. If the proposal +//! got approved and _grace period_ passed - the `engine` module tries to execute the proposal. +//! The final [approved status](./enum.ApprovedProposalStatus.html) of the proposal defines +//! an overall proposal outcome. +//! +//! ### Notes +//! +//! - The proposal can be [vetoed](./struct.Module.html#method.veto_proposal) +//! anytime before the proposal execution by the _sudo_. +//! - When the proposal is created with some stake - refunding on proposal finalization with +//! different statuses should be accomplished from the external handler from the _stake module_ +//! (_StakingEventsHandler_). Such a handler should call +//! [refund_proposal_stake](./struct.Module.html#method.refund_proposal_stake) callback function. +//! - If the _council_ got reelected during the proposal _voting period_ the external handler calls +//! [reset_active_proposals](./trait.Module.html#method.reset_active_proposals) function and +//! all voting results get cleared. +//! +//! ### Important abstract types to be implemented +//! Proposals `engine` module has several abstractions to be implemented in order to work correctly. +//! - _VoterOriginValidator_ - ensure valid voter identity. Voters should have permissions to vote: +//! they should be council members. +//! - [VotersParameters](./trait.VotersParameters.html) - defines total voter number, which is +//! the council size +//! - _ProposerOriginValidator_ - ensure valid proposer identity. Proposers should have permissions +//! to create a proposal: they should be members of the Joystream. +//! - [StakeHandlerProvider](./trait.StakeHandlerProvider.html) - defines an interface for the staking. +//! +//! A full list of the abstractions can be found [here](./trait.Trait.html). +//! +//! ### Supported extrinsics +//! - [vote](./struct.Module.html#method.vote) - registers a vote for the proposal +//! - [cancel_proposal](./struct.Module.html#method.cancel_proposal) - cancels the proposal (can be canceled only by owner) +//! - [veto_proposal](./struct.Module.html#method.veto_proposal) - vetoes the proposal +//! +//! ### Public API +//! - [create_proposal](./struct.Module.html#method.create_proposal) - creates proposal using provided parameters +//! - [ensure_create_proposal_parameters_are_valid](./struct.Module.html#method.ensure_create_proposal_parameters_are_valid) - ensures that we can create the proposal +//! - [refund_proposal_stake](./struct.Module.html#method.refund_proposal_stake) - a callback for _StakingHandlerEvents_ +//! - [reset_active_proposals](./trait.Module.html#method.reset_active_proposals) - resets voting results for active proposals +//! +//! ## Usage +//! +//! ``` +//! use srml_support::{decl_module, dispatch::Result, print}; +//! use system::ensure_signed; +//! use codec::Encode; +//! use substrate_proposals_engine_module::{self as engine, ProposalParameters}; +//! +//! pub trait Trait: engine::Trait + membership::members::Trait {} +//! +//! decl_module! { +//! pub struct Module for enum Call where origin: T::Origin { +//! fn executable_proposal(origin) { +//! print("executed!"); +//! } +//! +//! pub fn create_spending_proposal( +//! origin, +//! proposer_id: T::MemberId, +//! ) -> Result { +//! let account_id = ensure_signed(origin)?; +//! let parameters = ProposalParameters::default(); +//! let title = b"Spending proposal".to_vec(); +//! let description = b"We need to spend some tokens to support the working group lead." +//! .to_vec(); +//! let encoded_proposal_code = >::executable_proposal().encode(); +//! +//! >::ensure_create_proposal_parameters_are_valid( +//! ¶meters, +//! &title, +//! &description, +//! None +//! )?; +//! >::create_proposal( +//! account_id, +//! proposer_id, +//! parameters, +//! title, +//! description, +//! None, +//! encoded_proposal_code +//! )?; +//! Ok(()) +//! } +//! } +//! } +//! # fn main() {} +//! ``` + +// Ensure we're `no_std` when compiling for Wasm. +#![cfg_attr(not(feature = "std"), no_std)] + +// Do not delete! Cannot be uncommented by default, because of Parity decl_module! issue. +//#![warn(missing_docs)] + +use types::FinalizedProposalData; +use types::ProposalStakeManager; +pub use types::{ + ActiveStake, ApprovedProposalStatus, FinalizationData, Proposal, ProposalDecisionStatus, + ProposalParameters, ProposalStatus, VotingResults, +}; +pub use types::{BalanceOf, CurrencyOf, NegativeImbalance}; +pub use types::{DefaultStakeHandlerProvider, StakeHandler, StakeHandlerProvider}; +pub use types::{ProposalCodeDecoder, ProposalExecutable}; +pub use types::{VoteKind, VotersParameters}; + +pub(crate) mod types; + +#[cfg(test)] +mod tests; + +use codec::Decode; +use rstd::prelude::*; +use sr_primitives::traits::{DispatchResult, Zero}; +use srml_support::traits::{Currency, Get}; +use srml_support::{ + decl_error, decl_event, decl_module, decl_storage, ensure, print, Parameter, StorageDoubleMap, +}; +use system::{ensure_root, RawOrigin}; + +use crate::types::ApprovedProposalData; +use common::origin_validator::ActorOriginValidator; +use srml_support::dispatch::Dispatchable; + +type MemberId = ::MemberId; + +/// Proposals engine trait. +pub trait Trait: + system::Trait + timestamp::Trait + stake::Trait + membership::members::Trait +{ + /// Engine event type. + type Event: From> + Into<::Event>; + + /// Validates proposer id and origin combination + type ProposerOriginValidator: ActorOriginValidator< + Self::Origin, + MemberId, + Self::AccountId, + >; + + /// Validates voter id and origin combination + type VoterOriginValidator: ActorOriginValidator, Self::AccountId>; + + /// Provides data for voting. Defines maximum voters count for the proposal. + type TotalVotersCounter: VotersParameters; + + /// Proposal Id type + type ProposalId: From + Parameter + Default + Copy; + + /// Provides stake logic implementation. Can be used to mock stake logic. + type StakeHandlerProvider: StakeHandlerProvider; + + /// The fee is applied when cancel the proposal. A fee would be slashed (burned). + type CancellationFee: Get>; + + /// The fee is applied when the proposal gets rejected. A fee would be slashed (burned). + type RejectionFee: Get>; + + /// Defines max allowed proposal title length. + type TitleMaxLength: Get; + + /// Defines max allowed proposal description length. + type DescriptionMaxLength: Get; + + /// Defines max simultaneous active proposals number. + type MaxActiveProposalLimit: Get; + + /// Proposals executable code. Can be instantiated by external module Call enum members. + type DispatchableCallCode: Parameter + Dispatchable + Default; +} + +decl_event!( + /// Proposals engine events + pub enum Event + where + ::ProposalId, + MemberId = MemberId, + ::BlockNumber, + ::AccountId, + ::StakeId, + { + /// Emits on proposal creation. + /// Params: + /// - Member id of a proposer. + /// - Id of a newly created proposal after it was saved in storage. + ProposalCreated(MemberId, ProposalId), + + /// Emits on proposal status change. + /// Params: + /// - Id of a updated proposal. + /// - New proposal status + ProposalStatusUpdated(ProposalId, ProposalStatus), + + /// Emits on voting for the proposal + /// Params: + /// - Voter - member id of a voter. + /// - Id of a proposal. + /// - Kind of vote. + Voted(MemberId, ProposalId, VoteKind), + } +); + +decl_error! { + /// Engine module predefined errors + pub enum Error { + /// Proposal cannot have an empty title" + EmptyTitleProvided, + + /// Proposal cannot have an empty body + EmptyDescriptionProvided, + + /// Title is too long + TitleIsTooLong, + + /// Description is too long + DescriptionIsTooLong, + + /// The proposal does not exist + ProposalNotFound, + + /// Proposal is finalized already + ProposalFinalized, + + /// The proposal have been already voted on + AlreadyVoted, + + /// Not an author + NotAuthor, + + /// Max active proposals number exceeded + MaxActiveProposalNumberExceeded, + + /// Stake cannot be empty with this proposal + EmptyStake, + + /// Stake should be empty for this proposal + StakeShouldBeEmpty, + + /// Stake differs from the proposal requirements + StakeDiffersFromRequired, + + /// Approval threshold cannot be zero + InvalidParameterApprovalThreshold, + + /// Slashing threshold cannot be zero + InvalidParameterSlashingThreshold, + + /// Require root origin in extrinsics + RequireRootOrigin, + } +} + +impl From for Error { + fn from(error: system::Error) -> Self { + match error { + system::Error::Other(msg) => Error::Other(msg), + system::Error::RequireRootOrigin => Error::RequireRootOrigin, + _ => Error::Other(error.into()), + } + } +} + +// Storage for the proposals engine module +decl_storage! { + pub trait Store for Module as ProposalEngine{ + /// Map proposal by its id. + pub Proposals get(fn proposals): map T::ProposalId => ProposalOf; + + /// Count of all proposals that have been created. + pub ProposalCount get(fn proposal_count): u32; + + /// Map proposal executable code by proposal id. + pub DispatchableCallCode get(fn proposal_codes): map T::ProposalId => Vec; + + /// Count of active proposals. + pub ActiveProposalCount get(fn active_proposal_count): u32; + + /// Ids of proposals that are open for voting (have not been finalized yet). + pub ActiveProposalIds get(fn active_proposal_ids): linked_map T::ProposalId=> (); + + /// Ids of proposals that were approved and theirs grace period was not expired. + pub PendingExecutionProposalIds get(fn pending_proposal_ids): linked_map T::ProposalId=> (); + + /// Double map for preventing duplicate votes. Should be cleaned after usage. + pub VoteExistsByProposalByVoter get(fn vote_by_proposal_by_voter): + double_map T::ProposalId, twox_256(MemberId) => VoteKind; + + /// Map proposal id by stake id. Required by StakingEventsHandler callback call + pub StakesProposals get(fn stakes_proposals): map T::StakeId => T::ProposalId; + } +} + +decl_module! { + /// 'Proposal engine' substrate module + pub struct Module for enum Call where origin: T::Origin { + /// Predefined errors + type Error = Error; + + /// Emits an event. Default substrate implementation. + fn deposit_event() = default; + + /// Vote extrinsic. Conditions: origin must allow votes. + pub fn vote(origin, voter_id: MemberId, proposal_id: T::ProposalId, vote: VoteKind) { + T::VoterOriginValidator::ensure_actor_origin( + origin, + voter_id, + )?; + + ensure!(>::exists(proposal_id), Error::ProposalNotFound); + let mut proposal = Self::proposals(proposal_id); + + ensure!(matches!(proposal.status, ProposalStatus::Active{..}), Error::ProposalFinalized); + + let did_not_vote_before = !>::exists( + proposal_id, + voter_id, + ); + + ensure!(did_not_vote_before, Error::AlreadyVoted); + + proposal.voting_results.add_vote(vote.clone()); + + // mutation + + >::insert(proposal_id, proposal); + >::insert( proposal_id, voter_id, vote.clone()); + Self::deposit_event(RawEvent::Voted(voter_id, proposal_id, vote)); + } + + /// Cancel a proposal by its original proposer. + pub fn cancel_proposal(origin, proposer_id: MemberId, proposal_id: T::ProposalId) { + T::ProposerOriginValidator::ensure_actor_origin( + origin, + proposer_id, + )?; + + ensure!(>::exists(proposal_id), Error::ProposalNotFound); + let proposal = Self::proposals(proposal_id); + + ensure!(proposer_id == proposal.proposer_id, Error::NotAuthor); + ensure!(matches!(proposal.status, ProposalStatus::Active{..}), Error::ProposalFinalized); + + // mutation + + Self::finalize_proposal(proposal_id, ProposalDecisionStatus::Canceled); + } + + /// Veto a proposal. Must be root. + pub fn veto_proposal(origin, proposal_id: T::ProposalId) { + ensure_root(origin)?; + + ensure!(>::exists(proposal_id), Error::ProposalNotFound); + let proposal = Self::proposals(proposal_id); + + // mutation + + if >::exists(proposal_id) { + Self::veto_pending_execution_proposal(proposal_id, proposal); + } else { + ensure!(matches!(proposal.status, ProposalStatus::Active{..}), Error::ProposalFinalized); + Self::finalize_proposal(proposal_id, ProposalDecisionStatus::Vetoed); + } + } + + /// Block finalization. Perform voting period check, vote result tally, approved proposals + /// grace period checks, and proposal execution. + fn on_finalize(_n: T::BlockNumber) { + let finalized_proposals = Self::get_finalized_proposals(); + + // mutation + + // Check vote results. Approved proposals with zero grace period will be + // transitioned to the PendingExecution status. + for proposal_data in finalized_proposals { + >::insert(proposal_data.proposal_id, proposal_data.proposal); + Self::finalize_proposal(proposal_data.proposal_id, proposal_data.status); + } + + let executable_proposals = + Self::get_approved_proposal_with_expired_grace_period(); + + // Execute approved proposals with expired grace period + for approved_proosal in executable_proposals { + Self::execute_proposal(approved_proosal); + } + } + } +} + +impl Module { + /// Create proposal. Requires 'proposal origin' membership. + pub fn create_proposal( + account_id: T::AccountId, + proposer_id: MemberId, + parameters: ProposalParameters>, + title: Vec, + description: Vec, + stake_balance: Option>, + encoded_dispatchable_call_code: Vec, + ) -> Result { + Self::ensure_create_proposal_parameters_are_valid( + ¶meters, + &title, + &description, + stake_balance, + )?; + + // checks passed + // mutation + + let next_proposal_count_value = Self::proposal_count() + 1; + let new_proposal_id = next_proposal_count_value; + let proposal_id = T::ProposalId::from(new_proposal_id); + + // Check stake_balance for value and create stake if value exists, else take None + // If create_stake() returns error - return error from extrinsic + let stake_id_result = stake_balance + .map(|stake_amount| { + ProposalStakeManager::::create_stake(stake_amount, account_id.clone()) + }) + .transpose()?; + + let mut stake_data = None; + if let Some(stake_id) = stake_id_result { + stake_data = Some(ActiveStake { + stake_id, + source_account_id: account_id, + }); + + >::insert(stake_id, proposal_id); + } + + let new_proposal = Proposal { + created_at: Self::current_block(), + parameters, + title, + description, + proposer_id, + status: ProposalStatus::Active(stake_data), + voting_results: VotingResults::default(), + }; + + >::insert(proposal_id, new_proposal); + >::insert(proposal_id, encoded_dispatchable_call_code); + >::insert(proposal_id, ()); + ProposalCount::put(next_proposal_count_value); + Self::increase_active_proposal_counter(); + + Self::deposit_event(RawEvent::ProposalCreated(proposer_id, proposal_id)); + + Ok(proposal_id) + } + + /// Performs all checks for the proposal creation: + /// - title, body lengths + /// - max active proposal + /// - provided parameters: approval_threshold_percentage and slashing_threshold_percentage > 0 + /// - provided stake balance and parameters.required_stake are valid + pub fn ensure_create_proposal_parameters_are_valid( + parameters: &ProposalParameters>, + title: &[u8], + description: &[u8], + stake_balance: Option>, + ) -> DispatchResult { + ensure!(!title.is_empty(), Error::EmptyTitleProvided); + ensure!( + title.len() as u32 <= T::TitleMaxLength::get(), + Error::TitleIsTooLong + ); + + ensure!(!description.is_empty(), Error::EmptyDescriptionProvided); + ensure!( + description.len() as u32 <= T::DescriptionMaxLength::get(), + Error::DescriptionIsTooLong + ); + + ensure!( + (Self::active_proposal_count()) < T::MaxActiveProposalLimit::get(), + Error::MaxActiveProposalNumberExceeded + ); + + ensure!( + parameters.approval_threshold_percentage > 0, + Error::InvalidParameterApprovalThreshold + ); + + ensure!( + parameters.slashing_threshold_percentage > 0, + Error::InvalidParameterSlashingThreshold + ); + + // check stake parameters + if let Some(required_stake) = parameters.required_stake { + if let Some(staked_balance) = stake_balance { + ensure!( + required_stake == staked_balance, + Error::StakeDiffersFromRequired + ); + } else { + return Err(Error::EmptyStake); + } + } + + if stake_balance.is_some() && parameters.required_stake.is_none() { + return Err(Error::StakeShouldBeEmpty); + } + + Ok(()) + } + + /// Callback from StakingEventsHandler. Refunds unstaked imbalance back to the source account. + /// There can be a lot of invariant breaks in the scope of this proposal. + /// Such situations are handled by adding error messages to the log. + pub fn refund_proposal_stake(stake_id: T::StakeId, imbalance: NegativeImbalance) { + if >::exists(stake_id) { + let proposal_id = Self::stakes_proposals(stake_id); + + if >::exists(proposal_id) { + let proposal = Self::proposals(proposal_id); + + if let ProposalStatus::Active(active_stake_result) = proposal.status { + if let Some(active_stake) = active_stake_result { + let refunding_result = CurrencyOf::::resolve_into_existing( + &active_stake.source_account_id, + imbalance, + ); + + if refunding_result.is_err() { + print("Broken invariant: cannot refund"); + } + } + } else { + print("Broken invariant: proposal status is not Active"); + } + } else { + print("Broken invariant: proposal doesn't exist"); + } + } else { + print("Broken invariant: stake doesn't exist"); + } + } + + /// Resets voting results for active proposals. + /// Possible application includes new council elections. + pub fn reset_active_proposals() { + >::enumerate().for_each(|(proposal_id, _)| { + >::mutate(proposal_id, |proposal| { + proposal.reset_proposal(); + >::remove_prefix(&proposal_id); + }); + }); + } +} + +impl Module { + // Wrapper-function over system::block_number() + fn current_block() -> T::BlockNumber { + >::block_number() + } + + // Enumerates through active proposals. Tally Voting results. + // Returns proposals with finalized status and id + fn get_finalized_proposals() -> Vec> { + // Enumerate active proposals id and gather finalization data. + // Skip proposals with unfinished voting. + >::enumerate() + .filter_map(|(proposal_id, _)| { + // load current proposal + let proposal = Self::proposals(proposal_id); + + // Calculates votes, takes in account voting period expiration. + // If voting process is in progress, then decision status is None. + let decision_status = proposal.define_proposal_decision_status( + T::TotalVotersCounter::total_voters_count(), + Self::current_block(), + ); + + // map to FinalizedProposalData if decision for the proposal is made or return None + decision_status.map(|status| FinalizedProposalData { + proposal_id, + proposal, + status, + finalized_at: Self::current_block(), + }) + }) + .collect() // compose output vector + } + + // Veto approved proposal during its grace period. Saves a new proposal status and removes + // proposal id from the 'PendingExecutionProposalIds' + fn veto_pending_execution_proposal(proposal_id: T::ProposalId, proposal: ProposalOf) { + >::remove(proposal_id); + + let vetoed_proposal_status = ProposalStatus::finalized( + ProposalDecisionStatus::Vetoed, + None, + None, + Self::current_block(), + ); + + >::insert( + proposal_id, + Proposal { + status: vetoed_proposal_status, + ..proposal + }, + ); + } + + // Executes approved proposal code + fn execute_proposal(approved_proposal: ApprovedProposal) { + let proposal_code = Self::proposal_codes(approved_proposal.proposal_id); + + let proposal_code_result = T::DispatchableCallCode::decode(&mut &proposal_code[..]); + + let approved_proposal_status = match proposal_code_result { + Ok(proposal_code) => { + if let Err(error) = proposal_code.dispatch(T::Origin::from(RawOrigin::Root)) { + ApprovedProposalStatus::failed_execution( + error.into().message.unwrap_or("Dispatch error"), + ) + } else { + ApprovedProposalStatus::Executed + } + } + Err(error) => ApprovedProposalStatus::failed_execution(error.what()), + }; + + let proposal_execution_status = approved_proposal + .finalisation_status_data + .create_approved_proposal_status(approved_proposal_status); + + let mut proposal = approved_proposal.proposal; + proposal.status = proposal_execution_status.clone(); + >::insert(approved_proposal.proposal_id, proposal); + + Self::deposit_event(RawEvent::ProposalStatusUpdated( + approved_proposal.proposal_id, + proposal_execution_status, + )); + + >::remove(&approved_proposal.proposal_id); + } + + // Performs all actions on proposal finalization: + // - clean active proposal cache + // - update proposal status fields (status, finalized_at) + // - add to pending execution proposal cache if approved + // - slash and unstake proposal stake if stake exists + // - decrease active proposal counter + // - fire an event + // It prints an error message in case of an attempt to finalize the non-active proposal. + fn finalize_proposal(proposal_id: T::ProposalId, decision_status: ProposalDecisionStatus) { + Self::decrease_active_proposal_counter(); + >::remove(&proposal_id.clone()); + + let mut proposal = Self::proposals(proposal_id); + + if let ProposalStatus::Active(active_stake) = proposal.status.clone() { + if let ProposalDecisionStatus::Approved { .. } = decision_status { + >::insert(proposal_id, ()); + } + + // deal with stakes if necessary + let slash_balance = + Self::calculate_slash_balance(&decision_status, &proposal.parameters); + let slash_and_unstake_result = + Self::slash_and_unstake(active_stake.clone(), slash_balance); + + // create finalized proposal status with error if any + let new_proposal_status = ProposalStatus::finalized( + decision_status, + slash_and_unstake_result.err(), + active_stake, + Self::current_block(), + ); + + proposal.status = new_proposal_status.clone(); + >::insert(proposal_id, proposal); + + Self::deposit_event(RawEvent::ProposalStatusUpdated( + proposal_id, + new_proposal_status, + )); + } else { + print("Broken invariant: proposal cannot be non-active during the finalisation"); + } + } + + // Slashes the stake and perform unstake only in case of existing stake + fn slash_and_unstake( + current_stake_data: Option>, + slash_balance: BalanceOf, + ) -> Result<(), &'static str> { + // only if stake exists + if let Some(stake_data) = current_stake_data { + if !slash_balance.is_zero() { + ProposalStakeManager::::slash(stake_data.stake_id, slash_balance)?; + } + + ProposalStakeManager::::remove_stake(stake_data.stake_id)?; + } + + Ok(()) + } + + // Calculates required slash based on finalization ProposalDecisionStatus and proposal parameters. + // Method visibility allows testing. + pub(crate) fn calculate_slash_balance( + decision_status: &ProposalDecisionStatus, + proposal_parameters: &ProposalParameters>, + ) -> types::BalanceOf { + match decision_status { + ProposalDecisionStatus::Rejected | ProposalDecisionStatus::Expired => { + T::RejectionFee::get() + } + ProposalDecisionStatus::Approved { .. } | ProposalDecisionStatus::Vetoed => { + BalanceOf::::zero() + } + ProposalDecisionStatus::Canceled => T::CancellationFee::get(), + ProposalDecisionStatus::Slashed => proposal_parameters + .required_stake + .clone() + .unwrap_or_else(BalanceOf::::zero), // stake if set or zero + } + } + + // Enumerates approved proposals and checks their grace period expiration + fn get_approved_proposal_with_expired_grace_period() -> Vec> { + >::enumerate() + .filter_map(|(proposal_id, _)| { + let proposal = Self::proposals(proposal_id); + + if proposal.is_grace_period_expired(Self::current_block()) { + // this should be true, because it was tested inside is_grace_period_expired() + if let ProposalStatus::Finalized(finalisation_data) = proposal.status.clone() { + Some(ApprovedProposalData { + proposal_id, + proposal, + finalisation_status_data: finalisation_data, + }) + } else { + None + } + } else { + None + } + }) + .collect() + } + + // Increases active proposal counter. + fn increase_active_proposal_counter() { + let next_active_proposal_count_value = Self::active_proposal_count() + 1; + ActiveProposalCount::put(next_active_proposal_count_value); + } + + // Decreases active proposal counter down to zero. Decreasing below zero has no effect. + fn decrease_active_proposal_counter() { + let current_active_proposal_counter = Self::active_proposal_count(); + + if current_active_proposal_counter > 0 { + let next_active_proposal_count_value = current_active_proposal_counter - 1; + ActiveProposalCount::put(next_active_proposal_count_value); + }; + } +} + +// Simplification of the 'FinalizedProposalData' type +type FinalizedProposal = FinalizedProposalData< + ::ProposalId, + ::BlockNumber, + MemberId, + types::BalanceOf, + ::StakeId, + ::AccountId, +>; + +// Simplification of the 'ApprovedProposalData' type +type ApprovedProposal = ApprovedProposalData< + ::ProposalId, + ::BlockNumber, + MemberId, + types::BalanceOf, + ::StakeId, + ::AccountId, +>; + +// Simplification of the 'Proposal' type +type ProposalOf = Proposal< + ::BlockNumber, + MemberId, + types::BalanceOf, + ::StakeId, + ::AccountId, +>; diff --git a/runtime-modules/proposals/engine/src/tests/mock/balance_manager.rs b/runtime-modules/proposals/engine/src/tests/mock/balance_manager.rs new file mode 100644 index 0000000000..6c3cef6be1 --- /dev/null +++ b/runtime-modules/proposals/engine/src/tests/mock/balance_manager.rs @@ -0,0 +1,33 @@ +#![cfg(test)] + +pub use sr_primitives::traits::Zero; +use srml_support::traits::{Currency, Imbalance}; + +use super::*; + +/// StakingEventsHandler implementation for the stake::Trait. Restores balances after the unstaking +/// and slashes balances if necessary. +pub struct BalanceManagerStakingEventsHandler; +impl stake::StakingEventsHandler for BalanceManagerStakingEventsHandler { + fn unstaked( + _id: &u64, + _unstaked_amount: stake::BalanceOf, + imbalance: stake::NegativeImbalance, + ) -> stake::NegativeImbalance { + let default_account_id = 1; + + ::Currency::resolve_creating(&default_account_id, imbalance); + + stake::NegativeImbalance::::zero() + } + + fn slashed( + _id: &u64, + _slash_id: Option<::SlashId>, + _slashed_amount: stake::BalanceOf, + _remaining_stake: stake::BalanceOf, + imbalance: stake::NegativeImbalance, + ) -> stake::NegativeImbalance { + imbalance + } +} diff --git a/runtime-modules/proposals/engine/src/tests/mock/mod.rs b/runtime-modules/proposals/engine/src/tests/mock/mod.rs new file mode 100644 index 0000000000..5dd0ac9c60 --- /dev/null +++ b/runtime-modules/proposals/engine/src/tests/mock/mod.rs @@ -0,0 +1,186 @@ +//! Mock runtime for the module testing. +//! +//! Submodules: +//! - stakes: contains support for mocking external 'stake' module +//! - balance_restorator: restores balances after unstaking +//! - proposals: provides types for proposal execution tests +//! + +#![cfg(test)] +pub use primitives::{Blake2Hasher, H256}; +pub use sr_primitives::{ + testing::{Digest, DigestItem, Header, UintAuthorityId}, + traits::{BlakeTwo256, Convert, IdentityLookup, OnFinalize, Zero}, + weights::Weight, + BuildStorage, DispatchError, Perbill, +}; +use srml_support::{impl_outer_event, impl_outer_origin, parameter_types}; +pub use system; + +mod balance_manager; +pub(crate) mod proposals; +mod stakes; + +use balance_manager::*; +pub use proposals::*; +pub use stakes::*; + +// Workaround for https://github.com/rust-lang/rust/issues/26925 . Remove when sorted. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Test; + +impl_outer_origin! { + pub enum Origin for Test {} +} + +mod engine { + pub use crate::Event; +} + +mod membership_mod { + pub use membership::members::Event; +} + +impl_outer_event! { + pub enum TestEvent for Test { + balances, + engine, + membership_mod, + } +} + +parameter_types! { + pub const ExistentialDeposit: u32 = 0; + pub const TransferFee: u32 = 0; + pub const CreationFee: u32 = 0; +} + +impl balances::Trait for Test { + /// The type for recording an account's balance. + type Balance = u64; + /// What to do if an account's free balance gets zeroed. + type OnFreeBalanceZero = (); + /// What to do if a new account is created. + type OnNewAccount = (); + + type TransferPayment = (); + + type DustRemoval = (); + type Event = TestEvent; + type ExistentialDeposit = ExistentialDeposit; + type TransferFee = TransferFee; + type CreationFee = CreationFee; +} + +impl common::currency::GovernanceCurrency for Test { + type Currency = balances::Module; +} + +impl proposals::Trait for Test {} + +impl stake::Trait for Test { + type Currency = Balances; + type StakePoolId = StakePoolId; + type StakingEventsHandler = BalanceManagerStakingEventsHandler; + type StakeId = u64; + type SlashId = u64; +} + +parameter_types! { + pub const CancellationFee: u64 = 5; + pub const RejectionFee: u64 = 3; + pub const TitleMaxLength: u32 = 100; + pub const DescriptionMaxLength: u32 = 10000; + pub const MaxActiveProposalLimit: u32 = 100; +} + +impl membership::members::Trait for Test { + type Event = TestEvent; + type MemberId = u64; + type PaidTermId = u64; + type SubscriptionId = u64; + type ActorId = u64; + type InitialMembersBalance = (); +} + +impl crate::Trait for Test { + type Event = TestEvent; + type ProposerOriginValidator = (); + type VoterOriginValidator = (); + type TotalVotersCounter = (); + type ProposalId = u32; + type StakeHandlerProvider = stakes::TestStakeHandlerProvider; + type CancellationFee = CancellationFee; + type RejectionFee = RejectionFee; + type TitleMaxLength = TitleMaxLength; + type DescriptionMaxLength = DescriptionMaxLength; + type MaxActiveProposalLimit = MaxActiveProposalLimit; + type DispatchableCallCode = proposals::Call; +} + +impl Default for proposals::Call { + fn default() -> Self { + panic!("shouldn't call default for Call"); + } +} + +impl common::origin_validator::ActorOriginValidator for () { + fn ensure_actor_origin(origin: Origin, _account_id: u64) -> Result { + let signed_account_id = system::ensure_signed(origin)?; + + Ok(signed_account_id) + } +} + +// If changing count is required, we can upgrade the implementation as shown here: +// https://substrate.dev/recipes/3-entrees/testing/externalities.html +impl crate::VotersParameters for () { + fn total_voters_count() -> u32 { + 4 + } +} + +parameter_types! { + pub const BlockHashCount: u64 = 250; + pub const MaximumBlockWeight: u32 = 1024; + pub const MaximumBlockLength: u32 = 2 * 1024; + pub const AvailableBlockRatio: Perbill = Perbill::one(); + pub const MinimumPeriod: u64 = 5; + pub const StakePoolId: [u8; 8] = *b"joystake"; +} + +impl system::Trait for Test { + type Origin = Origin; + type Call = (); + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = u64; + type Lookup = IdentityLookup; + type Header = Header; + type Event = TestEvent; + type BlockHashCount = BlockHashCount; + type MaximumBlockWeight = MaximumBlockWeight; + type MaximumBlockLength = MaximumBlockLength; + type AvailableBlockRatio = AvailableBlockRatio; + type Version = (); +} + +impl timestamp::Trait for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = MinimumPeriod; +} + +pub fn initial_test_ext() -> runtime_io::TestExternalities { + let t = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + t.into() +} + +pub type ProposalsEngine = crate::Module; +pub type System = system::Module; +pub type Balances = balances::Module; diff --git a/runtime-modules/proposals/engine/src/tests/mock/proposals.rs b/runtime-modules/proposals/engine/src/tests/mock/proposals.rs new file mode 100644 index 0000000000..b8b8cc6675 --- /dev/null +++ b/runtime-modules/proposals/engine/src/tests/mock/proposals.rs @@ -0,0 +1,18 @@ +//! Contains executable proposal extrinsic mocks + +use rstd::prelude::*; +use rstd::vec::Vec; +use srml_support::decl_module; +pub trait Trait: system::Trait {} + +decl_module! { + pub struct Module for enum Call where origin: T::Origin { + /// Working extrinsic test + pub fn dummy_proposal(_origin, _title: Vec, _description: Vec) {} + + /// Broken extrinsic test + pub fn faulty_proposal(_origin, _title: Vec, _description: Vec,) { + Err("ExecutionFailed")? + } + } +} diff --git a/runtime-modules/proposals/engine/src/tests/mock/stakes.rs b/runtime-modules/proposals/engine/src/tests/mock/stakes.rs new file mode 100644 index 0000000000..188c69c9e7 --- /dev/null +++ b/runtime-modules/proposals/engine/src/tests/mock/stakes.rs @@ -0,0 +1,66 @@ +#![cfg(test)] + +use rstd::marker::PhantomData; +use std::cell::RefCell; +use std::panic; +use std::rc::Rc; + +use super::Test; + +// Intercepts panic method +// Returns: whether panic occurred +pub(crate) fn panics(could_panic_func: F) -> bool { + { + let default_hook = panic::take_hook(); + panic::set_hook(Box::new(|info| { + println!("{}", info); + })); + + // intercept panic + let result = panic::catch_unwind(|| could_panic_func()); + + //restore default behaviour + panic::set_hook(default_hook); + + result.is_err() + } +} + +// Test StakeHandlerProvider implementation based on local thread static variables +pub struct TestStakeHandlerProvider; +impl crate::StakeHandlerProvider for TestStakeHandlerProvider { + /// Returns StakeHandler. Mock entry point for stake module. + fn stakes() -> Rc> { + THREAD_LOCAL_STAKE_HANDLER.with(|f| f.borrow().clone()) + } +} + +// 1. RefCell - thread_local! mutation pattern +// 2. Rc - ability to have multiple references +thread_local! { + pub static THREAD_LOCAL_STAKE_HANDLER: + RefCell>> = RefCell::new(Rc::new(crate::types::DefaultStakeHandler{marker: PhantomData::})); +} + +// Sets stake handler implementation. Mockall framework integration. +pub(crate) fn set_stake_handler_impl(mock: Rc>) { + THREAD_LOCAL_STAKE_HANDLER.with(|f| { + *f.borrow_mut() = mock.clone(); + }); +} + +// Tests mock expectation and restores default behaviour +pub(crate) fn test_expectation_and_clear_mock() { + set_stake_handler_impl(Rc::new(crate::types::DefaultStakeHandler { + marker: PhantomData::, + })); +} + +// Intercepts panic in provided function, test mock expectation and restores default behaviour +pub(crate) fn handle_mock(func: F) { + let panicked = panics(func); + + test_expectation_and_clear_mock(); + + assert!(!panicked); +} diff --git a/runtime-modules/proposals/engine/src/tests/mod.rs b/runtime-modules/proposals/engine/src/tests/mod.rs new file mode 100644 index 0000000000..5cdc3ec0e0 --- /dev/null +++ b/runtime-modules/proposals/engine/src/tests/mod.rs @@ -0,0 +1,1581 @@ +pub(crate) mod mock; + +use crate::*; +use mock::*; + +use codec::Encode; +use rstd::rc::Rc; +use sr_primitives::traits::{DispatchResult, OnFinalize, OnInitialize}; +use srml_support::{StorageDoubleMap, StorageMap, StorageValue}; +use system::RawOrigin; +use system::{EventRecord, Phase}; + +use srml_support::traits::Currency; + +pub(crate) fn increase_total_balance_issuance_using_account_id(account_id: u64, balance: u64) { + let initial_balance = Balances::total_issuance(); + { + let _ = ::Currency::deposit_creating(&account_id, balance); + } + assert_eq!(Balances::total_issuance(), initial_balance + balance); +} + +struct ProposalParametersFixture { + parameters: ProposalParameters, +} + +impl ProposalParametersFixture { + fn with_required_stake(&self, required_stake: BalanceOf) -> Self { + ProposalParametersFixture { + parameters: ProposalParameters { + required_stake: Some(required_stake), + ..self.parameters + }, + } + } + fn with_grace_period(&self, grace_period: u64) -> Self { + ProposalParametersFixture { + parameters: ProposalParameters { + grace_period, + ..self.parameters + }, + } + } + + fn params(&self) -> ProposalParameters { + self.parameters.clone() + } +} + +impl Default for ProposalParametersFixture { + fn default() -> Self { + ProposalParametersFixture { + parameters: ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 60, + approval_threshold_percentage: 60, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 60, + grace_period: 0, + required_stake: None, + }, + } + } +} + +#[derive(Clone)] +struct DummyProposalFixture { + parameters: ProposalParameters, + account_id: u64, + proposer_id: u64, + proposal_code: Vec, + title: Vec, + description: Vec, + stake_balance: Option>, +} + +impl Default for DummyProposalFixture { + fn default() -> Self { + let title = b"title".to_vec(); + let description = b"description".to_vec(); + let dummy_proposal = + mock::proposals::Call::::dummy_proposal(title.clone(), description.clone()); + + DummyProposalFixture { + parameters: ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 60, + approval_threshold_percentage: 60, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 60, + grace_period: 0, + required_stake: None, + }, + account_id: 1, + proposer_id: 1, + proposal_code: dummy_proposal.encode(), + title, + description, + stake_balance: None, + } + } +} + +impl DummyProposalFixture { + fn with_title_and_body(self, title: Vec, description: Vec) -> Self { + DummyProposalFixture { + title, + description, + ..self + } + } + + fn with_parameters(self, parameters: ProposalParameters) -> Self { + DummyProposalFixture { parameters, ..self } + } + + fn with_account_id(self, account_id: u64) -> Self { + DummyProposalFixture { account_id, ..self } + } + + fn with_stake(self, stake_balance: BalanceOf) -> Self { + DummyProposalFixture { + stake_balance: Some(stake_balance), + ..self + } + } + + fn with_proposal_code(self, proposal_code: Vec) -> Self { + DummyProposalFixture { + proposal_code, + ..self + } + } + + fn create_proposal_and_assert(self, result: Result) -> Option { + let proposal_id_result = ProposalsEngine::create_proposal( + self.account_id, + self.proposer_id, + self.parameters, + self.title, + self.description, + self.stake_balance, + self.proposal_code, + ); + assert_eq!(proposal_id_result, result); + + proposal_id_result.ok() + } +} + +struct CancelProposalFixture { + origin: RawOrigin, + proposal_id: u32, + proposer_id: u64, +} + +impl CancelProposalFixture { + fn new(proposal_id: u32) -> Self { + CancelProposalFixture { + proposal_id, + origin: RawOrigin::Signed(1), + proposer_id: 1, + } + } + + fn with_origin(self, origin: RawOrigin) -> Self { + CancelProposalFixture { origin, ..self } + } + + fn with_proposer(self, proposer_id: u64) -> Self { + CancelProposalFixture { + proposer_id, + ..self + } + } + + fn cancel_and_assert(self, expected_result: DispatchResult) { + assert_eq!( + ProposalsEngine::cancel_proposal( + self.origin.into(), + self.proposer_id, + self.proposal_id + ), + expected_result + ); + } +} +struct VetoProposalFixture { + origin: RawOrigin, + proposal_id: u32, +} + +impl VetoProposalFixture { + fn new(proposal_id: u32) -> Self { + VetoProposalFixture { + proposal_id, + origin: RawOrigin::Root, + } + } + + fn with_origin(self, origin: RawOrigin) -> Self { + VetoProposalFixture { origin, ..self } + } + + fn veto_and_assert(self, expected_result: DispatchResult) { + assert_eq!( + ProposalsEngine::veto_proposal(self.origin.into(), self.proposal_id,), + expected_result + ); + } +} + +struct VoteGenerator { + proposal_id: u32, + current_account_id: u64, + current_voter_id: u64, + pub auto_increment_voter_id: bool, +} + +impl VoteGenerator { + fn new(proposal_id: u32) -> Self { + VoteGenerator { + proposal_id, + current_voter_id: 0, + current_account_id: 0, + auto_increment_voter_id: true, + } + } + fn vote_and_assert_ok(&mut self, vote_kind: VoteKind) { + self.vote_and_assert(vote_kind, Ok(())); + } + + fn vote_and_assert(&mut self, vote_kind: VoteKind, expected_result: DispatchResult) { + assert_eq!(self.vote(vote_kind.clone()), expected_result); + } + + fn vote(&mut self, vote_kind: VoteKind) -> DispatchResult { + if self.auto_increment_voter_id { + self.current_account_id += 1; + self.current_voter_id += 1; + } + + ProposalsEngine::vote( + system::RawOrigin::Signed(self.current_account_id).into(), + self.current_voter_id, + self.proposal_id, + vote_kind, + ) + } +} + +struct EventFixture; +impl EventFixture { + fn assert_events(expected_raw_events: Vec>) { + let expected_events = expected_raw_events + .iter() + .map(|ev| EventRecord { + phase: Phase::ApplyExtrinsic(0), + event: TestEvent::engine(ev.clone()), + topics: vec![], + }) + .collect::>>(); + + assert_eq!(System::events(), expected_events); + } +} + +// Recommendation from Parity on testing on_finalize +// https://substrate.dev/docs/en/next/development/module/tests +fn run_to_block(n: u64) { + while System::block_number() < n { + >::on_finalize(System::block_number()); + >::on_finalize(System::block_number()); + System::set_block_number(System::block_number() + 1); + >::on_initialize(System::block_number()); + >::on_initialize(System::block_number()); + } +} + +fn run_to_block_and_finalize(n: u64) { + run_to_block(n); + >::on_finalize(n); +} + +#[test] +fn create_dummy_proposal_succeeds() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + + dummy_proposal.create_proposal_and_assert(Ok(1)); + }); +} + +#[test] +fn vote_succeeds() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + }); +} + +#[test] +fn vote_fails_with_insufficient_rights() { + initial_test_ext().execute_with(|| { + assert_eq!( + ProposalsEngine::vote(system::RawOrigin::None.into(), 1, 1, VoteKind::Approve), + Err(Error::Other("RequireSignedOrigin")) + ); + }); +} + +#[test] +fn proposal_execution_succeeds() { + initial_test_ext().execute_with(|| { + let parameters_fixture = ProposalParametersFixture::default(); + let dummy_proposal = + DummyProposalFixture::default().with_parameters(parameters_fixture.params()); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + // internal active proposal counter check + assert_eq!(::get(), 1); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + + run_to_block_and_finalize(1); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::approved(ApprovedProposalStatus::Executed, 1), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults { + abstentions: 0, + approvals: 4, + rejections: 0, + slashes: 0, + }, + } + ); + + // internal active proposal counter check + assert_eq!(::get(), 0); + }); +} + +#[test] +fn proposal_execution_failed() { + initial_test_ext().execute_with(|| { + let parameters_fixture = ProposalParametersFixture::default(); + + let faulty_proposal = mock::proposals::Call::::faulty_proposal( + b"title".to_vec(), + b"description".to_vec(), + ); + + let dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters_fixture.params()) + .with_proposal_code(faulty_proposal.encode()); + + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + + run_to_block_and_finalize(2); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::approved( + ApprovedProposalStatus::failed_execution("ExecutionFailed"), + 1 + ), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults { + abstentions: 0, + approvals: 4, + rejections: 0, + slashes: 0, + }, + } + ) + }); +} + +#[test] +fn voting_results_calculation_succeeds() { + initial_test_ext().execute_with(|| { + let parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 50, + approval_threshold_percentage: 50, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 60, + grace_period: 0, + required_stake: None, + }; + let dummy_proposal = DummyProposalFixture::default().with_parameters(parameters); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + + run_to_block_and_finalize(2); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 1, + approvals: 2, + rejections: 1, + slashes: 0, + } + ) + }); +} + +#[test] +fn rejected_voting_results_and_remove_proposal_id_from_active_succeeds() { + initial_test_ext().execute_with(|| { + // internal active proposal counter check + assert_eq!(::get(), 0); + + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + + assert!(>::exists(proposal_id)); + + // internal active proposal counter check + assert_eq!(::get(), 1); + + run_to_block_and_finalize(2); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 2, + approvals: 0, + rejections: 2, + slashes: 0, + } + ); + + assert_eq!( + proposal.status, + ProposalStatus::finalized_successfully(ProposalDecisionStatus::Rejected, 1), + ); + assert!(!>::exists(proposal_id)); + + // internal active proposal counter check + assert_eq!(::get(), 0); + }); +} + +#[test] +fn create_proposal_fails_with_invalid_body_or_title() { + initial_test_ext().execute_with(|| { + let mut dummy_proposal = + DummyProposalFixture::default().with_title_and_body(Vec::new(), b"body".to_vec()); + dummy_proposal.create_proposal_and_assert(Err(Error::EmptyTitleProvided.into())); + + dummy_proposal = + DummyProposalFixture::default().with_title_and_body(b"title".to_vec(), Vec::new()); + dummy_proposal.create_proposal_and_assert(Err(Error::EmptyDescriptionProvided.into())); + + let too_long_title = vec![0; 200]; + dummy_proposal = + DummyProposalFixture::default().with_title_and_body(too_long_title, b"body".to_vec()); + dummy_proposal.create_proposal_and_assert(Err(Error::TitleIsTooLong.into())); + + let too_long_body = vec![0; 11000]; + dummy_proposal = + DummyProposalFixture::default().with_title_and_body(b"title".to_vec(), too_long_body); + dummy_proposal.create_proposal_and_assert(Err(Error::DescriptionIsTooLong.into())); + }); +} + +#[test] +fn vote_fails_with_expired_voting_period() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + run_to_block_and_finalize(6); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert(VoteKind::Approve, Err(Error::ProposalFinalized)); + }); +} + +#[test] +fn vote_fails_with_not_active_proposal() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + + run_to_block_and_finalize(2); + + let mut vote_generator_to_fail = VoteGenerator::new(proposal_id); + vote_generator_to_fail.vote_and_assert(VoteKind::Approve, Err(Error::ProposalFinalized)); + }); +} + +#[test] +fn vote_fails_with_absent_proposal() { + initial_test_ext().execute_with(|| { + let mut vote_generator = VoteGenerator::new(2); + vote_generator.vote_and_assert(VoteKind::Approve, Err(Error::ProposalNotFound)); + }); +} + +#[test] +fn vote_fails_on_double_voting() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.auto_increment_voter_id = false; + + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert(VoteKind::Approve, Err(Error::AlreadyVoted)); + }); +} + +#[test] +fn cancel_proposal_succeeds() { + initial_test_ext().execute_with(|| { + let parameters_fixture = ProposalParametersFixture::default(); + let dummy_proposal = + DummyProposalFixture::default().with_parameters(parameters_fixture.params()); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + // internal active proposal counter check + assert_eq!(::get(), 1); + + let cancel_proposal = CancelProposalFixture::new(proposal_id); + cancel_proposal.cancel_and_assert(Ok(())); + + // internal active proposal counter check + assert_eq!(::get(), 0); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::finalized_successfully(ProposalDecisionStatus::Canceled, 1), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults::default(), + } + ) + }); +} + +#[test] +fn cancel_proposal_fails_with_not_active_proposal() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + run_to_block_and_finalize(6); + + let cancel_proposal = CancelProposalFixture::new(proposal_id); + cancel_proposal.cancel_and_assert(Err(Error::ProposalFinalized)); + }); +} + +#[test] +fn cancel_proposal_fails_with_not_existing_proposal() { + initial_test_ext().execute_with(|| { + let cancel_proposal = CancelProposalFixture::new(2); + cancel_proposal.cancel_and_assert(Err(Error::ProposalNotFound)); + }); +} + +#[test] +fn cancel_proposal_fails_with_insufficient_rights() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let cancel_proposal = CancelProposalFixture::new(proposal_id) + .with_origin(RawOrigin::Signed(2)) + .with_proposer(2); + cancel_proposal.cancel_and_assert(Err(Error::NotAuthor)); + }); +} + +#[test] +fn veto_proposal_succeeds() { + initial_test_ext().execute_with(|| { + // internal active proposal counter check + assert_eq!(::get(), 0); + + let parameters_fixture = ProposalParametersFixture::default(); + let dummy_proposal = + DummyProposalFixture::default().with_parameters(parameters_fixture.params()); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + // internal active proposal counter check + assert_eq!(::get(), 1); + + let veto_proposal = VetoProposalFixture::new(proposal_id); + veto_proposal.veto_and_assert(Ok(())); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::finalized_successfully(ProposalDecisionStatus::Vetoed, 1), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults::default(), + } + ); + + // internal active proposal counter check + assert_eq!(::get(), 0); + }); +} + +#[test] +fn veto_proposal_fails_with_not_active_proposal() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + run_to_block_and_finalize(6); + + let veto_proposal = VetoProposalFixture::new(proposal_id); + veto_proposal.veto_and_assert(Err(Error::ProposalFinalized)); + }); +} + +#[test] +fn veto_proposal_fails_with_not_existing_proposal() { + initial_test_ext().execute_with(|| { + let veto_proposal = VetoProposalFixture::new(2); + veto_proposal.veto_and_assert(Err(Error::ProposalNotFound)); + }); +} + +#[test] +fn veto_proposal_fails_with_insufficient_rights() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let veto_proposal = VetoProposalFixture::new(proposal_id).with_origin(RawOrigin::Signed(2)); + veto_proposal.veto_and_assert(Err(Error::RequireRootOrigin)); + }); +} + +#[test] +fn create_proposal_event_emitted() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(1)); + + EventFixture::assert_events(vec![RawEvent::ProposalCreated(1, 1)]); + }); +} + +#[test] +fn veto_proposal_event_emitted() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let veto_proposal = VetoProposalFixture::new(proposal_id); + veto_proposal.veto_and_assert(Ok(())); + + EventFixture::assert_events(vec![ + RawEvent::ProposalCreated(1, 1), + RawEvent::ProposalStatusUpdated( + 1, + ProposalStatus::finalized_successfully(ProposalDecisionStatus::Vetoed, 1), + ), + ]); + }); +} + +#[test] +fn cancel_proposal_event_emitted() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let cancel_proposal = CancelProposalFixture::new(proposal_id); + cancel_proposal.cancel_and_assert(Ok(())); + + EventFixture::assert_events(vec![ + RawEvent::ProposalCreated(1, 1), + RawEvent::ProposalStatusUpdated( + 1, + ProposalStatus::Finalized(FinalizationData { + proposal_status: ProposalDecisionStatus::Canceled, + encoded_unstaking_error_due_to_broken_runtime: None, + stake_data_after_unstaking_error: None, + finalized_at: 1, + }), + ), + ]); + }); +} + +#[test] +fn vote_proposal_event_emitted() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + + EventFixture::assert_events(vec![ + RawEvent::ProposalCreated(1, 1), + RawEvent::Voted(1, 1, VoteKind::Approve), + ]); + }); +} + +#[test] +fn create_proposal_and_expire_it() { + initial_test_ext().execute_with(|| { + let parameters_fixture = ProposalParametersFixture::default(); + let dummy_proposal = + DummyProposalFixture::default().with_parameters(parameters_fixture.params()); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + run_to_block_and_finalize(8); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::finalized_successfully(ProposalDecisionStatus::Expired, 4), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults::default(), + } + ) + }); +} + +#[test] +fn proposal_execution_postponed_because_of_grace_period() { + initial_test_ext().execute_with(|| { + let parameters_fixture = ProposalParametersFixture::default().with_grace_period(2); + let dummy_proposal = + DummyProposalFixture::default().with_parameters(parameters_fixture.params()); + + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + + run_to_block_and_finalize(1); + run_to_block_and_finalize(2); + + // check internal cache for proposal_id presense + assert!(>::enumerate() + .find(|(x, _)| *x == proposal_id) + .is_some()); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::approved(ApprovedProposalStatus::PendingExecution, 1), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults { + abstentions: 0, + approvals: 4, + rejections: 0, + slashes: 0, + }, + } + ); + }); +} + +#[test] +fn proposal_execution_vetoed_successfully_during_the_grace_period() { + initial_test_ext().execute_with(|| { + let parameters_fixture = ProposalParametersFixture::default().with_grace_period(2); + let dummy_proposal = + DummyProposalFixture::default().with_parameters(parameters_fixture.params()); + + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + + run_to_block_and_finalize(1); + run_to_block_and_finalize(2); + + // check internal cache for proposal_id presense + assert!(>::enumerate() + .find(|(x, _)| *x == proposal_id) + .is_some()); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::approved(ApprovedProposalStatus::PendingExecution, 1), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults { + abstentions: 0, + approvals: 4, + rejections: 0, + slashes: 0, + }, + } + ); + + let veto_proposal = VetoProposalFixture::new(proposal_id); + veto_proposal.veto_and_assert(Ok(())); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::finalized_successfully(ProposalDecisionStatus::Vetoed, 2), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults { + abstentions: 0, + approvals: 4, + rejections: 0, + slashes: 0, + }, + } + ); + + // check internal cache for proposal_id presense + assert!(>::enumerate() + .find(|(x, _)| *x == proposal_id) + .is_none()); + }); +} + +#[test] +fn proposal_execution_succeeds_after_the_grace_period() { + initial_test_ext().execute_with(|| { + let parameters_fixture = ProposalParametersFixture::default().with_grace_period(1); + let dummy_proposal = + DummyProposalFixture::default().with_parameters(parameters_fixture.params()); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + vote_generator.vote_and_assert_ok(VoteKind::Approve); + + run_to_block_and_finalize(1); + + // check internal cache for proposal_id presence + assert!(>::enumerate() + .find(|(x, _)| *x == proposal_id) + .is_some()); + + let mut proposal = >::get(proposal_id); + + let mut expected_proposal = Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::approved(ApprovedProposalStatus::PendingExecution, 1), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults { + abstentions: 0, + approvals: 4, + rejections: 0, + slashes: 0, + }, + }; + + assert_eq!(proposal, expected_proposal); + + run_to_block_and_finalize(2); + + proposal = >::get(proposal_id); + + expected_proposal.status = ProposalStatus::approved(ApprovedProposalStatus::Executed, 1); + + assert_eq!(proposal, expected_proposal); + + // check internal cache for proposal_id absense + assert!(>::enumerate() + .find(|(x, _)| *x == proposal_id) + .is_none()); + }); +} + +#[test] +fn create_proposal_fails_on_exceeding_max_active_proposals_count() { + initial_test_ext().execute_with(|| { + for idx in 1..101 { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(idx)); + // internal active proposal counter check + assert_eq!(::get(), idx); + } + + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal + .create_proposal_and_assert(Err(Error::MaxActiveProposalNumberExceeded.into())); + // internal active proposal counter check + assert_eq!(::get(), 100); + }); +} + +#[test] +fn voting_internal_cache_exists_after_proposal_finalization() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + dummy_proposal.create_proposal_and_assert(Ok(1)); + + // last created proposal id equals current proposal count + let proposal_id = ::get(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + + // cache exists + assert!(>::exists( + proposal_id, + 1 + )); + + run_to_block_and_finalize(2); + + // cache still exists and is not cleared + assert!(>::exists( + proposal_id, + 1 + )); + }); +} + +#[test] +fn create_dummy_proposal_succeeds_with_stake() { + initial_test_ext().execute_with(|| { + let account_id = 1; + + let required_stake = 200; + let parameters_fixture = + ProposalParametersFixture::default().with_required_stake(required_stake); + + let dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters_fixture.params()) + .with_account_id(account_id) + .with_stake(200); + + let _imbalance = ::Currency::deposit_creating(&account_id, 500); + + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal, + Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::Active(Some(ActiveStake { + stake_id: 0, // valid stake_id + source_account_id: 1 + })), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults::default(), + } + ) + }); +} + +#[test] +fn create_dummy_proposal_fail_with_stake_on_empty_account() { + initial_test_ext().execute_with(|| { + let account_id = 1; + + let required_stake = 200; + let parameters_fixture = + ProposalParametersFixture::default().with_required_stake(required_stake); + let dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters_fixture.params()) + .with_account_id(account_id) + .with_stake(required_stake); + + dummy_proposal + .create_proposal_and_assert(Err(Error::Other("too few free funds in account"))); + }); +} + +#[test] +fn create_proposal_fais_with_invalid_stake_parameters() { + initial_test_ext().execute_with(|| { + let parameters_fixture = ProposalParametersFixture::default(); + + let mut dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters_fixture.params()) + .with_stake(200); + + dummy_proposal.create_proposal_and_assert(Err(Error::StakeShouldBeEmpty.into())); + + let parameters_fixture_stake_200 = parameters_fixture.with_required_stake(200); + dummy_proposal = + DummyProposalFixture::default().with_parameters(parameters_fixture_stake_200.params()); + + dummy_proposal.create_proposal_and_assert(Err(Error::EmptyStake.into())); + + let parameters_fixture_stake_300 = parameters_fixture.with_required_stake(300); + dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters_fixture_stake_300.params()) + .with_stake(200); + + dummy_proposal.create_proposal_and_assert(Err(Error::StakeDiffersFromRequired.into())); + }); +} + +#[test] +fn finalize_expired_proposal_and_check_stake_removing_with_balance_checks_succeeds() { + initial_test_ext().execute_with(|| { + let account_id = 1; + + let stake_amount = 200; + let parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 50, + approval_threshold_percentage: 60, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 60, + grace_period: 5, + required_stake: Some(stake_amount), + }; + let dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters) + .with_account_id(account_id) + .with_stake(stake_amount); + + let account_balance = 500; + let _imbalance = + ::Currency::deposit_creating(&account_id, account_balance); + + assert_eq!( + ::Currency::total_balance(&account_id), + account_balance + ); + + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + assert_eq!( + ::Currency::total_balance(&account_id), + account_balance - stake_amount + ); + + let mut proposal = >::get(proposal_id); + + let mut expected_proposal = Proposal { + parameters, + proposer_id: 1, + created_at: 1, + status: ProposalStatus::Active(Some(ActiveStake { + stake_id: 0, + source_account_id: 1, + })), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults::default(), + }; + + assert_eq!(proposal, expected_proposal); + + run_to_block_and_finalize(5); + + proposal = >::get(proposal_id); + + expected_proposal.status = ProposalStatus::Finalized(FinalizationData { + proposal_status: ProposalDecisionStatus::Expired, + finalized_at: 4, + encoded_unstaking_error_due_to_broken_runtime: None, + stake_data_after_unstaking_error: None, + }); + + assert_eq!(proposal, expected_proposal); + + let rejection_fee = RejectionFee::get(); + assert_eq!( + ::Currency::total_balance(&account_id), + account_balance - rejection_fee + ); + }); +} + +#[test] +fn proposal_cancellation_with_slashes_with_balance_checks_succeeds() { + initial_test_ext().execute_with(|| { + let account_id = 1; + + let stake_amount = 200; + let parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 50, + approval_threshold_percentage: 60, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 60, + grace_period: 5, + required_stake: Some(stake_amount), + }; + let dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters) + .with_account_id(account_id.clone()) + .with_stake(stake_amount); + + let account_balance = 500; + let _imbalance = + ::Currency::deposit_creating(&account_id, account_balance); + + assert_eq!( + ::Currency::total_balance(&account_id), + account_balance + ); + + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + assert_eq!( + ::Currency::total_balance(&account_id), + account_balance - stake_amount + ); + + let mut proposal = >::get(proposal_id); + + let mut expected_proposal = Proposal { + parameters, + proposer_id: 1, + created_at: 1, + status: ProposalStatus::Active(Some(ActiveStake { + stake_id: 0, + source_account_id: 1, + })), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults::default(), + }; + + assert_eq!(proposal, expected_proposal); + + let cancel_proposal_fixture = CancelProposalFixture::new(proposal_id); + + cancel_proposal_fixture.cancel_and_assert(Ok(())); + + proposal = >::get(proposal_id); + + expected_proposal.status = ProposalStatus::Finalized(FinalizationData { + proposal_status: ProposalDecisionStatus::Canceled, + finalized_at: 1, + encoded_unstaking_error_due_to_broken_runtime: None, + stake_data_after_unstaking_error: None, + }); + + assert_eq!(proposal, expected_proposal); + + let cancellation_fee = CancellationFee::get(); + assert_eq!( + ::Currency::total_balance(&account_id), + account_balance - cancellation_fee + ); + }); +} + +#[test] +fn finalize_proposal_using_stake_mocks_succeeds() { + handle_mock(|| { + initial_test_ext().execute_with(|| { + let mock = { + let mut mock = crate::types::MockStakeHandler::::new(); + mock.expect_create_stake().times(1).returning(|| Ok(1)); + + mock.expect_make_stake_imbalance() + .times(1) + .returning(|_, _| Ok(crate::types::NegativeImbalance::::new(200))); + + mock.expect_stake().times(1).returning(|_, _| Ok(())); + + mock.expect_remove_stake().times(1).returning(|_| Ok(())); + + mock.expect_unstake().times(1).returning(|_| Ok(())); + + mock.expect_slash().times(1).returning(|_, _| Ok(())); + + Rc::new(mock) + }; + set_stake_handler_impl(mock.clone()); + + let account_id = 1; + + let stake_amount = 200; + let parameters_fixture = + ProposalParametersFixture::default().with_required_stake(stake_amount); + let dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters_fixture.params()) + .with_account_id(account_id) + .with_stake(stake_amount); + + let _proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + run_to_block_and_finalize(5); + }); + }); +} + +#[test] +fn proposal_slashing_succeeds() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Slash); + vote_generator.vote_and_assert_ok(VoteKind::Slash); + vote_generator.vote_and_assert_ok(VoteKind::Slash); + + assert!(>::exists(proposal_id)); + + run_to_block_and_finalize(2); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 0, + approvals: 0, + rejections: 1, + slashes: 3, + } + ); + + assert_eq!( + proposal.status, + ProposalStatus::Finalized(FinalizationData { + proposal_status: ProposalDecisionStatus::Slashed, + encoded_unstaking_error_due_to_broken_runtime: None, + finalized_at: 1, + stake_data_after_unstaking_error: None, + }), + ); + assert!(!>::exists(proposal_id)); + }); +} + +#[test] +fn finalize_proposal_using_stake_mocks_failed() { + handle_mock(|| { + initial_test_ext().execute_with(|| { + let mock = { + let mut mock = crate::types::MockStakeHandler::::new(); + mock.expect_create_stake().times(1).returning(|| Ok(1)); + + mock.expect_remove_stake() + .times(1) + .returning(|_| Err("Cannot remove stake")); + + mock.expect_make_stake_imbalance() + .times(1) + .returning(|_, _| Ok(crate::types::NegativeImbalance::::new(200))); + + mock.expect_stake().times(1).returning(|_, _| Ok(())); + + mock.expect_unstake().times(1).returning(|_| Ok(())); + + mock.expect_slash().times(1).returning(|_, _| Ok(())); + + Rc::new(mock) + }; + set_stake_handler_impl(mock.clone()); + + let account_id = 1; + + let stake_amount = 200; + let parameters_fixture = + ProposalParametersFixture::default().with_required_stake(stake_amount); + let dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters_fixture.params()) + .with_account_id(account_id) + .with_stake(stake_amount); + + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + run_to_block_and_finalize(5); + + let proposal = >::get(proposal_id); + assert_eq!( + proposal, + Proposal { + parameters: parameters_fixture.params(), + proposer_id: 1, + created_at: 1, + status: ProposalStatus::finalized( + ProposalDecisionStatus::Expired, + Some("Cannot remove stake"), + Some(ActiveStake { + stake_id: 1, + source_account_id: 1 + }), + 4, + ), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults::default(), + } + ); + }); + }); +} + +#[test] +fn create_proposal_fails_with_invalid_threshold_parameters() { + initial_test_ext().execute_with(|| { + let mut parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 50, + approval_threshold_percentage: 0, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 60, + grace_period: 5, + required_stake: None, + }; + + let mut dummy_proposal = DummyProposalFixture::default().with_parameters(parameters); + + dummy_proposal + .create_proposal_and_assert(Err(Error::InvalidParameterApprovalThreshold.into())); + + parameters.approval_threshold_percentage = 60; + parameters.slashing_threshold_percentage = 0; + dummy_proposal = DummyProposalFixture::default().with_parameters(parameters); + + dummy_proposal + .create_proposal_and_assert(Err(Error::InvalidParameterSlashingThreshold.into())); + }); +} + +#[test] +fn proposal_reset_succeeds() { + initial_test_ext().execute_with(|| { + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + vote_generator.vote_and_assert_ok(VoteKind::Slash); + + assert!(>::exists(proposal_id)); + assert_eq!( + >::get(&proposal_id, &2), + VoteKind::Abstain + ); + + run_to_block_and_finalize(2); + + let proposal = >::get(proposal_id); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 1, + approvals: 0, + rejections: 1, + slashes: 1, + } + ); + + ProposalsEngine::reset_active_proposals(); + + let updated_proposal = >::get(proposal_id); + + assert_eq!( + updated_proposal.voting_results, + VotingResults { + abstentions: 0, + approvals: 0, + rejections: 0, + slashes: 0, + } + ); + + // whole double map prefix was removed (should return default value) + assert_eq!( + >::get(&proposal_id, &2), + VoteKind::default() + ); + }); +} + +#[test] +fn proposal_counters_are_valid() { + initial_test_ext().execute_with(|| { + let mut dummy_proposal = DummyProposalFixture::default(); + let _ = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + dummy_proposal = DummyProposalFixture::default(); + let _ = dummy_proposal.create_proposal_and_assert(Ok(2)).unwrap(); + + dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(3)).unwrap(); + + assert_eq!(ActiveProposalCount::get(), 3); + assert_eq!(ProposalCount::get(), 3); + + let cancel_proposal_fixture = CancelProposalFixture::new(proposal_id); + cancel_proposal_fixture.cancel_and_assert(Ok(())); + + assert_eq!(ActiveProposalCount::get(), 2); + assert_eq!(ProposalCount::get(), 3); + }); +} + +#[test] +fn proposal_stake_cache_is_valid() { + initial_test_ext().execute_with(|| { + increase_total_balance_issuance_using_account_id(1, 50000); + + let stake = 250u32; + let parameters = ProposalParametersFixture::default().with_required_stake(stake.into()); + let dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters.params()) + .with_stake(stake as u64); + + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + let expected_stake_id = 0; + assert_eq!( + >::get(&expected_stake_id), + proposal_id + ); + }); +} + +#[test] +fn slash_balance_is_calculated_correctly() { + initial_test_ext().execute_with(|| { + let vetoed_slash_balance = ProposalsEngine::calculate_slash_balance( + &ProposalDecisionStatus::Vetoed, + &ProposalParametersFixture::default().params(), + ); + + assert_eq!(vetoed_slash_balance, 0); + + let approved_slash_balance = ProposalsEngine::calculate_slash_balance( + &ProposalDecisionStatus::Approved(ApprovedProposalStatus::Executed), + &ProposalParametersFixture::default().params(), + ); + + assert_eq!(approved_slash_balance, 0); + + let rejection_fee = ::RejectionFee::get(); + + let rejected_slash_balance = ProposalsEngine::calculate_slash_balance( + &ProposalDecisionStatus::Rejected, + &ProposalParametersFixture::default().params(), + ); + + assert_eq!(rejected_slash_balance, rejection_fee); + + let expired_slash_balance = ProposalsEngine::calculate_slash_balance( + &ProposalDecisionStatus::Expired, + &ProposalParametersFixture::default().params(), + ); + + assert_eq!(expired_slash_balance, rejection_fee); + + let cancellation_fee = ::CancellationFee::get(); + + let cancellation_slash_balance = ProposalsEngine::calculate_slash_balance( + &ProposalDecisionStatus::Canceled, + &ProposalParametersFixture::default().params(), + ); + + assert_eq!(cancellation_slash_balance, cancellation_fee); + + let slash_balance_with_no_stake = ProposalsEngine::calculate_slash_balance( + &ProposalDecisionStatus::Slashed, + &ProposalParametersFixture::default().params(), + ); + + assert_eq!(slash_balance_with_no_stake, 0); + + let stake = 256; + let slash_balance_with_stake = ProposalsEngine::calculate_slash_balance( + &ProposalDecisionStatus::Slashed, + &ProposalParametersFixture::default() + .with_required_stake(stake) + .params(), + ); + + assert_eq!(slash_balance_with_stake, stake); + }); +} diff --git a/runtime-modules/proposals/engine/src/types/mod.rs b/runtime-modules/proposals/engine/src/types/mod.rs new file mode 100644 index 0000000000..d6ad1a8c73 --- /dev/null +++ b/runtime-modules/proposals/engine/src/types/mod.rs @@ -0,0 +1,793 @@ +//! Proposals types module for the Joystream platform. Version 2. +//! Provides types for the proposal engine. + +#![warn(missing_docs)] + +use codec::{Decode, Encode}; +use rstd::cmp::PartialOrd; +use rstd::ops::Add; +use rstd::prelude::*; + +#[cfg(feature = "std")] +use serde::{Deserialize, Serialize}; +use sr_primitives::Perbill; +use srml_support::dispatch; +use srml_support::traits::Currency; + +mod proposal_statuses; +mod stakes; + +pub use proposal_statuses::{ + ApprovedProposalStatus, FinalizationData, ProposalDecisionStatus, ProposalStatus, +}; +pub(crate) use stakes::ProposalStakeManager; +pub use stakes::{DefaultStakeHandlerProvider, StakeHandler, StakeHandlerProvider}; + +#[cfg(test)] +pub(crate) use stakes::DefaultStakeHandler; + +#[cfg(test)] +pub(crate) use stakes::MockStakeHandler; + +/// Vote kind for the proposal. Sum of all votes defines proposal status. +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug)] +pub enum VoteKind { + /// Pass, an alternative or a ranking, for binary, multiple choice + /// and ranked choice propositions, respectively. + Approve, + + /// Against proposal. + Reject, + + /// Reject proposal and slash it stake. + Slash, + + /// Signals presence, but unwillingness to cast judgment on substance of vote. + Abstain, +} + +impl Default for VoteKind { + fn default() -> Self { + VoteKind::Reject + } +} + +/// Proposal parameters required to manage proposal risk. +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Default, Clone, Copy, PartialEq, Eq, Debug)] +pub struct ProposalParameters { + /// During this period, votes can be accepted + pub voting_period: BlockNumber, + + /// A pause before execution of the approved proposal. Zero means approved proposal would be + /// executed immediately. + pub grace_period: BlockNumber, + + /// Quorum percentage of approving voters required to pass the proposal. + pub approval_quorum_percentage: u32, + + /// Approval votes percentage threshold to pass the proposal. + pub approval_threshold_percentage: u32, + + /// Quorum percentage of voters required to slash the proposal. + pub slashing_quorum_percentage: u32, + + /// Slashing votes percentage threshold to slash the proposal. + pub slashing_threshold_percentage: u32, + + /// Proposal stake + pub required_stake: Option, +} + +/// Contains current voting results +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Default, Clone, PartialEq, Eq, Debug)] +pub struct VotingResults { + /// 'Abstain' votes counter + pub abstentions: u32, + + /// 'Approve' votes counter + pub approvals: u32, + + /// 'Reject' votes counter + pub rejections: u32, + + /// 'Slash' votes counter + pub slashes: u32, +} + +impl VotingResults { + /// Add vote to the related counter + pub fn add_vote(&mut self, vote: VoteKind) { + match vote { + VoteKind::Abstain => self.abstentions += 1, + VoteKind::Approve => self.approvals += 1, + VoteKind::Reject => self.rejections += 1, + VoteKind::Slash => self.slashes += 1, + } + } + + /// Calculates number of votes so far + pub fn votes_number(&self) -> u32 { + self.abstentions + self.approvals + self.rejections + self.slashes + } +} + +/// Contains created stake id and source account for the stake balance +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Default, Clone, Copy, PartialEq, Eq, Debug)] +pub struct ActiveStake { + /// Created stake id for the proposal + pub stake_id: StakeId, + + /// Source account of the stake balance. Refund if any will be provided using this account + pub source_account_id: AccountId, +} + +/// 'Proposal' contains information necessary for the proposal system functioning. +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Default, Clone, PartialEq, Eq, Debug)] +pub struct Proposal { + /// Proposals parameter, characterize different proposal types. + pub parameters: ProposalParameters, + + /// Identifier of member proposing. + pub proposer_id: ProposerId, + + /// Proposal description + pub title: Vec, + + /// Proposal body + pub description: Vec, + + /// When it was created. + pub created_at: BlockNumber, + + /// Current proposal status + pub status: ProposalStatus, + + /// Curring voting result for the proposal + pub voting_results: VotingResults, +} + +impl + Proposal +where + BlockNumber: Add + PartialOrd + Copy, + StakeId: Clone, + AccountId: Clone, +{ + /// Returns whether voting period expired by now + pub fn is_voting_period_expired(&self, now: BlockNumber) -> bool { + now >= self.created_at + self.parameters.voting_period + } + + /// Returns whether grace period expired by now. + /// Grace period can be expired only if proposal is finalized with Approved status. + /// Returns false otherwise. + pub fn is_grace_period_expired(&self, now: BlockNumber) -> bool { + if let ProposalStatus::Finalized(finalized_status) = self.status.clone() { + if let ProposalDecisionStatus::Approved(_) = finalized_status.proposal_status { + return now >= finalized_status.finalized_at + self.parameters.grace_period; + } + } + + false + } + + /// Determines the finalized proposal status using voting results tally for current proposal. + /// Calculates votes, takes in account voting period expiration. + /// If voting process is in progress, then decision status is None. + /// Parameters: current time, total voters number involved (council size). + /// Returns the proposal finalized status if any. + pub fn define_proposal_decision_status( + &self, + total_voters_count: u32, + now: BlockNumber, + ) -> Option { + let proposal_status_resolution = ProposalStatusResolution { + proposal: self, + approvals: self.voting_results.approvals, + slashes: self.voting_results.slashes, + now, + votes_count: self.voting_results.votes_number(), + total_voters_count, + }; + + if proposal_status_resolution.is_approval_quorum_reached() + && proposal_status_resolution.is_approval_threshold_reached() + { + Some(ProposalDecisionStatus::Approved( + ApprovedProposalStatus::PendingExecution, + )) + } else if proposal_status_resolution.is_slashing_quorum_reached() + && proposal_status_resolution.is_slashing_threshold_reached() + { + Some(ProposalDecisionStatus::Slashed) + } else if proposal_status_resolution.is_expired() { + Some(ProposalDecisionStatus::Expired) + } else if proposal_status_resolution.is_voting_completed() { + Some(ProposalDecisionStatus::Rejected) + } else { + None + } + } + + /// Reset the proposal in Active status. Proposal with other status won't be changed. + /// Reset proposal operation clears voting results. + pub fn reset_proposal(&mut self) { + if let ProposalStatus::Active(_) = self.status.clone() { + self.voting_results = VotingResults::default(); + } + } +} + +/// Provides data for the voting. +pub trait VotersParameters { + /// Defines maximum voters count for the proposal + fn total_voters_count() -> u32; +} + +// Calculates quorum, votes threshold, expiration status +struct ProposalStatusResolution<'a, BlockNumber, ProposerId, Balance, StakeId, AccountId> { + proposal: &'a Proposal, + now: BlockNumber, + votes_count: u32, + total_voters_count: u32, + approvals: u32, + slashes: u32, +} + +impl<'a, BlockNumber, ProposerId, Balance, StakeId, AccountId> + ProposalStatusResolution<'a, BlockNumber, ProposerId, Balance, StakeId, AccountId> +where + BlockNumber: Add + PartialOrd + Copy, + StakeId: Clone, + AccountId: Clone, +{ + // Proposal has been expired and quorum not reached. + pub fn is_expired(&self) -> bool { + self.proposal.is_voting_period_expired(self.now) + } + + // Approval quorum reached for the proposal. Compares predefined parameter with actual + // votes sum divided by total possible votes count. + pub fn is_approval_quorum_reached(&self) -> bool { + let actual_votes_fraction = + Perbill::from_rational_approximation(self.votes_count, self.total_voters_count); + let approval_quorum_fraction = + Perbill::from_percent(self.proposal.parameters.approval_quorum_percentage); + + actual_votes_fraction.deconstruct() >= approval_quorum_fraction.deconstruct() + } + + // Slashing quorum reached for the proposal. Compares predefined parameter with actual + // votes sum divided by total possible votes count. + pub fn is_slashing_quorum_reached(&self) -> bool { + let actual_votes_fraction = + Perbill::from_rational_approximation(self.votes_count, self.total_voters_count); + let slashing_quorum_fraction = + Perbill::from_percent(self.proposal.parameters.slashing_quorum_percentage); + + actual_votes_fraction.deconstruct() >= slashing_quorum_fraction.deconstruct() + } + + // Approval threshold reached for the proposal. Compares predefined parameter with 'approve' + // votes sum divided by actual votes count. + pub fn is_approval_threshold_reached(&self) -> bool { + let approval_votes_fraction = + Perbill::from_rational_approximation(self.approvals, self.votes_count); + let required_threshold_fraction = + Perbill::from_percent(self.proposal.parameters.approval_threshold_percentage); + + approval_votes_fraction.deconstruct() >= required_threshold_fraction.deconstruct() + } + + // Slashing threshold reached for the proposal. Compares predefined parameter with 'approve' + // votes sum divided by actual votes count. + pub fn is_slashing_threshold_reached(&self) -> bool { + let slashing_votes_fraction = + Perbill::from_rational_approximation(self.slashes, self.votes_count); + let required_threshold_fraction = + Perbill::from_percent(self.proposal.parameters.slashing_threshold_percentage); + + slashing_votes_fraction.deconstruct() >= required_threshold_fraction.deconstruct() + } + + // All voters had voted + pub fn is_voting_completed(&self) -> bool { + self.votes_count == self.total_voters_count + } +} + +/// Proposal executable code wrapper +pub trait ProposalExecutable { + /// Executes proposal code + fn execute(&self) -> dispatch::Result; +} + +/// Proposal code binary converter +pub trait ProposalCodeDecoder { + /// Converts proposal code binary to executable representation + fn decode_proposal( + proposal_type: u32, + proposal_code: Vec, + ) -> Result, &'static str>; +} + +/// Balance alias +pub type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + +/// Balance alias for staking +pub type NegativeImbalance = + <::Currency as Currency<::AccountId>>::NegativeImbalance; + +/// Balance type of runtime +pub type CurrencyOf = ::Currency; + +/// Data container for the finalized proposal results +pub(crate) struct FinalizedProposalData< + ProposalId, + BlockNumber, + ProposerId, + Balance, + StakeId, + AccountId, +> { + /// Proposal id + pub proposal_id: ProposalId, + + /// Proposal to be finalized + pub proposal: Proposal, + + /// Proposal finalization status + pub status: ProposalDecisionStatus, + + /// Proposal finalization block number + pub finalized_at: BlockNumber, +} + +/// Data container for the approved proposal results +pub(crate) struct ApprovedProposalData< + ProposalId, + BlockNumber, + ProposerId, + Balance, + StakeId, + AccountId, +> { + /// Proposal id + pub proposal_id: ProposalId, + + /// Proposal to be finalized + pub proposal: Proposal, + + /// Proposal finalisation status data + pub finalisation_status_data: FinalizationData, +} + +#[cfg(test)] +mod tests { + use crate::types::ProposalStatusResolution; + use crate::*; + + // Alias introduced for simplicity of changing Proposal exact types. + type ProposalObject = Proposal; + + #[test] + fn proposal_voting_period_expired() { + let mut proposal = ProposalObject::default(); + + proposal.created_at = 1; + proposal.parameters.voting_period = 3; + + assert!(proposal.is_voting_period_expired(4)); + } + + #[test] + fn proposal_voting_period_not_expired() { + let mut proposal = ProposalObject::default(); + + proposal.created_at = 1; + proposal.parameters.voting_period = 3; + + assert!(!proposal.is_voting_period_expired(3)); + } + + #[test] + fn proposal_grace_period_expired() { + let mut proposal = ProposalObject::default(); + + proposal.parameters.grace_period = 3; + proposal.status = ProposalStatus::finalized_successfully( + ProposalDecisionStatus::Approved(ApprovedProposalStatus::PendingExecution), + 0, + ); + + assert!(proposal.is_grace_period_expired(4)); + } + + #[test] + fn proposal_grace_period_auto_expired() { + let mut proposal = ProposalObject::default(); + + proposal.parameters.grace_period = 0; + proposal.status = ProposalStatus::finalized_successfully( + ProposalDecisionStatus::Approved(ApprovedProposalStatus::PendingExecution), + 0, + ); + + assert!(proposal.is_grace_period_expired(1)); + } + + #[test] + fn proposal_grace_period_not_expired() { + let mut proposal = ProposalObject::default(); + + proposal.parameters.grace_period = 3; + + assert!(!proposal.is_grace_period_expired(3)); + } + + #[test] + fn proposal_grace_period_not_expired_because_of_not_approved_proposal() { + let mut proposal = ProposalObject::default(); + + proposal.parameters.grace_period = 3; + + assert!(!proposal.is_grace_period_expired(3)); + } + + #[test] + fn define_proposal_decision_status_returns_expired() { + let mut proposal = ProposalObject::default(); + let now = 5; + proposal.created_at = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 80; + proposal.parameters.approval_threshold_percentage = 40; + proposal.parameters.slashing_quorum_percentage = 50; + proposal.parameters.slashing_threshold_percentage = 50; + + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Approve); + proposal.voting_results.add_vote(VoteKind::Approve); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 0, + approvals: 2, + rejections: 1, + slashes: 0, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(5, now); + assert_eq!( + expected_proposal_status, + Some(ProposalDecisionStatus::Expired) + ); + } + + #[test] + fn define_proposal_decision_status_returns_approved() { + let now = 2; + let mut proposal = ProposalObject::default(); + proposal.created_at = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 60; + proposal.parameters.slashing_quorum_percentage = 50; + proposal.parameters.slashing_threshold_percentage = 50; + + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Approve); + proposal.voting_results.add_vote(VoteKind::Approve); + proposal.voting_results.add_vote(VoteKind::Approve); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 0, + approvals: 3, + rejections: 1, + slashes: 0, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(5, now); + assert_eq!( + expected_proposal_status, + Some(ProposalDecisionStatus::Approved( + ApprovedProposalStatus::PendingExecution + )) + ); + } + + #[test] + fn define_proposal_decision_status_returns_rejected() { + let mut proposal = ProposalObject::default(); + let now = 2; + + proposal.created_at = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 50; + proposal.parameters.approval_threshold_percentage = 51; + proposal.parameters.slashing_quorum_percentage = 50; + proposal.parameters.slashing_threshold_percentage = 50; + + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Abstain); + proposal.voting_results.add_vote(VoteKind::Approve); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 1, + approvals: 1, + rejections: 2, + slashes: 0, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(4, now); + assert_eq!( + expected_proposal_status, + Some(ProposalDecisionStatus::Rejected) + ); + } + + #[test] + fn define_proposal_decision_status_returns_slashed() { + let mut proposal = ProposalObject::default(); + let now = 2; + + proposal.created_at = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 50; + proposal.parameters.approval_threshold_percentage = 50; + proposal.parameters.slashing_quorum_percentage = 50; + proposal.parameters.slashing_threshold_percentage = 50; + + proposal.voting_results.add_vote(VoteKind::Slash); + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Abstain); + proposal.voting_results.add_vote(VoteKind::Slash); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 1, + approvals: 0, + rejections: 1, + slashes: 2, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(4, now); + assert_eq!( + expected_proposal_status, + Some(ProposalDecisionStatus::Slashed) + ); + } + + #[test] + fn define_proposal_decision_status_returns_none() { + let mut proposal = ProposalObject::default(); + let now = 2; + + proposal.created_at = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 60; + proposal.parameters.slashing_quorum_percentage = 50; + + proposal.voting_results.add_vote(VoteKind::Abstain); + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 1, + approvals: 0, + rejections: 0, + slashes: 0, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(5, now); + assert_eq!(expected_proposal_status, None); + } + + #[test] + fn define_proposal_decision_status_returns_approved_before_slashing_before_rejection() { + let mut proposal = ProposalObject::default(); + let now = 2; + + proposal.created_at = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 50; + proposal.parameters.approval_threshold_percentage = 30; + proposal.parameters.slashing_quorum_percentage = 50; + proposal.parameters.slashing_threshold_percentage = 30; + + proposal.voting_results.add_vote(VoteKind::Approve); + proposal.voting_results.add_vote(VoteKind::Approve); + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Slash); + proposal.voting_results.add_vote(VoteKind::Slash); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 0, + approvals: 2, + rejections: 2, + slashes: 2, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(6, now); + + assert_eq!( + expected_proposal_status, + Some(ProposalDecisionStatus::Approved( + ApprovedProposalStatus::PendingExecution + )) + ); + } + + #[test] + fn define_proposal_decision_status_returns_slashed_before_rejection() { + let mut proposal = ProposalObject::default(); + let now = 2; + + proposal.created_at = 1; + proposal.parameters.voting_period = 3; + proposal.parameters.approval_quorum_percentage = 50; + proposal.parameters.approval_threshold_percentage = 30; + proposal.parameters.slashing_quorum_percentage = 50; + proposal.parameters.slashing_threshold_percentage = 30; + + proposal.voting_results.add_vote(VoteKind::Abstain); + proposal.voting_results.add_vote(VoteKind::Approve); + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Reject); + proposal.voting_results.add_vote(VoteKind::Slash); + proposal.voting_results.add_vote(VoteKind::Slash); + + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 1, + approvals: 1, + rejections: 2, + slashes: 2, + } + ); + + let expected_proposal_status = proposal.define_proposal_decision_status(6, now); + + assert_eq!( + expected_proposal_status, + Some(ProposalDecisionStatus::Slashed) + ); + } + + #[test] + fn proposal_status_resolution_approval_quorum_works_correctly() { + let no_approval_quorum_proposal: Proposal = Proposal { + parameters: ProposalParameters { + approval_quorum_percentage: 63, + slashing_threshold_percentage: 63, + ..ProposalParameters::default() + }, + ..Proposal::default() + }; + let no_approval_proposal_status_resolution = ProposalStatusResolution { + proposal: &no_approval_quorum_proposal, + now: 20, + votes_count: 314, + total_voters_count: 500, + approvals: 3, + slashes: 3, + }; + + assert!(!no_approval_proposal_status_resolution.is_approval_quorum_reached()); + + let approval_quorum_proposal_status_resolution = ProposalStatusResolution { + votes_count: 315, + ..no_approval_proposal_status_resolution + }; + + assert!(approval_quorum_proposal_status_resolution.is_approval_quorum_reached()); + } + + #[test] + fn proposal_status_resolution_slashing_quorum_works_correctly() { + let no_slashing_quorum_proposal: Proposal = Proposal { + parameters: ProposalParameters { + approval_quorum_percentage: 63, + slashing_quorum_percentage: 63, + ..ProposalParameters::default() + }, + ..Proposal::default() + }; + let no_slashing_proposal_status_resolution = ProposalStatusResolution { + proposal: &no_slashing_quorum_proposal, + now: 20, + votes_count: 314, + total_voters_count: 500, + approvals: 3, + slashes: 3, + }; + + assert!(!no_slashing_proposal_status_resolution.is_slashing_quorum_reached()); + + let slashing_quorum_proposal_status_resolution = ProposalStatusResolution { + votes_count: 315, + ..no_slashing_proposal_status_resolution + }; + + assert!(slashing_quorum_proposal_status_resolution.is_slashing_quorum_reached()); + } + + #[test] + fn proposal_status_resolution_approval_threshold_works_correctly() { + let no_approval_threshold_proposal: Proposal = Proposal { + parameters: ProposalParameters { + slashing_threshold_percentage: 63, + approval_threshold_percentage: 63, + ..ProposalParameters::default() + }, + ..Proposal::default() + }; + let no_approval_proposal_status_resolution = ProposalStatusResolution { + proposal: &no_approval_threshold_proposal, + now: 20, + votes_count: 500, + total_voters_count: 600, + approvals: 314, + slashes: 3, + }; + + assert!(!no_approval_proposal_status_resolution.is_approval_threshold_reached()); + + let approval_threshold_proposal_status_resolution = ProposalStatusResolution { + approvals: 315, + ..no_approval_proposal_status_resolution + }; + + assert!(approval_threshold_proposal_status_resolution.is_approval_threshold_reached()); + } + + #[test] + fn proposal_status_resolution_slashing_threshold_works_correctly() { + let no_slashing_threshold_proposal: Proposal = Proposal { + parameters: ProposalParameters { + slashing_threshold_percentage: 63, + approval_threshold_percentage: 63, + ..ProposalParameters::default() + }, + ..Proposal::default() + }; + let no_slashing_proposal_status_resolution = ProposalStatusResolution { + proposal: &no_slashing_threshold_proposal, + now: 20, + votes_count: 500, + total_voters_count: 600, + approvals: 3, + slashes: 314, + }; + + assert!(!no_slashing_proposal_status_resolution.is_slashing_threshold_reached()); + + let slashing_threshold_proposal_status_resolution = ProposalStatusResolution { + slashes: 315, + ..no_slashing_proposal_status_resolution + }; + + assert!(slashing_threshold_proposal_status_resolution.is_slashing_threshold_reached()); + } +} diff --git a/runtime-modules/proposals/engine/src/types/proposal_statuses.rs b/runtime-modules/proposals/engine/src/types/proposal_statuses.rs new file mode 100644 index 0000000000..4deb8b647d --- /dev/null +++ b/runtime-modules/proposals/engine/src/types/proposal_statuses.rs @@ -0,0 +1,197 @@ +#![warn(missing_docs)] + +use codec::{Decode, Encode}; +use rstd::prelude::*; + +use crate::ActiveStake; +#[cfg(feature = "std")] +use serde::{Deserialize, Serialize}; + +/// Current status of the proposal +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug)] +pub enum ProposalStatus { + /// A new proposal status that is available for voting (with optional stake data). + Active(Option>), + + /// The proposal decision was made. + Finalized(FinalizationData), +} + +impl Default for ProposalStatus { + fn default() -> Self { + ProposalStatus::Active(None) + } +} + +impl ProposalStatus { + /// Creates finalized proposal status with provided ProposalDecisionStatus + pub fn finalized_successfully( + decision_status: ProposalDecisionStatus, + now: BlockNumber, + ) -> ProposalStatus { + Self::finalized(decision_status, None, None, now) + } + + /// Creates finalized proposal status with provided ProposalDecisionStatus and error + pub fn finalized( + decision_status: ProposalDecisionStatus, + encoded_unstaking_error_due_to_broken_runtime: Option<&str>, + active_stake: Option>, + now: BlockNumber, + ) -> ProposalStatus { + // drop the stake information if there were no errors on unstaking + let actual_stake = if encoded_unstaking_error_due_to_broken_runtime.is_some() { + active_stake + } else { + None + }; + ProposalStatus::Finalized(FinalizationData { + proposal_status: decision_status, + encoded_unstaking_error_due_to_broken_runtime: + encoded_unstaking_error_due_to_broken_runtime.map(|err| err.as_bytes().to_vec()), + finalized_at: now, + stake_data_after_unstaking_error: actual_stake, + }) + } + + /// Creates finalized and approved proposal status with provided ApprovedProposalStatus + pub fn approved( + approved_status: ApprovedProposalStatus, + now: BlockNumber, + ) -> ProposalStatus { + ProposalStatus::Finalized(FinalizationData { + proposal_status: ProposalDecisionStatus::Approved(approved_status), + encoded_unstaking_error_due_to_broken_runtime: None, + finalized_at: now, + stake_data_after_unstaking_error: None, + }) + } +} + +/// Final proposal status and potential error. +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug)] +pub struct FinalizationData { + /// Final proposal status + pub proposal_status: ProposalDecisionStatus, + + /// Proposal finalization block number + pub finalized_at: BlockNumber, + + /// Error occured during the proposal finalization - unstaking failed in the stake module + pub encoded_unstaking_error_due_to_broken_runtime: Option>, + + /// Stake data for the proposal, filled if the unstaking wasn't successful + pub stake_data_after_unstaking_error: Option>, +} + +impl FinalizationData { + /// FinalizationData helper, creates ApprovedProposalStatus + pub fn create_approved_proposal_status( + self, + approved_status: ApprovedProposalStatus, + ) -> ProposalStatus { + ProposalStatus::Finalized(FinalizationData { + proposal_status: ProposalDecisionStatus::Approved(approved_status), + ..self + }) + } +} + +/// Status of the approved proposal. Defines execution stages. +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug)] +pub enum ApprovedProposalStatus { + /// A proposal was approved and grace period is in effect + PendingExecution, + + /// Proposal was successfully executed + Executed, + + /// Proposal was executed and failed with an error + ExecutionFailed { + /// Error message + error: Vec, + }, +} + +impl ApprovedProposalStatus { + /// ApprovedProposalStatus helper, creates ExecutionFailed approved proposal status + pub fn failed_execution(err: &str) -> ApprovedProposalStatus { + ApprovedProposalStatus::ExecutionFailed { + error: err.as_bytes().to_vec(), + } + } +} + +/// Status for the proposal with finalized decision +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] +#[derive(Encode, Decode, Clone, PartialEq, Eq, Debug)] +pub enum ProposalDecisionStatus { + /// Proposal was withdrawn by its proposer. + Canceled, + + /// Proposal was vetoed by root. + Vetoed, + + /// A proposal was rejected + Rejected, + + /// A proposal was rejected ans its stake should be slashed + Slashed, + + /// Not enough votes and voting period expired. + Expired, + + /// To clear the quorum requirement, the percentage of council members with revealed votes + /// must be no less than the quorum value for the given proposal type. + Approved(ApprovedProposalStatus), +} + +#[cfg(test)] +mod tests { + use crate::{ + ActiveStake, ApprovedProposalStatus, FinalizationData, ProposalDecisionStatus, + ProposalStatus, + }; + + #[test] + fn approved_proposal_status_helper_succeeds() { + let msg = "error"; + + assert_eq!( + ApprovedProposalStatus::failed_execution(&msg), + ApprovedProposalStatus::ExecutionFailed { + error: msg.as_bytes().to_vec() + } + ); + } + + #[test] + fn finalized_proposal_status_helper_succeeds() { + let msg = "error"; + let block_number = 20; + let stake = ActiveStake { + stake_id: 50, + source_account_id: 2, + }; + + let proposal_status = ProposalStatus::finalized( + ProposalDecisionStatus::Slashed, + Some(msg), + Some(stake), + block_number, + ); + + assert_eq!( + ProposalStatus::Finalized(FinalizationData { + proposal_status: ProposalDecisionStatus::Slashed, + finalized_at: block_number, + encoded_unstaking_error_due_to_broken_runtime: Some(msg.as_bytes().to_vec()), + stake_data_after_unstaking_error: Some(stake) + }), + proposal_status + ); + } +} diff --git a/runtime-modules/proposals/engine/src/types/stakes.rs b/runtime-modules/proposals/engine/src/types/stakes.rs new file mode 100644 index 0000000000..88a378981b --- /dev/null +++ b/runtime-modules/proposals/engine/src/types/stakes.rs @@ -0,0 +1,247 @@ +#![warn(missing_docs)] + +use super::{BalanceOf, CurrencyOf, NegativeImbalance}; +use crate::Trait; +use rstd::convert::From; +use rstd::marker::PhantomData; +use rstd::rc::Rc; +use srml_support::traits::{Currency, ExistenceRequirement, WithdrawReasons}; + +// Mocking dependencies for testing +#[cfg(test)] +use mockall::predicate::*; +#[cfg(test)] +use mockall::*; + +/// Returns registered stake handler. This is scaffolds for the mocking of the stake module. +pub trait StakeHandlerProvider { + /// Returns stake logic handler + fn stakes() -> Rc>; +} + +/// Default implementation of the stake module logic provider. Returns actual implementation +/// dependent on the stake module. +pub struct DefaultStakeHandlerProvider; +impl StakeHandlerProvider for DefaultStakeHandlerProvider { + /// Returns stake logic handler + fn stakes() -> Rc> { + Rc::new(DefaultStakeHandler { + marker: PhantomData::::default(), + }) + } +} + +/// Stake logic handler. +#[cfg_attr(test, automock)] // attributes creates mocks in testing environment +pub trait StakeHandler { + /// Creates a stake. Returns created stake id or an error. + fn create_stake(&self) -> Result; + + /// Stake the imbalance + fn stake( + &self, + stake_id: &T::StakeId, + stake_imbalance: NegativeImbalance, + ) -> Result<(), &'static str>; + + /// Removes stake + fn remove_stake(&self, stake_id: T::StakeId) -> Result<(), &'static str>; + + /// Execute unstaking + fn unstake(&self, stake_id: T::StakeId) -> Result<(), &'static str>; + + /// Slash balance from the existing stake + fn slash(&self, stake_id: T::StakeId, slash_balance: BalanceOf) -> Result<(), &'static str>; + + /// Withdraw some balance from the source account and create stake imbalance + fn make_stake_imbalance( + &self, + balance: BalanceOf, + source_account_id: &T::AccountId, + ) -> Result, &'static str>; +} + +/// Default implementation of the stake logic. Uses actual stake module. +/// 'marker' responsible for the 'Trait' binding. +pub(crate) struct DefaultStakeHandler { + pub marker: PhantomData, +} + +impl StakeHandler for DefaultStakeHandler { + /// Creates a stake. Returns created stake id or an error. + fn create_stake(&self) -> Result<::StakeId, &'static str> { + Ok(stake::Module::::create_stake()) + } + + /// Stake the imbalance + fn stake( + &self, + stake_id: &::StakeId, + stake_imbalance: NegativeImbalance, + ) -> Result<(), &'static str> { + stake::Module::::stake(&stake_id, stake_imbalance).map_err(WrappedError)?; + + Ok(()) + } + + /// Removes stake + fn remove_stake(&self, stake_id: ::StakeId) -> Result<(), &'static str> { + stake::Module::::remove_stake(&stake_id).map_err(WrappedError)?; + + Ok(()) + } + + /// Execute unstaking + fn unstake(&self, stake_id: ::StakeId) -> Result<(), &'static str> { + stake::Module::::initiate_unstaking(&stake_id, None).map_err(WrappedError)?; + + Ok(()) + } + + /// Slash balance from the existing stake + fn slash( + &self, + stake_id: ::StakeId, + slash_balance: BalanceOf, + ) -> Result<(), &'static str> { + let _ignored_successful_result = + stake::Module::::slash_immediate(&stake_id, slash_balance, false) + .map_err(WrappedError)?; + + Ok(()) + } + + /// Withdraw some balance from the source account and create stake imbalance + fn make_stake_imbalance( + &self, + balance: BalanceOf, + source_account_id: &T::AccountId, + ) -> Result, &'static str> { + CurrencyOf::::withdraw( + source_account_id, + balance, + WithdrawReasons::all(), + ExistenceRequirement::AllowDeath, + ) + } +} + +/// Proposal implementation of the stake logic. +/// 'marker' responsible for the 'Trait' binding. +pub(crate) struct ProposalStakeManager { + pub marker: PhantomData, +} + +impl ProposalStakeManager { + /// Creates a stake using stake balance and source account. + /// Returns created stake id or an error. + pub fn create_stake( + stake_balance: BalanceOf, + source_account_id: T::AccountId, + ) -> Result { + let stake_id = T::StakeHandlerProvider::stakes().create_stake()?; + + let stake_imbalance = T::StakeHandlerProvider::stakes() + .make_stake_imbalance(stake_balance, &source_account_id)?; + + T::StakeHandlerProvider::stakes().stake(&stake_id, stake_imbalance)?; + + Ok(stake_id) + } + + /// Execute unstaking and removes the stake + pub fn remove_stake(stake_id: T::StakeId) -> Result<(), &'static str> { + T::StakeHandlerProvider::stakes().unstake(stake_id)?; + + T::StakeHandlerProvider::stakes().remove_stake(stake_id)?; + + Ok(()) + } + + /// Slash balance from the existing stake + pub fn slash(stake_id: T::StakeId, slash_balance: BalanceOf) -> Result<(), &'static str> { + T::StakeHandlerProvider::stakes().slash(stake_id, slash_balance) + } +} + +// 'New type' pattern for the error +// https://doc.rust-lang.org/book/ch19-03-advanced-traits.html#using-the-newtype-pattern-to-implement-external-traits-on-external-types +struct WrappedError(E); + +// error conversion for the Wrapped StakeActionError with the inner InitiateUnstakingError +impl From>> for &str { + fn from(wrapper: WrappedError>) -> Self { + { + match wrapper.0 { + stake::StakeActionError::StakeNotFound => "StakeNotFound", + stake::StakeActionError::Error(err) => match err { + stake::InitiateUnstakingError::UnstakingPeriodShouldBeGreaterThanZero => { + "UnstakingPeriodShouldBeGreaterThanZero" + } + stake::InitiateUnstakingError::UnstakingError(e) => match e { + stake::UnstakingError::NotStaked => "NotStaked", + stake::UnstakingError::AlreadyUnstaking => "AlreadyUnstaking", + stake::UnstakingError::CannotUnstakeWhileSlashesOngoing => { + "CannotUnstakeWhileSlashesOngoing" + } + }, + }, + } + } + } +} + +// error conversion for the Wrapped StakeActionError with the inner StakingError +impl From>> for &str { + fn from(wrapper: WrappedError>) -> Self { + { + match wrapper.0 { + stake::StakeActionError::StakeNotFound => "StakeNotFound", + stake::StakeActionError::Error(err) => match err { + stake::StakingError::CannotStakeZero => "CannotStakeZero", + stake::StakingError::CannotStakeLessThanMinimumBalance => { + "CannotStakeLessThanMinimumBalance" + } + stake::StakingError::AlreadyStaked => "AlreadyStaked", + }, + } + } + } +} + +// error conversion for the Wrapped StakeActionError with the inner InitiateSlashingError +impl From>> for &str { + fn from(wrapper: WrappedError>) -> Self { + { + match wrapper.0 { + stake::StakeActionError::StakeNotFound => "StakeNotFound", + stake::StakeActionError::Error(err) => match err { + stake::InitiateSlashingError::NotStaked => "NotStaked", + stake::InitiateSlashingError::SlashPeriodShouldBeGreaterThanZero => { + "SlashPeriodShouldBeGreaterThanZero" + } + stake::InitiateSlashingError::SlashAmountShouldBeGreaterThanZero => { + "SlashAmountShouldBeGreaterThanZero" + } + }, + } + } + } +} + +// error conversion for the Wrapped StakeActionError with the inner ImmediateSlashingError +impl From>> for &str { + fn from(wrapper: WrappedError>) -> Self { + { + match wrapper.0 { + stake::StakeActionError::StakeNotFound => "StakeNotFound", + stake::StakeActionError::Error(err) => match err { + stake::ImmediateSlashingError::NotStaked => "NotStaked", + stake::ImmediateSlashingError::SlashAmountShouldBeGreaterThanZero => { + "SlashAmountShouldBeGreaterThanZero" + } + }, + } + } + } +} diff --git a/runtime-modules/roles/Cargo.toml b/runtime-modules/roles/Cargo.toml index 78f024c802..4b31781d2d 100644 --- a/runtime-modules/roles/Cargo.toml +++ b/runtime-modules/roles/Cargo.toml @@ -1,6 +1,6 @@ [package] name = 'substrate-roles-module' -version = '1.0.0' +version = '1.0.1' authors = ['Joystream contributors'] edition = '2018' diff --git a/runtime-modules/roles/src/actors.rs b/runtime-modules/roles/src/actors.rs index c702726168..d1771cd135 100644 --- a/runtime-modules/roles/src/actors.rs +++ b/runtime-modules/roles/src/actors.rs @@ -8,10 +8,14 @@ use srml_support::traits::{ use srml_support::{decl_event, decl_module, decl_storage, ensure}; use system::{self, ensure_root, ensure_signed}; +#[cfg(feature = "std")] +use serde::{Deserialize, Serialize}; + pub use membership::members::Role; const STAKING_ID: LockIdentifier = *b"role_stk"; +#[cfg_attr(feature = "std", derive(Serialize, Deserialize))] #[derive(Encode, Decode, Copy, Clone, Eq, PartialEq, Debug)] pub struct RoleParameters { // minium balance required to stake to enter a role diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index c0eee0a3cf..90d74ed9a1 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -353,3 +353,21 @@ default_features = false package = 'substrate-storage-module' path = '../runtime-modules/storage' version = '1.0.0' + +[dependencies.proposals_engine] +default_features = false +package = 'substrate-proposals-engine-module' +path = '../runtime-modules/proposals/engine' +version = '2.0.0' + +[dependencies.proposals_discussion] +default_features = false +package = 'substrate-proposals-discussion-module' +path = '../runtime-modules/proposals/discussion' +version = '2.0.0' + +[dependencies.proposals_codex] +default_features = false +package = 'substrate-proposals-codex-module' +path = '../runtime-modules/proposals/codex' +version = '2.0.0' \ No newline at end of file diff --git a/runtime/src/integration/mod.rs b/runtime/src/integration/mod.rs new file mode 100644 index 0000000000..8d18108be0 --- /dev/null +++ b/runtime/src/integration/mod.rs @@ -0,0 +1 @@ +pub mod proposals; diff --git a/runtime/src/integration/proposals/council_elected_handler.rs b/runtime/src/integration/proposals/council_elected_handler.rs new file mode 100644 index 0000000000..24a5e5f360 --- /dev/null +++ b/runtime/src/integration/proposals/council_elected_handler.rs @@ -0,0 +1,14 @@ +#![warn(missing_docs)] + +use crate::Runtime; +use governance::election::CouncilElected; + +/// 'Council elected' event handler. Should be applied to the 'election' substrate module. +/// CouncilEvent is handled by resetting active proposals. +pub struct CouncilElectedHandler; + +impl CouncilElected for CouncilElectedHandler { + fn council_elected(_new_council: Elected, _term: Term) { + >::reset_active_proposals(); + } +} diff --git a/runtime/src/integration/proposals/council_origin_validator.rs b/runtime/src/integration/proposals/council_origin_validator.rs new file mode 100644 index 0000000000..cd53f83f30 --- /dev/null +++ b/runtime/src/integration/proposals/council_origin_validator.rs @@ -0,0 +1,208 @@ +#![warn(missing_docs)] + +use rstd::marker::PhantomData; + +use common::origin_validator::ActorOriginValidator; +use proposals_engine::VotersParameters; + +use super::{MemberId, MembershipOriginValidator}; + +/// Handles work with the council. +/// Provides implementations for ActorOriginValidator and VotersParameters. +pub struct CouncilManager { + marker: PhantomData, +} + +impl + ActorOriginValidator<::Origin, MemberId, ::AccountId> + for CouncilManager +{ + /// Check for valid combination of origin and actor_id. Actor_id should be valid member_id of + /// the membership module + fn ensure_actor_origin( + origin: ::Origin, + actor_id: MemberId, + ) -> Result<::AccountId, &'static str> { + let account_id = >::ensure_actor_origin(origin, actor_id)?; + + if >::is_councilor(&account_id) { + return Ok(account_id); + } + + Err("Council validation failed: account id doesn't belong to a council member") + } +} + +impl VotersParameters for CouncilManager { + /// Implement total_voters_count() as council size + fn total_voters_count() -> u32 { + >::active_council().len() as u32 + } +} + +#[cfg(test)] +mod tests { + use super::CouncilManager; + use crate::Runtime; + use common::origin_validator::ActorOriginValidator; + use membership::members::UserInfo; + use proposals_engine::VotersParameters; + use sr_primitives::AccountId32; + use system::RawOrigin; + + type Council = governance::council::Module; + + fn initial_test_ext() -> runtime_io::TestExternalities { + let t = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + t.into() + } + + type Membership = membership::members::Module; + + #[test] + fn council_origin_validator_fails_with_unregistered_member() { + initial_test_ext().execute_with(|| { + let origin = RawOrigin::Signed(AccountId32::default()); + let member_id = 1; + let error = "Membership validation failed: cannot find a profile for a member"; + + let validation_result = + CouncilManager::::ensure_actor_origin(origin.into(), member_id); + + assert_eq!(validation_result, Err(error)); + }); + } + + #[test] + fn council_origin_validator_succeeds() { + initial_test_ext().execute_with(|| { + let councilor1 = AccountId32::default(); + let councilor2: [u8; 32] = [2; 32]; + let councilor3: [u8; 32] = [3; 32]; + + assert!(Council::set_council( + system::RawOrigin::Root.into(), + vec![councilor1, councilor2.into(), councilor3.into()] + ) + .is_ok()); + + let account_id = AccountId32::default(); + let origin = RawOrigin::Signed(account_id.clone()); + let authority_account_id = AccountId32::default(); + Membership::set_screening_authority( + RawOrigin::Root.into(), + authority_account_id.clone(), + ) + .unwrap(); + + Membership::add_screened_member( + RawOrigin::Signed(authority_account_id).into(), + account_id.clone(), + UserInfo { + handle: Some(b"handle".to_vec()), + avatar_uri: None, + about: None, + }, + ) + .unwrap(); + let member_id = 0; // newly created member_id + + let validation_result = + CouncilManager::::ensure_actor_origin(origin.into(), member_id); + + assert_eq!(validation_result, Ok(account_id)); + }); + } + + #[test] + fn council_origin_validator_fails_with_incompatible_account_id_and_member_id() { + initial_test_ext().execute_with(|| { + let account_id = AccountId32::default(); + let error = + "Membership validation failed: given account doesn't match with profile accounts"; + let authority_account_id = AccountId32::default(); + Membership::set_screening_authority( + RawOrigin::Root.into(), + authority_account_id.clone(), + ) + .unwrap(); + + Membership::add_screened_member( + RawOrigin::Signed(authority_account_id).into(), + account_id.clone(), + UserInfo { + handle: Some(b"handle".to_vec()), + avatar_uri: None, + about: None, + }, + ) + .unwrap(); + let member_id = 0; // newly created member_id + + let invalid_account_id: [u8; 32] = [2; 32]; + let validation_result = CouncilManager::::ensure_actor_origin( + RawOrigin::Signed(invalid_account_id.into()).into(), + member_id, + ); + + assert_eq!(validation_result, Err(error)); + }); + } + + #[test] + fn council_origin_validator_fails_with_not_council_account_id() { + initial_test_ext().execute_with(|| { + let account_id = AccountId32::default(); + let origin = RawOrigin::Signed(account_id.clone()); + let error = "Council validation failed: account id doesn't belong to a council member"; + let authority_account_id = AccountId32::default(); + Membership::set_screening_authority( + RawOrigin::Root.into(), + authority_account_id.clone(), + ) + .unwrap(); + + Membership::add_screened_member( + RawOrigin::Signed(authority_account_id).into(), + account_id, + UserInfo { + handle: Some(b"handle".to_vec()), + avatar_uri: None, + about: None, + }, + ) + .unwrap(); + let member_id = 0; // newly created member_id + + let validation_result = + CouncilManager::::ensure_actor_origin(origin.into(), member_id); + + assert_eq!(validation_result, Err(error)); + }); + } + + #[test] + fn council_size_calculation_aka_total_voters_count_succeeds() { + initial_test_ext().execute_with(|| { + let councilor1 = AccountId32::default(); + let councilor2: [u8; 32] = [2; 32]; + let councilor3: [u8; 32] = [3; 32]; + let councilor4: [u8; 32] = [4; 32]; + assert!(Council::set_council( + system::RawOrigin::Root.into(), + vec![ + councilor1, + councilor2.into(), + councilor3.into(), + councilor4.into() + ] + ) + .is_ok()); + + assert_eq!(CouncilManager::::total_voters_count(), 4) + }); + } +} diff --git a/runtime/src/integration/proposals/membership_origin_validator.rs b/runtime/src/integration/proposals/membership_origin_validator.rs new file mode 100644 index 0000000000..abe0a0a8be --- /dev/null +++ b/runtime/src/integration/proposals/membership_origin_validator.rs @@ -0,0 +1,143 @@ +#![warn(missing_docs)] + +use rstd::marker::PhantomData; + +use common::origin_validator::ActorOriginValidator; +use system::ensure_signed; + +/// Member of the Joystream organization +pub type MemberId = ::MemberId; + +/// Default membership actor origin validator. +pub struct MembershipOriginValidator { + marker: PhantomData, +} + +impl + ActorOriginValidator<::Origin, MemberId, ::AccountId> + for MembershipOriginValidator +{ + /// Check for valid combination of origin and actor_id. Actor_id should be valid member_id of + /// the membership module + fn ensure_actor_origin( + origin: ::Origin, + actor_id: MemberId, + ) -> Result<::AccountId, &'static str> { + // check valid signed account_id + let account_id = ensure_signed(origin)?; + + // check whether actor_id belongs to the registered member + let profile_result = >::ensure_profile(actor_id); + + if let Ok(profile) = profile_result { + // whether the account_id belongs to the actor + if profile.controller_account == account_id { + return Ok(account_id); + } else { + return Err("Membership validation failed: given account doesn't match with profile accounts"); + } + } + + Err("Membership validation failed: cannot find a profile for a member") + } +} + +#[cfg(test)] +mod tests { + use super::MembershipOriginValidator; + use crate::Runtime; + use common::origin_validator::ActorOriginValidator; + use membership::members::UserInfo; + use sr_primitives::AccountId32; + use system::RawOrigin; + + type Membership = crate::members::Module; + + fn initial_test_ext() -> runtime_io::TestExternalities { + let t = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + t.into() + } + + #[test] + fn membership_origin_validator_fails_with_unregistered_member() { + initial_test_ext().execute_with(|| { + let origin = RawOrigin::Signed(AccountId32::default()); + let member_id = 1; + let error = "Membership validation failed: cannot find a profile for a member"; + + let validation_result = + MembershipOriginValidator::::ensure_actor_origin(origin.into(), member_id); + + assert_eq!(validation_result, Err(error)); + }); + } + + #[test] + fn membership_origin_validator_succeeds() { + initial_test_ext().execute_with(|| { + let account_id = AccountId32::default(); + let origin = RawOrigin::Signed(account_id.clone()); + let authority_account_id = AccountId32::default(); + Membership::set_screening_authority( + RawOrigin::Root.into(), + authority_account_id.clone(), + ) + .unwrap(); + + Membership::add_screened_member( + RawOrigin::Signed(authority_account_id).into(), + account_id.clone(), + UserInfo { + handle: Some(b"handle".to_vec()), + avatar_uri: None, + about: None, + }, + ) + .unwrap(); + let member_id = 0; // newly created member_id + + let validation_result = + MembershipOriginValidator::::ensure_actor_origin(origin.into(), member_id); + + assert_eq!(validation_result, Ok(account_id)); + }); + } + + #[test] + fn membership_origin_validator_fails_with_incompatible_account_id_and_member_id() { + initial_test_ext().execute_with(|| { + let account_id = AccountId32::default(); + let error = + "Membership validation failed: given account doesn't match with profile accounts"; + let authority_account_id = AccountId32::default(); + Membership::set_screening_authority( + RawOrigin::Root.into(), + authority_account_id.clone(), + ) + .unwrap(); + + Membership::add_screened_member( + RawOrigin::Signed(authority_account_id).into(), + account_id, + UserInfo { + handle: Some(b"handle".to_vec()), + avatar_uri: None, + about: None, + }, + ) + .unwrap(); + let member_id = 0; // newly created member_id + + let invalid_account_id: [u8; 32] = [2; 32]; + let validation_result = MembershipOriginValidator::::ensure_actor_origin( + RawOrigin::Signed(invalid_account_id.into()).into(), + member_id, + ); + + assert_eq!(validation_result, Err(error)); + }); + } +} diff --git a/runtime/src/integration/proposals/mod.rs b/runtime/src/integration/proposals/mod.rs new file mode 100644 index 0000000000..c38aff5e7f --- /dev/null +++ b/runtime/src/integration/proposals/mod.rs @@ -0,0 +1,11 @@ +#![warn(missing_docs)] + +mod council_elected_handler; +mod council_origin_validator; +mod membership_origin_validator; +mod staking_events_handler; + +pub use council_elected_handler::CouncilElectedHandler; +pub use council_origin_validator::CouncilManager; +pub use membership_origin_validator::{MemberId, MembershipOriginValidator}; +pub use staking_events_handler::StakingEventsHandler; diff --git a/runtime/src/integration/proposals/staking_events_handler.rs b/runtime/src/integration/proposals/staking_events_handler.rs new file mode 100644 index 0000000000..8adbbb0843 --- /dev/null +++ b/runtime/src/integration/proposals/staking_events_handler.rs @@ -0,0 +1,49 @@ +#![warn(missing_docs)] + +use rstd::marker::PhantomData; +use srml_support::traits::{Currency, Imbalance}; +use srml_support::StorageMap; + +// Balance alias +type BalanceOf = + <::Currency as Currency<::AccountId>>::Balance; + +// Balance alias for staking +type NegativeImbalance = + <::Currency as Currency<::AccountId>>::NegativeImbalance; + +/// Proposal implementation of the staking event handler from the stake module. +/// 'marker' responsible for the 'Trait' binding. +pub struct StakingEventsHandler { + pub marker: PhantomData, +} + +impl stake::StakingEventsHandler + for StakingEventsHandler +{ + /// Unstake remaining sum back to the source_account_id + fn unstaked( + id: &::StakeId, + _unstaked_amount: BalanceOf, + remaining_imbalance: NegativeImbalance, + ) -> NegativeImbalance { + if >::exists(id) { + >::refund_proposal_stake(*id, remaining_imbalance); + + return >::zero(); // imbalance was consumed + } + + remaining_imbalance + } + + /// Empty handler for slashing + fn slashed( + _: &::StakeId, + _: Option<::SlashId>, + _: BalanceOf, + _: BalanceOf, + remaining_imbalance: NegativeImbalance, + ) -> NegativeImbalance { + remaining_imbalance + } +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index d4be1f6363..2eea0aca46 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -1,8 +1,14 @@ -//! The Substrate Node Template runtime. This can be compiled with `#[no_std]`, ready for Wasm. +//! The Joystream Substrate Node runtime. #![cfg_attr(not(feature = "std"), no_std)] // `construct_runtime!` does a lot of recursion and requires us to increase the limit to 256. #![recursion_limit = "256"] +// srml_staking_reward_curve::build! - substrate macro produces a warning. +// TODO: remove after post-Rome substrate upgrade +#![allow(array_into_iter)] + +// Runtime integration tests +mod test; // Make the WASM binary available. // This is required only by the node build. @@ -10,6 +16,8 @@ #[cfg(feature = "std")] include!(concat!(env!("OUT_DIR"), "/wasm_binary.rs")); +mod integration; + use authority_discovery_primitives::{ AuthorityId as EncodedAuthorityId, Signature as EncodedSignature, }; @@ -51,6 +59,8 @@ pub use srml_support::{ pub use staking::StakerStatus; pub use timestamp::Call as TimestampCall; +use integration::proposals::{CouncilManager, MembershipOriginValidator}; + /// An index to a block. pub type BlockNumber = u32; @@ -397,7 +407,7 @@ impl finality_tracker::Trait for Runtime { pub use forum; pub use governance::election_params::ElectionParameters; -use governance::{council, election, proposals}; +use governance::{council, election}; use membership::members; use storage::{data_directory, data_object_storage_registry, data_object_type_registry}; pub use versioned_store; @@ -578,7 +588,10 @@ parameter_types! { impl stake::Trait for Runtime { type Currency = ::Currency; type StakePoolId = StakePoolId; - type StakingEventsHandler = ContentWorkingGroupStakingEventHandler; + type StakingEventsHandler = ( + ContentWorkingGroupStakingEventHandler, + crate::integration::proposals::StakingEventsHandler, + ); type StakeId = u64; type SlashId = u64; } @@ -658,13 +671,9 @@ impl common::currency::GovernanceCurrency for Runtime { type Currency = balances::Module; } -impl governance::proposals::Trait for Runtime { - type Event = Event; -} - impl governance::election::Trait for Runtime { type Event = Event; - type CouncilElected = (Council,); + type CouncilElected = (Council, integration::proposals::CouncilElectedHandler); } impl governance::council::Trait for Runtime { @@ -802,6 +811,63 @@ impl discovery::Trait for Runtime { type Roles = LookupRoles; } +parameter_types! { + pub const ProposalCancellationFee: u64 = 5; + pub const ProposalRejectionFee: u64 = 3; + pub const ProposalTitleMaxLength: u32 = 40; + pub const ProposalDescriptionMaxLength: u32 = 3000; + pub const ProposalMaxActiveProposalLimit: u32 = 5; +} + +impl proposals_engine::Trait for Runtime { + type Event = Event; + type ProposerOriginValidator = MembershipOriginValidator; + type VoterOriginValidator = CouncilManager; + type TotalVotersCounter = CouncilManager; + type ProposalId = u32; + type StakeHandlerProvider = proposals_engine::DefaultStakeHandlerProvider; + type CancellationFee = ProposalCancellationFee; + type RejectionFee = ProposalRejectionFee; + type TitleMaxLength = ProposalTitleMaxLength; + type DescriptionMaxLength = ProposalDescriptionMaxLength; + type MaxActiveProposalLimit = ProposalMaxActiveProposalLimit; + type DispatchableCallCode = Call; +} +impl Default for Call { + fn default() -> Self { + panic!("shouldn't call default for Call"); + } +} + +parameter_types! { + pub const ProposalMaxPostEditionNumber: u32 = 0; // post update is disabled + pub const ProposalMaxThreadInARowNumber: u32 = 100000; // will not be used + pub const ProposalThreadTitleLengthLimit: u32 = 40; + pub const ProposalPostLengthLimit: u32 = 1000; +} + +impl proposals_discussion::Trait for Runtime { + type Event = Event; + type PostAuthorOriginValidator = MembershipOriginValidator; + type ThreadId = u32; + type PostId = u32; + type MaxPostEditionNumber = ProposalMaxPostEditionNumber; + type ThreadTitleLengthLimit = ProposalThreadTitleLengthLimit; + type PostLengthLimit = ProposalPostLengthLimit; + type MaxThreadInARowNumber = ProposalMaxThreadInARowNumber; +} + +parameter_types! { + pub const TextProposalMaxLength: u32 = 5_000; + pub const RuntimeUpgradeWasmProposalMaxLength: u32 = 2_000_000; +} + +impl proposals_codex::Trait for Runtime { + type MembershipOriginValidator = MembershipOriginValidator; + type TextProposalMaxLength = TextProposalMaxLength; + type RuntimeUpgradeWasmProposalMaxLength = RuntimeUpgradeWasmProposalMaxLength; +} + construct_runtime!( pub enum Runtime where Block = Block, @@ -826,7 +892,6 @@ construct_runtime!( RandomnessCollectiveFlip: randomness_collective_flip::{Module, Call, Storage}, Sudo: sudo, // Joystream - Proposals: proposals::{Module, Call, Storage, Event, Config}, CouncilElection: election::{Module, Call, Storage, Event, Config}, Council: council::{Module, Call, Storage, Event, Config}, Memo: memo::{Module, Call, Storage, Event}, @@ -845,6 +910,11 @@ construct_runtime!( RecurringRewards: recurringrewards::{Module, Call, Storage}, Hiring: hiring::{Module, Call, Storage}, ContentWorkingGroup: content_wg::{Module, Call, Storage, Event, Config}, + // --- Proposals + ProposalsEngine: proposals_engine::{Module, Call, Storage, Event}, + ProposalsDiscussion: proposals_discussion::{Module, Call, Storage, Event}, + ProposalsCodex: proposals_codex::{Module, Call, Storage, Error}, + // --- } ); diff --git a/runtime/src/test/mod.rs b/runtime/src/test/mod.rs new file mode 100644 index 0000000000..46c37939e8 --- /dev/null +++ b/runtime/src/test/mod.rs @@ -0,0 +1,5 @@ +//! The Joystream Substrate Node runtime integration tests. + +#![cfg(test)] + +mod proposals_integration; diff --git a/runtime/src/test/proposals_integration.rs b/runtime/src/test/proposals_integration.rs new file mode 100644 index 0000000000..1e5939f56a --- /dev/null +++ b/runtime/src/test/proposals_integration.rs @@ -0,0 +1,367 @@ +//! Proposals integration tests - with stake, membership, governance modules. + +#![cfg(test)] + +use crate::{ProposalCancellationFee, Runtime}; +use codec::Encode; +use governance::election::CouncilElected; +use membership::members; +use proposals_engine::{ + ActiveStake, BalanceOf, Error, FinalizationData, Proposal, ProposalDecisionStatus, + ProposalParameters, ProposalStatus, VoteKind, VotersParameters, VotingResults, +}; +use sr_primitives::traits::DispatchResult; +use sr_primitives::AccountId32; +use srml_support::traits::Currency; +use srml_support::StorageLinkedMap; +use system::RawOrigin; + +use crate::CouncilManager; + +fn initial_test_ext() -> runtime_io::TestExternalities { + let t = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + t.into() +} + +type Membership = membership::members::Module; +type ProposalsEngine = proposals_engine::Module; +type Council = governance::council::Module; + +fn setup_members(count: u8) { + let authority_account_id = ::AccountId::default(); + Membership::set_screening_authority(RawOrigin::Root.into(), authority_account_id.clone()) + .unwrap(); + + for i in 0..count { + let account_id: [u8; 32] = [i; 32]; + Membership::add_screened_member( + RawOrigin::Signed(authority_account_id.clone().into()).into(), + account_id.clone().into(), + members::UserInfo { + handle: Some(account_id.to_vec()), + avatar_uri: None, + about: None, + }, + ) + .unwrap(); + } +} + +fn setup_council() { + let councilor0 = AccountId32::default(); + let councilor1: [u8; 32] = [1; 32]; + let councilor2: [u8; 32] = [2; 32]; + let councilor3: [u8; 32] = [3; 32]; + let councilor4: [u8; 32] = [4; 32]; + let councilor5: [u8; 32] = [5; 32]; + assert!(Council::set_council( + system::RawOrigin::Root.into(), + vec![ + councilor0, + councilor1.into(), + councilor2.into(), + councilor3.into(), + councilor4.into(), + councilor5.into() + ] + ) + .is_ok()); +} + +struct VoteGenerator { + proposal_id: u32, + current_account_id: AccountId32, + current_account_id_seed: u8, + current_voter_id: u64, + pub auto_increment_voter_id: bool, +} + +impl VoteGenerator { + fn new(proposal_id: u32) -> Self { + VoteGenerator { + proposal_id, + current_voter_id: 0, + current_account_id_seed: 0, + current_account_id: AccountId32::default(), + auto_increment_voter_id: true, + } + } + fn vote_and_assert_ok(&mut self, vote_kind: VoteKind) { + self.vote_and_assert(vote_kind, Ok(())); + } + + fn vote_and_assert(&mut self, vote_kind: VoteKind, expected_result: DispatchResult) { + assert_eq!(self.vote(vote_kind.clone()), expected_result); + } + + fn vote(&mut self, vote_kind: VoteKind) -> DispatchResult { + if self.auto_increment_voter_id { + self.current_account_id_seed += 1; + self.current_voter_id += 1; + let account_id: [u8; 32] = [self.current_account_id_seed; 32]; + self.current_account_id = account_id.into(); + } + + ProposalsEngine::vote( + system::RawOrigin::Signed(self.current_account_id.clone()).into(), + self.current_voter_id, + self.proposal_id, + vote_kind, + ) + } +} + +#[derive(Clone)] +struct DummyProposalFixture { + parameters: ProposalParameters, + account_id: AccountId32, + proposer_id: u64, + proposal_code: Vec, + title: Vec, + description: Vec, + stake_balance: Option>, +} + +impl Default for DummyProposalFixture { + fn default() -> Self { + let title = b"title".to_vec(); + let description = b"description".to_vec(); + let dummy_proposal = proposals_codex::Call::::execute_text_proposal( + title.clone(), + description.clone(), + b"text".to_vec(), + ); + + DummyProposalFixture { + parameters: ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 60, + approval_threshold_percentage: 60, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 60, + grace_period: 0, + required_stake: None, + }, + account_id: ::AccountId::default(), + proposer_id: 0, + proposal_code: dummy_proposal.encode(), + title, + description, + stake_balance: None, + } + } +} + +impl DummyProposalFixture { + fn with_parameters(self, parameters: ProposalParameters) -> Self { + DummyProposalFixture { parameters, ..self } + } + + fn with_account_id(self, account_id: AccountId32) -> Self { + DummyProposalFixture { account_id, ..self } + } + + fn with_stake(self, stake_balance: BalanceOf) -> Self { + DummyProposalFixture { + stake_balance: Some(stake_balance), + ..self + } + } + + fn with_proposer(self, proposer_id: u64) -> Self { + DummyProposalFixture { + proposer_id, + ..self + } + } + + fn create_proposal_and_assert(self, result: Result) -> Option { + let proposal_id_result = ProposalsEngine::create_proposal( + self.account_id, + self.proposer_id, + self.parameters, + self.title, + self.description, + self.stake_balance, + self.proposal_code, + ); + assert_eq!(proposal_id_result, result); + + proposal_id_result.ok() + } +} + +struct CancelProposalFixture { + origin: RawOrigin, + proposal_id: u32, + proposer_id: u64, +} + +impl CancelProposalFixture { + fn new(proposal_id: u32) -> Self { + let account_id = ::AccountId::default(); + CancelProposalFixture { + proposal_id, + origin: RawOrigin::Signed(account_id), + proposer_id: 0, + } + } + + fn with_proposer(self, proposer_id: u64) -> Self { + CancelProposalFixture { + proposer_id, + ..self + } + } + + fn cancel_and_assert(self, expected_result: DispatchResult) { + assert_eq!( + ProposalsEngine::cancel_proposal( + self.origin.into(), + self.proposer_id, + self.proposal_id + ), + expected_result + ); + } +} + +/// Main purpose of this integration test: check balance of the member on proposal finalization (cancellation) +/// It tests StakingEventsHandler integration. Also, membership module is tested during the proposal creation (ActorOriginValidator). +#[test] +fn proposal_cancellation_with_slashes_with_balance_checks_succeeds() { + initial_test_ext().execute_with(|| { + let account_id = ::AccountId::default(); + + setup_members(2); + let member_id = 0; // newly created member_id + + let stake_amount = 200u128; + let parameters = ProposalParameters { + voting_period: 3, + approval_quorum_percentage: 50, + approval_threshold_percentage: 60, + slashing_quorum_percentage: 60, + slashing_threshold_percentage: 60, + grace_period: 5, + required_stake: Some(stake_amount), + }; + let dummy_proposal = DummyProposalFixture::default() + .with_parameters(parameters) + .with_account_id(account_id.clone()) + .with_stake(stake_amount) + .with_proposer(member_id); + + let account_balance = 500; + let _imbalance = + ::Currency::deposit_creating(&account_id, account_balance); + + assert_eq!( + ::Currency::total_balance(&account_id), + account_balance + ); + + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + assert_eq!( + ::Currency::total_balance(&account_id), + account_balance - stake_amount + ); + + let mut proposal = ProposalsEngine::proposals(proposal_id); + + let mut expected_proposal = Proposal { + parameters, + proposer_id: member_id, + created_at: 1, + status: ProposalStatus::Active(Some(ActiveStake { + stake_id: 0, + source_account_id: account_id.clone(), + })), + title: b"title".to_vec(), + description: b"description".to_vec(), + voting_results: VotingResults::default(), + }; + + assert_eq!(proposal, expected_proposal); + + let cancel_proposal_fixture = + CancelProposalFixture::new(proposal_id).with_proposer(member_id); + + cancel_proposal_fixture.cancel_and_assert(Ok(())); + + proposal = ProposalsEngine::proposals(proposal_id); + + expected_proposal.status = ProposalStatus::Finalized(FinalizationData { + proposal_status: ProposalDecisionStatus::Canceled, + finalized_at: 1, + encoded_unstaking_error_due_to_broken_runtime: None, + stake_data_after_unstaking_error: None, + }); + + assert_eq!(proposal, expected_proposal); + + let cancellation_fee = ProposalCancellationFee::get() as u128; + assert_eq!( + ::Currency::total_balance(&account_id), + account_balance - cancellation_fee + ); + }); +} + +#[test] +fn proposal_reset_succeeds() { + initial_test_ext().execute_with(|| { + setup_members(4); + setup_council(); + // create proposal + let dummy_proposal = DummyProposalFixture::default(); + let proposal_id = dummy_proposal.create_proposal_and_assert(Ok(1)).unwrap(); + + // create some votes + let mut vote_generator = VoteGenerator::new(proposal_id); + vote_generator.vote_and_assert_ok(VoteKind::Reject); + vote_generator.vote_and_assert_ok(VoteKind::Abstain); + vote_generator.vote_and_assert_ok(VoteKind::Slash); + + assert!(>::exists( + proposal_id + )); + + // check + let proposal = ProposalsEngine::proposals(proposal_id); + assert_eq!( + proposal.voting_results, + VotingResults { + abstentions: 1, + approvals: 0, + rejections: 1, + slashes: 1, + } + ); + + // Ensure council was elected + assert_eq!(CouncilManager::::total_voters_count(), 6); + + // Check proposals CouncilElected hook + // just trigger the election hook, we don't care about the parameters + ::CouncilElected::council_elected(Vec::new(), 10); + + let updated_proposal = ProposalsEngine::proposals(proposal_id); + + assert_eq!( + updated_proposal.voting_results, + VotingResults { + abstentions: 0, + approvals: 0, + rejections: 0, + slashes: 0, + } + ); + + // Check council CouncilElected hook. It should set current council. And we passed empty council. + assert_eq!(CouncilManager::::total_voters_count(), 0); + }); +}