From 28870b1a3863f542f601a76bf014fa1f1599bd96 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 13:52:58 +0300 Subject: [PATCH 1/7] feat(platform): decision proposals system contract, its limits and fund fee (PV15) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 ++ Cargo.toml | 1 + packages/data-contracts/Cargo.toml | 3 + packages/data-contracts/src/error.rs | 18 +++ packages/data-contracts/src/lib.rs | 36 ++++- .../decision-proposals-contract/.mocharc.yml | 2 + .../decision-proposals-contract/Cargo.toml | 15 ++ packages/decision-proposals-contract/LICENSE | 20 +++ .../eslint.config.mjs | 10 ++ ...decision-proposals-contract-documents.json | 137 ++++++++++++++++++ .../decision-proposals-contract/src/error.rs | 17 +++ .../decision-proposals-contract/src/lib.rs | 129 +++++++++++++++++ .../decision-proposals-contract/src/v1/mod.rs | 52 +++++++ .../test/bootstrap.js | 30 ++++ packages/rs-dpp/Cargo.toml | 2 + packages/rs-dpp/src/system_data_contracts.rs | 8 + .../create_genesis_state/v1/mod.rs | 10 ++ .../v0/mod.rs | 18 ++- .../rs-drive/src/cache/system_contracts.rs | 40 +++++ .../feature_initial_protocol_versions.rs | 5 + .../src/version/fee/mod.rs | 1 + .../rs-platform-version/src/version/fee/v4.rs | 11 ++ .../fee/vote_resolution_fund_fees/mod.rs | 10 ++ .../fee/vote_resolution_fund_fees/v1.rs | 2 + .../fee/vote_resolution_fund_fees/v3.rs | 10 ++ .../src/version/mocks/v2_test.rs | 2 + .../system_data_contract_versions/mod.rs | 1 + .../system_data_contract_versions/v1.rs | 1 + .../system_data_contract_versions/v2.rs | 1 + .../system_data_contract_versions/v3.rs | 1 + .../system_data_contract_versions/v4.rs | 5 + .../src/version/system_limits/mod.rs | 10 ++ .../src/version/system_limits/v1.rs | 2 + .../src/version/system_limits/v2.rs | 2 + .../src/version/system_limits/v3.rs | 2 + .../src/version/system_limits/v4.rs | 2 + .../rs-platform-version/src/version/v15.rs | 4 +- packages/rs-sdk-ffi/Cargo.toml | 1 + .../Cargo.toml | 2 + packages/rs-sdk/Cargo.toml | 1 + packages/wasm-sdk/Cargo.toml | 5 + 41 files changed, 637 insertions(+), 4 deletions(-) create mode 100644 packages/decision-proposals-contract/.mocharc.yml create mode 100644 packages/decision-proposals-contract/Cargo.toml create mode 100644 packages/decision-proposals-contract/LICENSE create mode 100644 packages/decision-proposals-contract/eslint.config.mjs create mode 100644 packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json create mode 100644 packages/decision-proposals-contract/src/error.rs create mode 100644 packages/decision-proposals-contract/src/lib.rs create mode 100644 packages/decision-proposals-contract/src/v1/mod.rs create mode 100644 packages/decision-proposals-contract/test/bootstrap.js create mode 100644 packages/rs-platform-version/src/version/fee/v4.rs create mode 100644 packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v3.rs diff --git a/Cargo.lock b/Cargo.lock index 90ca9978ddb..2c7312e8878 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2016,6 +2016,7 @@ dependencies = [ "app-connect-contract", "base58", "dashpay-contract", + "decision-proposals-contract", "document-history-contract", "dpns-contract", "keyword-search-contract", @@ -2076,6 +2077,17 @@ dependencies = [ "keyring-core", ] +[[package]] +name = "decision-proposals-contract" +version = "5.0.1" +dependencies = [ + "base58", + "platform-value", + "platform-version", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "delegate" version = "0.13.5" diff --git a/Cargo.toml b/Cargo.toml index d52acd39390..e4961c7d4e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ members = [ "packages/keyword-search-contract", "packages/app-connect-contract", "packages/moderation-charters-contract", + "packages/decision-proposals-contract", "packages/rs-sdk-ffi", "packages/wasm-drive-verify", "packages/dash-platform-balance-checker", diff --git a/packages/data-contracts/Cargo.toml b/packages/data-contracts/Cargo.toml index a7562a2d2e2..940c043b195 100644 --- a/packages/data-contracts/Cargo.toml +++ b/packages/data-contracts/Cargo.toml @@ -20,6 +20,7 @@ all-contracts = [ "document-history", "app-connect", "moderation-charters", + "decision-proposals", ] # Individual contract features @@ -33,6 +34,7 @@ keyword-search = ["dep:keyword-search-contract"] document-history = ["dep:document-history-contract"] app-connect = ["dep:app-connect-contract"] moderation-charters = ["dep:moderation-charters-contract"] +decision-proposals = ["dep:decision-proposals-contract"] [dependencies] thiserror = "2.0.12" @@ -49,6 +51,7 @@ keyword-search-contract = { path = "../keyword-search-contract", optional = true document-history-contract = { path = "../document-history-contract", optional = true } app-connect-contract = { path = "../app-connect-contract", optional = true } moderation-charters-contract = { path = "../moderation-charters-contract", optional = true } +decision-proposals-contract = { path = "../decision-proposals-contract", optional = true } [dev-dependencies] base58 = "0.2.0" diff --git a/packages/data-contracts/src/error.rs b/packages/data-contracts/src/error.rs index 698e7e727d1..ae66a5feb0d 100644 --- a/packages/data-contracts/src/error.rs +++ b/packages/data-contracts/src/error.rs @@ -203,3 +203,21 @@ impl From for Error { } } } + +#[cfg(feature = "decision-proposals")] +impl From for Error { + fn from(e: decision_proposals_contract::Error) -> Self { + match e { + decision_proposals_contract::Error::UnknownVersionMismatch { + method, + known_versions, + received, + } => Error::UnknownVersionMismatch { + method, + known_versions, + received, + }, + decision_proposals_contract::Error::InvalidSchemaJson(e) => Error::InvalidSchemaJson(e), + } + } +} diff --git a/packages/data-contracts/src/lib.rs b/packages/data-contracts/src/lib.rs index 850e0a93c50..76d3c242b74 100644 --- a/packages/data-contracts/src/lib.rs +++ b/packages/data-contracts/src/lib.rs @@ -22,6 +22,9 @@ pub use masternode_reward_shares_contract; #[cfg(feature = "moderation-charters")] pub use moderation_charters_contract; +#[cfg(feature = "decision-proposals")] +pub use decision_proposals_contract; + use platform_value::Identifier; use platform_version::version::PlatformVersion; @@ -57,6 +60,10 @@ pub enum SystemDataContract { /// protocol version 14 on: registered at genesis by chains born at 14 and inserted by the /// upgrade to 14. ModerationCharters = 10, + /// Decision proposals: yes/no questions anyone puts to the masternodes, each opening a + /// vote poll it funds (protocol version 15). Registered from protocol version 15 on: + /// registered at genesis by chains born at 15 and inserted by the upgrade to 15. + DecisionProposals = 11, } pub struct DataContractSource { @@ -73,7 +80,7 @@ impl SystemDataContract { /// Deliberately kept beside the enum so that adding a variant and adding it here are the /// same edit. `assert_every_variant_is_listed` below makes that mechanical rather than /// remembered: a new variant makes its match non-exhaustive and the crate stops compiling. - pub const ALL: [SystemDataContract; 11] = [ + pub const ALL: [SystemDataContract; 12] = [ SystemDataContract::Withdrawals, SystemDataContract::MasternodeRewards, SystemDataContract::FeatureFlags, @@ -85,6 +92,7 @@ impl SystemDataContract { SystemDataContract::DocumentHistory, SystemDataContract::AppConnect, SystemDataContract::ModerationCharters, + SystemDataContract::DecisionProposals, ]; /// A new variant must also be added to [`SystemDataContract::ALL`]; this match is where the @@ -178,6 +186,14 @@ impl SystemDataContract { 197, 6, 230, 72, 106, 198, 82, 129, 253, 135, 43, 86, 185, 182, 17, 112, 164, 127, 96, 5, 107, 185, 156, 46, 14, 10, 109, 237, 77, 228, 248, 129, ], + + #[cfg(feature = "decision-proposals")] + SystemDataContract::DecisionProposals => decision_proposals_contract::ID_BYTES, + #[cfg(not(feature = "decision-proposals"))] + SystemDataContract::DecisionProposals => [ + 40, 81, 10, 2, 166, 107, 62, 218, 6, 184, 111, 202, 230, 218, 170, 157, 181, 119, + 102, 211, 195, 114, 92, 200, 90, 88, 205, 30, 174, 49, 203, 200, + ], }; Identifier::new(bytes) } @@ -313,6 +329,21 @@ impl SystemDataContract { SystemDataContract::ModerationCharters => { Err(Error::ContractNotIncluded("moderation-charters")) } + + #[cfg(feature = "decision-proposals")] + SystemDataContract::DecisionProposals => Ok(DataContractSource { + id_bytes: decision_proposals_contract::ID_BYTES, + owner_id_bytes: decision_proposals_contract::OWNER_ID_BYTES, + version: platform_version.system_data_contracts.decision_proposals as u32, + definitions: decision_proposals_contract::load_definitions(platform_version)?, + document_schemas: decision_proposals_contract::load_documents_schemas( + platform_version, + )?, + }), + #[cfg(not(feature = "decision-proposals"))] + SystemDataContract::DecisionProposals => { + Err(Error::ContractNotIncluded("decision-proposals")) + } } } } @@ -365,6 +396,9 @@ mod tests { SystemDataContract::ModerationCharters => { published("EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88") } + SystemDataContract::DecisionProposals => { + published("3iNyoNwjCTkb7XS3RHyWUtLx7pgC6AFAEQNNDHzxqngF") + } }; assert_eq!( diff --git a/packages/decision-proposals-contract/.mocharc.yml b/packages/decision-proposals-contract/.mocharc.yml new file mode 100644 index 00000000000..164b941c1b6 --- /dev/null +++ b/packages/decision-proposals-contract/.mocharc.yml @@ -0,0 +1,2 @@ +require: test/bootstrap.js +recursive: true diff --git a/packages/decision-proposals-contract/Cargo.toml b/packages/decision-proposals-contract/Cargo.toml new file mode 100644 index 00000000000..efb7fe32e04 --- /dev/null +++ b/packages/decision-proposals-contract/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "decision-proposals-contract" +description = "Decision proposals data contract schema and tools. A yes/no question put to the masternodes" +version.workspace = true +edition = "2021" +rust-version.workspace = true +license = "MIT" + +[dependencies] +thiserror = "2.0.12" +platform-version = { path = "../rs-platform-version" } +serde_json = { version = "1.0" } +platform-value = { path = "../rs-platform-value" } +[dev-dependencies] +base58 = "0.2.0" diff --git a/packages/decision-proposals-contract/LICENSE b/packages/decision-proposals-contract/LICENSE new file mode 100644 index 00000000000..3be95833750 --- /dev/null +++ b/packages/decision-proposals-contract/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2019 Dash Core Group, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/decision-proposals-contract/eslint.config.mjs b/packages/decision-proposals-contract/eslint.config.mjs new file mode 100644 index 00000000000..cdf50e57d0d --- /dev/null +++ b/packages/decision-proposals-contract/eslint.config.mjs @@ -0,0 +1,10 @@ +import baseConfig from '../../eslint/base.mjs'; +import mochaTestConfig from '../../eslint/mocha-tests.mjs'; + +export default [ + ...baseConfig, + mochaTestConfig, + { + ignores: ['dist/**', 'node_modules/**'], + }, +]; diff --git a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json new file mode 100644 index 00000000000..3b134efcd13 --- /dev/null +++ b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json @@ -0,0 +1,137 @@ +{ + "proposal": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "indices": [ + { + "name": "byEndDate", + "properties": [ + { + "endDate": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "byOwner", + "properties": [ + { + "$ownerId": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "byCreatedAt", + "properties": [ + { + "$createdAt": "asc" + } + ] + } + ], + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "maxBytes": 512, + "description": "The question, in one line", + "position": 0 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "maxBytes": 4096, + "description": "What a yes and a no mean", + "position": 1 + }, + "url": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 512, + "maxBytes": 512, + "description": "Where the proposal is discussed", + "position": 2 + }, + "supermajorityNumerator": { + "type": "integer", + "minimum": 1, + "maximum": 255, + "description": "The proposal passes when yes is more than supermajorityNumerator / supermajorityDenominator of yes plus no", + "position": 3 + }, + "supermajorityDenominator": { + "type": "integer", + "minimum": 1, + "maximum": 255, + "position": 4 + }, + "minimumVotingPower": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "The least yes plus no voting power the proposal needs (a masternode votes with 1, an evonode with 4); set this or minimumShare", + "position": 5 + }, + "minimumShare": { + "type": "object", + "properties": { + "numerator": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "position": 0 + }, + "denominator": { + "type": "integer", + "minimum": 1, + "maximum": 255, + "position": 1 + }, + "rounding": { + "type": "string", + "enum": [ + "down", + "up", + "downPlusOne", + "upMinusOne" + ], + "position": 2 + } + }, + "required": [ + "numerator", + "denominator", + "rounding" + ], + "additionalProperties": false, + "description": "The least yes plus no voting power the proposal needs, as a share of the masternode list's total when it closes; set this or minimumVotingPower", + "position": 6 + }, + "endDate": { + "type": "integer", + "minimum": 0, + "description": "When voting closes, in milliseconds since the Unix epoch", + "position": 7 + } + }, + "required": [ + "$createdAt", + "title", + "description", + "supermajorityNumerator", + "supermajorityDenominator", + "endDate" + ], + "additionalProperties": false, + "description": "A yes/no question put to the masternodes. Creating one opens its vote poll, funded by its owner" + } +} diff --git a/packages/decision-proposals-contract/src/error.rs b/packages/decision-proposals-contract/src/error.rs new file mode 100644 index 00000000000..d01bbcc91cf --- /dev/null +++ b/packages/decision-proposals-contract/src/error.rs @@ -0,0 +1,17 @@ +use platform_version::version::FeatureVersion; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// Platform expected some specific versions + #[error("platform unknown version on {method}, received: {received}")] + UnknownVersionMismatch { + /// method + method: String, + /// the allowed versions for this method + known_versions: Vec, + /// requested core height + received: FeatureVersion, + }, + #[error("schema deserialize error: {0}")] + InvalidSchemaJson(#[from] serde_json::Error), +} diff --git a/packages/decision-proposals-contract/src/lib.rs b/packages/decision-proposals-contract/src/lib.rs new file mode 100644 index 00000000000..29d62e9c330 --- /dev/null +++ b/packages/decision-proposals-contract/src/lib.rs @@ -0,0 +1,129 @@ +mod error; +pub mod v1; + +pub use crate::error::Error; +use platform_value::{Identifier, IdentifierBytes32}; +use platform_version::version::PlatformVersion; +use serde_json::Value; + +pub const ID_BYTES: [u8; 32] = [ + 40, 81, 10, 2, 166, 107, 62, 218, 6, 184, 111, 202, 230, 218, 170, 157, 181, 119, 102, 211, + 195, 114, 92, 200, 90, 88, 205, 30, 174, 49, 203, 200, +]; + +pub const OWNER_ID_BYTES: [u8; 32] = [0; 32]; + +pub const ID: Identifier = Identifier(IdentifierBytes32(ID_BYTES)); +pub const OWNER_ID: Identifier = Identifier(IdentifierBytes32(OWNER_ID_BYTES)); + +/// The contract's definitions for `platform_version`. Version 0, the value the tables of +/// protocol versions below 15 carry, names no schema generation and is refused like any +/// unknown version: the contract does not exist before its activation. +pub fn load_definitions(platform_version: &PlatformVersion) -> Result, Error> { + match platform_version.system_data_contracts.decision_proposals { + 1 => Ok(None), + version => Err(Error::UnknownVersionMismatch { + method: "decision_proposals_contract::load_definitions".to_string(), + known_versions: vec![1], + received: version, + }), + } +} + +pub fn load_documents_schemas(platform_version: &PlatformVersion) -> Result { + match platform_version.system_data_contracts.decision_proposals { + 1 => v1::load_documents_schemas(), + version => Err(Error::UnknownVersionMismatch { + method: "decision_proposals_contract::load_documents_schemas".to_string(), + known_versions: vec![1], + received: version, + }), + } +} + +#[cfg(test)] +mod tests { + use base58::FromBase58; + + use super::*; + + #[test] + fn should_match_the_published_system_contract_id() { + let base58_decoded = "3iNyoNwjCTkb7XS3RHyWUtLx7pgC6AFAEQNNDHzxqngF" + .from_base58() + .expect("base58 id"); + assert_eq!(base58_decoded, ID_BYTES); + assert_eq!(ID, Identifier(IdentifierBytes32(ID_BYTES))); + } + + #[test] + fn should_load_the_schema_at_the_latest_platform_version() { + use v1::document_types::proposal; + use v1::document_types::proposal::properties::minimum_share; + + let schema = load_documents_schemas(PlatformVersion::latest()).expect("schema loads"); + // One type and no others. + assert_eq!(schema.as_object().map(|types| types.len()), Some(1)); + let proposal_type = &schema[proposal::NAME]; + let properties = proposal_type["properties"] + .as_object() + .expect("proposal has properties"); + for property in [ + proposal::properties::TITLE, + proposal::properties::DESCRIPTION, + proposal::properties::URL, + proposal::properties::SUPERMAJORITY_NUMERATOR, + proposal::properties::SUPERMAJORITY_DENOMINATOR, + proposal::properties::MINIMUM_VOTING_POWER, + proposal::properties::MINIMUM_SHARE, + proposal::properties::END_DATE, + ] { + assert!(properties.contains_key(property), "proposal declares {property}"); + } + let share = &properties[proposal::properties::MINIMUM_SHARE]["properties"]; + for key in [ + minimum_share::NUMERATOR, + minimum_share::DENOMINATOR, + minimum_share::ROUNDING, + ] { + assert!(share.get(key).is_some(), "minimumShare declares {key}"); + } + let roundings: Vec<&str> = share[minimum_share::ROUNDING]["enum"] + .as_array() + .expect("rounding is an enum") + .iter() + .filter_map(Value::as_str) + .collect(); + assert_eq!(roundings, minimum_share::ROUNDINGS); + let indexes: Vec<&str> = proposal_type["indices"] + .as_array() + .expect("proposal has indexes") + .iter() + .filter_map(|index| index["name"].as_str()) + .collect(); + assert_eq!( + indexes, + [ + proposal::indexes::BY_END_DATE, + proposal::indexes::BY_OWNER, + proposal::indexes::BY_CREATED_AT + ] + ); + // What masternodes vote on cannot change after it opens. + assert_eq!(proposal_type["documentsMutable"], Value::Bool(false)); + assert_eq!(proposal_type["canBeDeleted"], Value::Bool(false)); + } + + #[test] + fn should_refuse_to_load_below_the_activation_version() { + let platform_version = PlatformVersion::get(14).expect("protocol version 14"); + assert!(matches!( + load_documents_schemas(platform_version), + Err(Error::UnknownVersionMismatch { received: 0, .. }) + )); + assert!(matches!( + load_definitions(platform_version), + Err(Error::UnknownVersionMismatch { received: 0, .. }) + )); + } +} diff --git a/packages/decision-proposals-contract/src/v1/mod.rs b/packages/decision-proposals-contract/src/v1/mod.rs new file mode 100644 index 00000000000..ad69acb437f --- /dev/null +++ b/packages/decision-proposals-contract/src/v1/mod.rs @@ -0,0 +1,52 @@ +use crate::Error; +use serde_json::Value; + +pub mod document_types { + /// A yes/no question put to the masternodes. Creating one opens its vote poll, which its + /// owner funds; the poll's rule is read from the proposal. Immutable and undeletable, so + /// masternodes vote on what the proposal said when it was created. + pub mod proposal { + pub const NAME: &str = "proposal"; + + pub mod properties { + pub const TITLE: &str = "title"; + pub const DESCRIPTION: &str = "description"; + pub const URL: &str = "url"; + pub const SUPERMAJORITY_NUMERATOR: &str = "supermajorityNumerator"; + pub const SUPERMAJORITY_DENOMINATOR: &str = "supermajorityDenominator"; + /// A fixed minimum voting power. A proposal sets this or `MINIMUM_SHARE`. + pub const MINIMUM_VOTING_POWER: &str = "minimumVotingPower"; + /// A minimum voting power as a share of the masternode list's total when the poll + /// closes. A proposal sets this or `MINIMUM_VOTING_POWER`. + pub const MINIMUM_SHARE: &str = "minimumShare"; + pub const END_DATE: &str = "endDate"; + + /// The keys of the `minimumShare` object. + pub mod minimum_share { + pub const NUMERATOR: &str = "numerator"; + pub const DENOMINATOR: &str = "denominator"; + /// One of `ROUNDINGS`. + pub const ROUNDING: &str = "rounding"; + + /// The values of `rounding`, as `VotingPowerRounding` names them in JSON. + pub const ROUNDINGS: [&str; 4] = ["down", "up", "downPlusOne", "upMinusOne"]; + } + } + + pub mod indexes { + /// By end date, then creation time: the proposals closing soonest first. + pub const BY_END_DATE: &str = "byEndDate"; + /// By owner, then creation time. + pub const BY_OWNER: &str = "byOwner"; + /// By creation time: the newest proposals. + pub const BY_CREATED_AT: &str = "byCreatedAt"; + } + } +} + +pub fn load_documents_schemas() -> Result { + serde_json::from_str(include_str!( + "../../schema/v1/decision-proposals-contract-documents.json" + )) + .map_err(Error::InvalidSchemaJson) +} diff --git a/packages/decision-proposals-contract/test/bootstrap.js b/packages/decision-proposals-contract/test/bootstrap.js new file mode 100644 index 00000000000..7af04f464d7 --- /dev/null +++ b/packages/decision-proposals-contract/test/bootstrap.js @@ -0,0 +1,30 @@ +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); + +const { expect, use } = require('chai'); +const dirtyChai = require('dirty-chai'); + +const { + default: loadWasmDpp, +} = require('@dashevo/wasm-dpp'); + +use(dirtyChai); +use(sinonChai); + +exports.mochaHooks = { + beforeAll: loadWasmDpp, + + beforeEach() { + if (!this.sinon) { + this.sinon = sinon.createSandbox(); + } else { + this.sinon.restore(); + } + }, + + afterEach() { + this.sinon.restore(); + }, +}; + +global.expect = expect; diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index 4b42108b46d..e67bbc6cd5c 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -308,6 +308,7 @@ all-system_contracts = [ "document-history-contract", "app-connect-contract", "moderation-charters-contract", + "decision-proposals-contract", ] # Individual data contract features @@ -324,6 +325,7 @@ keywords-contract = ["data-contracts", "data-contracts/keyword-search"] document-history-contract = ["data-contracts", "data-contracts/document-history"] app-connect-contract = ["data-contracts", "data-contracts/app-connect"] moderation-charters-contract = ["data-contracts", "data-contracts/moderation-charters"] +decision-proposals-contract = ["data-contracts", "data-contracts/decision-proposals"] fixtures-and-mocks = ["all-system_contracts", "platform-value/json"] random-public-keys = ["bls-signatures", "ed25519-dalek"] random-identities = ["random-public-keys"] diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index 0e0513c4c13..df426efcf30 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -79,6 +79,11 @@ impl ConfigurationForSystemContract for SystemDataContract { config.set_sized_integer_types_enabled(true); Ok(config) } + SystemDataContract::DecisionProposals => { + let mut config = DataContractConfig::default_for_version(platform_version)?; + config.set_sized_integer_types_enabled(true); + Ok(config) + } } } } @@ -175,6 +180,9 @@ mod tests { SystemDataContract::ModerationCharters => { contract_versions.moderation_charters == 0 } + SystemDataContract::DecisionProposals => { + contract_versions.decision_proposals == 0 + } SystemDataContract::Withdrawals | SystemDataContract::MasternodeRewards | SystemDataContract::DPNS diff --git a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs index 127133b0a4a..48097ff92a5 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs @@ -88,6 +88,16 @@ impl Platform { ); } + // The decision proposals contract activates with protocol version 15, the same way: + // a chain born at 15 registers it at genesis, an older chain gets it from + // `transition_to_version_15`. + if platform_version.protocol_version >= 15 { + system_data_contract_types.insert( + SystemDataContract::DecisionProposals, + system_data_contracts.load_decision_proposals(platform_version)?, + ); + } + for data_contract in system_data_contract_types.values() { self.register_system_data_contract_operations( data_contract, diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 4b8870fa3f1..ea739c7ddd3 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -915,7 +915,8 @@ impl Platform { /// When transitioning to version 15 we re-store the DPNS contract whose v3 /// schema checks a domain create with keywords instead of most of its data - /// trigger, and create the vote decisions trees that yes/no polls live in. + /// trigger, create the vote decisions trees that yes/no polls live in, and + /// insert the decision proposals contract whose proposals open those polls. fn transition_to_version_15( &self, block_info: &BlockInfo, @@ -943,6 +944,21 @@ impl Platform { self.drive .insert_vote_decisions_trees(Some(transaction), platform_version)?; + // Decision proposals contract (v15 note 12): creating a proposal opens a yes/no poll on + // it. Fresh chains register it at genesis (`create_genesis_state` v1, behind a `>= 15` + // branch). Stored without storage flags, as genesis stores system contracts. + let decision_proposals_contract = + load_system_data_contract(SystemDataContract::DecisionProposals, platform_version)?; + + self.drive.apply_contract( + &decision_proposals_contract, + *block_info, + true, + None, + Some(transaction), + platform_version, + )?; + Ok(()) } } diff --git a/packages/rs-drive/src/cache/system_contracts.rs b/packages/rs-drive/src/cache/system_contracts.rs index ac3949fbdaf..bb83284ad3e 100644 --- a/packages/rs-drive/src/cache/system_contracts.rs +++ b/packages/rs-drive/src/cache/system_contracts.rs @@ -5,6 +5,7 @@ use dpp::prelude::Identifier; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use platform_version::version::feature_initial_protocol_versions::{ APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION, + DECISION_PROPOSALS_CONTRACT_INITIAL_PROTOCOL_VERSION, MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION, }; use platform_version::version::{PlatformVersion, ProtocolVersion}; @@ -184,6 +185,14 @@ impl SystemDataContracts { self.load(SystemDataContract::ModerationCharters, platform_version) } + /// Returns the decision proposals contract materialized for `platform_version`. + pub fn load_decision_proposals( + &self, + platform_version: &PlatformVersion, + ) -> Result, Error> { + self.load(SystemDataContract::DecisionProposals, platform_version) + } + /// Returns the system contract whose deterministic identifier matches `id`, materialized /// for `platform_version`. /// @@ -228,6 +237,10 @@ impl SystemDataContracts { SystemDataContract::ModerationCharters => { MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION } + // Written to state by the transition to protocol version 15. + SystemDataContract::DecisionProposals => { + DECISION_PROPOSALS_CONTRACT_INITIAL_PROTOCOL_VERSION + } // Never served from this cache: `WalletUtils` is only ever read from grovedb, and // the reserved `FeatureFlags` slot has no implementation. SystemDataContract::WalletUtils | SystemDataContract::FeatureFlags => return Ok(None), @@ -457,6 +470,33 @@ mod tests { .is_none()); } + #[test] + fn should_serve_decision_proposals_only_from_its_activation_version() { + let contracts = SystemDataContracts::new(); + + assert!(contracts + .find_by_id( + SystemDataContract::DecisionProposals.id(), + platform_version(14) + ) + .expect("expected the pre-activation lookup to succeed") + .is_none()); + assert!(contracts + .find_by_id( + SystemDataContract::DecisionProposals.id(), + PlatformVersion::latest() + ) + .expect("expected the v15 lookup to succeed") + .is_some()); + assert!(contracts + .find_by_id( + SystemDataContract::DecisionProposals.id(), + platform_version(14) + ) + .expect("an old-version lookup after materialization must succeed") + .is_none()); + } + #[test] fn should_serve_app_connect_only_from_its_activation_version() { let contracts = SystemDataContracts::new(); diff --git a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs index 6d2b45d5ea8..e8424621a68 100644 --- a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs +++ b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs @@ -40,3 +40,8 @@ pub const TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 15; /// yes/no method slot is `None` in the tables before it, and the vote decisions trees are /// created at genesis from it (and by the upgrade to it). pub const YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 15; +/// The decision proposals system contract is written to state by the upgrade to protocol +/// version 15 and registered at genesis from that version on; below it the contract does not +/// exist and lookups must report it absent. Creating one of its proposals opens a yes/no vote +/// poll. +pub const DECISION_PROPOSALS_CONTRACT_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 15; diff --git a/packages/rs-platform-version/src/version/fee/mod.rs b/packages/rs-platform-version/src/version/fee/mod.rs index ad8e931bea9..6abb19f52ae 100644 --- a/packages/rs-platform-version/src/version/fee/mod.rs +++ b/packages/rs-platform-version/src/version/fee/mod.rs @@ -34,6 +34,7 @@ pub mod storage; pub mod v1; pub mod v2; pub mod v3; +pub mod v4; pub mod vote_resolution_fund_fees; pub type FeeVersionNumber = u32; diff --git a/packages/rs-platform-version/src/version/fee/v4.rs b/packages/rs-platform-version/src/version/fee/v4.rs new file mode 100644 index 00000000000..ae926cadc20 --- /dev/null +++ b/packages/rs-platform-version/src/version/fee/v4.rs @@ -0,0 +1,11 @@ +use crate::version::fee::v3::FEE_VERSION3; +use crate::version::fee::vote_resolution_fund_fees::v3::VOTE_RESOLUTION_FUND_FEES_VERSION3; +use crate::version::fee::FeeVersion; + +/// Introduced in protocol version 15 (5.1). +pub const FEE_VERSION4: FeeVersion = FeeVersion { + // Decision proposals (new in protocol version 15) prefund their poll's votes. Storage rates + // are unchanged, so the fee_version_number inherited from FEE_VERSION3 stays. + vote_resolution_fund_fees: VOTE_RESOLUTION_FUND_FEES_VERSION3, + ..FEE_VERSION3 +}; diff --git a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs index 0362a8df271..7c1cf616254 100644 --- a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs +++ b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs @@ -2,6 +2,7 @@ use bincode::{Decode, Encode}; pub mod v1; pub mod v2; +pub mod v3; #[derive(Clone, Debug, Encode, Decode, Default, PartialEq, Eq)] pub struct VoteResolutionFundFees { /// This is the amount that will be deducted from an identity and used to pay for voting @@ -28,6 +29,11 @@ pub struct VoteResolutionFundFees { /// times `2^(1 + (n - b) / this)`. 0 means the fund never doubles, what every schedule /// before protocol version 14 carries. pub contested_document_contenders_per_fund_doubling: u16, + /// The amount deducted from an identity that opens a decision proposal (a `proposal` + /// create on the decision proposals contract), prefunding the masternode votes on its + /// yes/no poll; what is left when the poll ends is released as processing fees. Decision + /// proposals exist from protocol version 15; earlier schedules carry 0, unread. + pub decision_proposal_fund_required_amount: u64, } /// The vote resolution fund fees exactly as every pre-1.4 release serialized them inside @@ -57,6 +63,8 @@ impl From for VoteResolutionFundFees // Pre-4.2 tables predate the fund doubling with the contenders a contest holds contested_document_contenders_before_fund_doubling: 0, contested_document_contenders_per_fund_doubling: 0, + // Pre-4.2 tables predate decision proposals (protocol version 15) + decision_proposal_fund_required_amount: 0, } } } @@ -76,6 +84,7 @@ mod tests { moderation_vote_resolution_fund_required_amount: 4, contested_document_contenders_before_fund_doubling: 5, contested_document_contenders_per_fund_doubling: 6, + decision_proposal_fund_required_amount: 7, }; let version2 = VoteResolutionFundFees { @@ -85,6 +94,7 @@ mod tests { moderation_vote_resolution_fund_required_amount: 4, contested_document_contenders_before_fund_doubling: 5, contested_document_contenders_per_fund_doubling: 6, + decision_proposal_fund_required_amount: 7, }; // This assertion will check if all fields are considered in the equality comparison diff --git a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs index c25fcf085bf..99d44067e8e 100644 --- a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs +++ b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs @@ -10,4 +10,6 @@ pub const VOTE_RESOLUTION_FUND_FEES_VERSION1: VoteResolutionFundFees = VoteResol // Before protocol version 14 every contender paid the same fund, however many had joined contested_document_contenders_before_fund_doubling: 0, contested_document_contenders_per_fund_doubling: 0, + // Decision proposals exist from protocol version 15; unread before it. + decision_proposal_fund_required_amount: 0, }; diff --git a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v3.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v3.rs new file mode 100644 index 00000000000..8e06ca89b45 --- /dev/null +++ b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v3.rs @@ -0,0 +1,10 @@ +use crate::version::fee::vote_resolution_fund_fees::v2::VOTE_RESOLUTION_FUND_FEES_VERSION2; +use crate::version::fee::vote_resolution_fund_fees::VoteResolutionFundFees; + +/// Introduced in protocol version 15 (5.1). +pub const VOTE_RESOLUTION_FUND_FEES_VERSION3: VoteResolutionFundFees = VoteResolutionFundFees { + // Opening a decision proposal prefunds 25,000 masternode votes on its poll, more than every + // masternode casting all of its votes on it + decision_proposal_fund_required_amount: 50_000_000_000, // 0.5 DASH + ..VOTE_RESOLUTION_FUND_FEES_VERSION2 +}; diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index aefc3b8cb75..1839c27aa75 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -630,6 +630,8 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_yes_no_vote_poll_minimum_voting_power: 2000, max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks + min_yes_no_vote_poll_duration_ms: 604_800_000, // one week + min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority yes_no_vote_poll_masternode_vote_strength: 1, yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs index 1fe2f9352a6..07e706b2342 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs @@ -17,4 +17,5 @@ pub struct SystemDataContractVersions { pub document_history: FeatureVersion, pub app_connect: FeatureVersion, pub moderation_charters: FeatureVersion, + pub decision_proposals: FeatureVersion, } diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v1.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v1.rs index c800f384b2c..7315fb5a7ef 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v1.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v1.rs @@ -16,4 +16,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V1: SystemDataContractVersions = // The moderation charters contract does not exist before protocol version 14 // either: no schema generation is selected here. moderation_charters: 0, + decision_proposals: 0, }; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v2.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v2.rs index 8dc349d7ad1..2b500c1ee5e 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v2.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v2.rs @@ -21,4 +21,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V2: SystemDataContractVersions = // The moderation charters contract does not exist before protocol version 14 // either: no schema generation is selected here. moderation_charters: 0, + decision_proposals: 0, }; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs index 5bdeac3b0b4..e338b0f1fb1 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs @@ -33,4 +33,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V3: SystemDataContractVersions = document_history: 1, app_connect: 1, moderation_charters: 1, + decision_proposals: 0, }; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs index 63fdcd01b8e..8035dfe6e7f 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v4.rs @@ -7,6 +7,10 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; // chosen), and the new domain's identity record held to its owner. Domains can // no longer be deleted. v3 (dpns: 2) remains for PROTOCOL_VERSION_14 chain // replay. +// +// The decision proposals contract (decision_proposals: 1) is registered at +// genesis from protocol version 15 and written to state by the upgrade to it: +// creating a proposal opens a yes/no masternode vote poll on it. pub const SYSTEM_DATA_CONTRACT_VERSIONS_V4: SystemDataContractVersions = SystemDataContractVersions { withdrawals: 2, @@ -19,4 +23,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V4: SystemDataContractVersions = document_history: 1, app_connect: 1, moderation_charters: 1, + decision_proposals: 1, }; diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index edb58baf7d2..7052106fc50 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -264,6 +264,16 @@ pub struct SystemLimits { /// its end date: four weeks, the longest election window a contract may declare. Read by /// `Drive::open_yes_no_vote_poll` (protocol version 15). pub max_yes_no_vote_poll_duration_ms: u64, + /// The shortest a yes/no vote poll may run, in milliseconds from the block that opens it to + /// its end date: one week, so masternode operators have time to see a poll before it + /// closes. Read by `YesNoVotePoll::validate_end_date` (protocol version 15). + pub min_yes_no_vote_poll_duration_ms: u64, + /// The smallest pass threshold a yes/no vote poll may set, in percent: a poll passes when + /// yes is more than `supermajority_numerator / supermajority_denominator` of yes plus no, + /// and that fraction may not be below this. 50 is a simple majority, so a poll cannot pass + /// with fewer yes than no. Read by `YesNoVotePoll::validate_parameters` (protocol version + /// 15). + pub min_yes_no_vote_poll_supermajority_percent: u8, /// The voting power a regular masternode's yes/no vote carries, and what it adds to the /// masternode list total a share minimum is measured against. Read by masternode vote /// `transform_into_action` v1 and `check_for_ended_vote_polls` v1 (protocol version 15). diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index c277cf4c0c9..453d84d8d44 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -66,6 +66,8 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_yes_no_vote_poll_minimum_voting_power: 2000, max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks + min_yes_no_vote_poll_duration_ms: 604_800_000, // one week + min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority yes_no_vote_poll_masternode_vote_strength: 1, yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 854b10b686c..a4a18ac05cf 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -47,6 +47,8 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_yes_no_vote_poll_minimum_voting_power: 2000, max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks + min_yes_no_vote_poll_duration_ms: 604_800_000, // one week + min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority yes_no_vote_poll_masternode_vote_strength: 1, yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 8d659bffaa5..a132f262cbb 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -49,6 +49,8 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_yes_no_vote_poll_minimum_voting_power: 2000, max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks + min_yes_no_vote_poll_duration_ms: 604_800_000, // one week + min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority yes_no_vote_poll_masternode_vote_strength: 1, yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 19ad43d5105..764c01b8c2e 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -170,6 +170,8 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_yes_no_vote_poll_minimum_voting_power: 2000, max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks + min_yes_no_vote_poll_duration_ms: 604_800_000, // one week + min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority yes_no_vote_poll_masternode_vote_strength: 1, yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index e2eebccff94..22d1bed5857 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -22,7 +22,7 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::v11::DR use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v3::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3; use crate::version::drive_abci_versions::DriveAbciVersion; use crate::version::drive_versions::v10::DRIVE_VERSION_V10; -use crate::version::fee::v3::FEE_VERSION3; +use crate::version::fee::v4::FEE_VERSION4; use crate::version::protocol_version::PlatformVersion; use crate::version::system_data_contract_versions::v4::SYSTEM_DATA_CONTRACT_VERSIONS_V4; use crate::version::system_limits::v5::SYSTEM_LIMITS_V5; @@ -268,7 +268,7 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { factory_versions: DPP_FACTORY_VERSIONS_V1, }, system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V4, // changed: DPNS v3 checks a name's create with keywords (note 3) - fee_version: FEE_VERSION3, + fee_version: FEE_VERSION4, // changed: decision proposals prefund their poll with 0.5 Dash (note 12) system_limits: SYSTEM_LIMITS_V5, // changed: max_compiled_contract_size caps a contract stored compiled at 1 MiB (note 8) consensus: ConsensusVersions { tenderdash_consensus_version: 1, diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index 33f50995ad7..ceb2afe6be0 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -26,6 +26,7 @@ rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider", "document-history-contract", "app-connect-contract", "moderation-charters-contract", + "decision-proposals-contract", ] } simple-signer = { path = "../simple-signer" } async-trait = { version = "0.1.83" } diff --git a/packages/rs-sdk-trusted-context-provider/Cargo.toml b/packages/rs-sdk-trusted-context-provider/Cargo.toml index 2b297c19b39..f9f2602d355 100644 --- a/packages/rs-sdk-trusted-context-provider/Cargo.toml +++ b/packages/rs-sdk-trusted-context-provider/Cargo.toml @@ -46,6 +46,7 @@ all-system-contracts = [ "document-history-contract", "app-connect-contract", "moderation-charters-contract", + "decision-proposals-contract", ] # Individual contract features - these enable specific contracts in DPP @@ -58,6 +59,7 @@ keywords-contract = ["dpp/keywords-contract"] document-history-contract = ["dpp/document-history-contract"] app-connect-contract = ["dpp/app-connect-contract"] moderation-charters-contract = ["dpp/moderation-charters-contract"] +decision-proposals-contract = ["dpp/decision-proposals-contract"] [target.'cfg(target_arch = "wasm32")'.dependencies] # Runs the browser's non-`Send` quorum fetches and timers on the local executor. diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 8ed5f0992dd..3d016298c7d 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -152,6 +152,7 @@ keywords-contract = ["dpp/keywords-contract"] document-history-contract = ["dpp/document-history-contract"] app-connect-contract = ["dpp/app-connect-contract"] moderation-charters-contract = ["dpp/moderation-charters-contract"] +decision-proposals-contract = ["dpp/decision-proposals-contract"] token_reward_explanations = ["dpp/token-reward-explanations"] diff --git a/packages/wasm-sdk/Cargo.toml b/packages/wasm-sdk/Cargo.toml index 306f77f670d..a74f484b076 100644 --- a/packages/wasm-sdk/Cargo.toml +++ b/packages/wasm-sdk/Cargo.toml @@ -16,6 +16,7 @@ default = [ "token-history-contract", "keywords-contract", "moderation-charters-contract", + "decision-proposals-contract", "mocks", ] @@ -56,6 +57,10 @@ moderation-charters-contract = [ "dash-sdk/moderation-charters-contract", "rs-sdk-trusted-context-provider/moderation-charters-contract", ] +decision-proposals-contract = [ + "dash-sdk/decision-proposals-contract", + "rs-sdk-trusted-context-provider/decision-proposals-contract", +] token_reward_explanations = ["dash-sdk/token_reward_explanations"] From 1c7482e8855cec4f34b0fd97b195b79e26e7ee7b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 14:29:05 +0300 Subject: [PATCH 2/7] feat(platform): creating a decision proposal opens and funds its yes/no poll (PV15) Co-Authored-By: Claude Opus 5.5 --- book/src/error-handling/error-codes.md | 2 + .../decision-proposals-contract/src/lib.rs | 5 +- .../methods/versioned_methods.rs | 11 + packages/rs-dpp/src/decision_proposal/mod.rs | 503 +++++++++++++ .../src/errors/consensus/basic/basic_error.rs | 29 +- .../decision_proposal_fund_mismatch_error.rs | 84 +++ .../invalid_decision_proposal_rule_error.rs | 60 ++ .../consensus/basic/decision_proposal/mod.rs | 5 + .../rs-dpp/src/errors/consensus/basic/mod.rs | 1 + packages/rs-dpp/src/errors/consensus/codes.rs | 7 + ...on_proposal_end_date_out_of_range_error.rs | 80 +++ .../consensus/state/decision_proposal/mod.rs | 3 + .../rs-dpp/src/errors/consensus/state/mod.rs | 1 + .../src/errors/consensus/state/state_error.rs | 15 +- packages/rs-dpp/src/lib.rs | 1 + .../voting/vote_polls/yes_no_vote_poll/mod.rs | 116 ++- .../advanced_structure_v1/mod.rs | 2 + .../advanced_structure_v2/mod.rs | 98 +++ .../document_create_transition_action/mod.rs | 7 +- .../v0/accepted_token_pool_nullifiers.rs | 1 + .../masternode_vote/yes_no_tests.rs | 18 +- .../yes_no_tests/decision_proposal_tests.rs | 680 ++++++++++++++++++ .../drive/document/index_uniqueness/mod.rs | 1 + .../yes_no/open_yes_no_vote_poll/mod.rs | 99 ++- .../yes_no/open_yes_no_vote_poll/v0/mod.rs | 105 ++- .../document/document_create_transition/v1.rs | 65 +- .../document_create_transition_action/mod.rs | 6 + .../v0/mod.rs | 17 + .../v0/transformer.rs | 15 +- .../state_transition_action/batch/tests.rs | 2 + .../src/util/batch/drive_op_batch/mod.rs | 12 + .../util/batch/drive_op_batch/vote_poll.rs | 58 ++ .../rs-drive/src/util/test_helpers/mod.rs | 7 +- .../dpp_versions/dpp_voting_versions/mod.rs | 4 + .../dpp_versions/dpp_voting_versions/v1.rs | 1 + .../dpp_versions/dpp_voting_versions/v2.rs | 1 + .../dpp_versions/dpp_voting_versions/v3.rs | 6 +- .../drive_abci_validation_versions/v11.rs | 6 + .../src/version/system_limits/v1.rs | 4 +- .../src/version/system_limits/v2.rs | 4 +- .../src/version/system_limits/v3.rs | 4 +- .../src/version/system_limits/v4.rs | 4 +- .../rs-platform-version/src/version/v15.rs | 34 +- .../src/errors/consensus/consensus_error.rs | 13 + 44 files changed, 2108 insertions(+), 89 deletions(-) create mode 100644 packages/rs-dpp/src/decision_proposal/mod.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/decision_proposal/decision_proposal_fund_mismatch_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/decision_proposal/invalid_decision_proposal_rule_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/decision_proposal/mod.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/decision_proposal/decision_proposal_end_date_out_of_range_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/decision_proposal/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v2/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests/decision_proposal_tests.rs create mode 100644 packages/rs-drive/src/util/batch/drive_op_batch/vote_poll.rs diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 39bbeb647d0..53ac6e76e3e 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -61,6 +61,7 @@ Error codes are organized into ranges that correspond to error categories and su | 10800-10818 | Address | `TransitionOverMaxInputsError` (10800), `WithdrawalBelowMinAmountError` (10818) | | 10819-10827 | Shielded | `ShieldedNoActionsError` (10819), `ShieldedTooManyActionsError` (10825), `ShieldedImplicitFeeCapExceededError` (10826), `ShieldedInvalidDenominationError` (10827 — `IdentityCreateFromShieldedPool` exit amount not a member of the versioned denomination set) | | 10900-10949 | Contract Moderation | `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `DocumentActionFeesWithoutModerationError` (10902), `ContractModerationReasonTooLongError` (10903), `InvalidContractModerationReasonDocumentsError` (10904), `InvalidContractModerationDocumentFieldsError` (10905) | +| 11100-11149 | Decision Proposals | `DecisionProposalFundMismatchError` (11100), `InvalidDecisionProposalRuleError` (11101) | ### SignatureError codes (20000-20012) @@ -119,6 +120,7 @@ The fee category currently has a single code. The 30000 range is reserved for fu | 41000-41003 | Contract Groups | `ContractGroupAlreadyExistsError` (41000), `ContractGroupNotFoundError` (41001), `IdentityNotContractGroupOwnerOrAdminError` (41002), `ContractGroupAdminNotFoundError` (41003) | | 41100-41124 | Contract Moderation | `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110), `ContractFeesAlreadyClaimedThisEpochError` (41111), `ContractFeesNothingToClaimError` (41112), `ContractFeeClaimNotAllowedError` (41113), `ContractModerationCounterpartyBarredError` (41114), `DocumentTypeNotDeletableByModeratorsError` (41115), `DocumentModerationWindowElapsedError` (41116), `ContractUserNotWarnedError` (41117), `ContractUserWarningLimitReachedError` (41118), `ContractDocumentRemovalNotFoundError` (41119), `DocumentRestoreWindowElapsedError` (41120), `DocumentRestoreHashMismatchError` (41121), `ContractDocumentAlreadyRestoredError` (41122), `DocumentFieldNotChangeableByModeratorsError` (41123), `DocumentModeratorFieldNotWritableError` (41124) | | 41200-41299 | Contract Moderation Teams | `ContractModeratedDocumentTypeNotYetUsableError` (41200), `ContractModerationAbilityNotGrantedError` (41201), `ModerationCharterAddedModeratorLimitReachedError` (41202), `ModerationReasonNotListedError` (41203), `DocumentTypeNotDeletableOnceSettledError` (41204), `ContractModerationTeamNotSeatedError` (41205), `DocumentNotSettledError` (41206), `ContractTeamActionDoesNotExistError` (41207), `ContractTeamActionAlreadySignedError` (41208), `SettledDeletionNotRestorableError` (41209), `ContractTeamActionAlreadyCompletedError` (41210), `ContractTeamActionDocumentChangedError` (41211), `ContractTeamMemberAddedAfterDocumentError` (41212) | +| 41300-41349 | Decision Proposals | `DecisionProposalEndDateOutOfRangeError` (41300) | Notice how the `DataTriggerError` sub-enum has its own `ErrorWithCode` implementation that the `StateError` delegates to: diff --git a/packages/decision-proposals-contract/src/lib.rs b/packages/decision-proposals-contract/src/lib.rs index 29d62e9c330..ac9f9e3a7b6 100644 --- a/packages/decision-proposals-contract/src/lib.rs +++ b/packages/decision-proposals-contract/src/lib.rs @@ -78,7 +78,10 @@ mod tests { proposal::properties::MINIMUM_SHARE, proposal::properties::END_DATE, ] { - assert!(properties.contains_key(property), "proposal declares {property}"); + assert!( + properties.contains_key(property), + "proposal declares {property}" + ); } let share = &properties[proposal::properties::MINIMUM_SHARE]["properties"]; for key in [ diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index 72c518aafce..81865139b10 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -16,6 +16,9 @@ use crate::data_contract::document_type::{ CONTRACT_VERSION_STAMP_MAX_SIZE, DEFAULT_HASH_SIZE, MAX_INDEX_SIZE, }; use crate::data_contract::errors::DataContractError; +use crate::decision_proposal::{ + decision_proposal_required_fund, is_decision_proposal, DECISION_PROPOSAL_FUND_NAME, +}; use crate::document::property_names::{ CREATED_AT, CREATED_AT_BLOCK_HEIGHT, CREATED_AT_CORE_BLOCK_HEIGHT, CREATOR_ID, TRANSFERRED_AT, TRANSFERRED_AT_BLOCK_HEIGHT, TRANSFERRED_AT_CORE_BLOCK_HEIGHT, UPDATED_AT, @@ -700,6 +703,14 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa document: &Document, platform_version: &PlatformVersion, ) -> Option<(String, Credits)> { + // A decision proposal (protocol version 15) states the decision proposal fund under its + // own type's name; it opens a yes/no poll, not a contest + if is_decision_proposal(&self.data_contract_id(), self.name()) { + return Some(( + DECISION_PROPOSAL_FUND_NAME.to_string(), + decision_proposal_required_fund(platform_version), + )); + } self.indexes() .values() .find(|index| { diff --git a/packages/rs-dpp/src/decision_proposal/mod.rs b/packages/rs-dpp/src/decision_proposal/mod.rs new file mode 100644 index 00000000000..149965c2a42 --- /dev/null +++ b/packages/rs-dpp/src/decision_proposal/mod.rs @@ -0,0 +1,503 @@ +//! Decision proposals (protocol version 15). +//! +//! A decision proposal is a yes/no question any identity puts to the masternodes: a `proposal` +//! document of the decision proposals system contract. Creating one opens a yes/no vote poll +//! ([`YesNoVotePoll`]) whose purpose is the proposal ([`YesNoVotePollPurpose::DecisionProposal`]) +//! and whose rule is the one the proposal states, and funds the poll's votes with the decision +//! proposal fund, which the proposer pays. Proposals are signaling only: nothing acts on a +//! result, which stays in the poll's stored info. +//! +//! [`DecisionProposalVotePoll::from_proposal`] reads the poll a proposal opens from its id and +//! properties. Consensus uses it to open the poll, and clients to name the poll they query. + +use crate::balances::credits::Credits; +use crate::consensus::basic::decision_proposal::InvalidDecisionProposalRuleError; +use crate::prelude::TimestampMillis; +use crate::validation::ConsensusValidationResult; +use crate::voting::vote_polls::yes_no_vote_poll::{ + VotingPowerRounding, YesNoMinimumVotingPower, YesNoVotePoll, YesNoVotePollPurpose, +}; +use crate::ProtocolError; +use platform_value::btreemap_extensions::BTreeValueMapPathHelper; +use platform_value::{Identifier, IdentifierBytes32, Value}; +use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; + +/// The id of the decision proposals system contract, `3iNyoNwjCTkb7XS3RHyWUtLx7pgC6AFAEQNNDHzxqngF`. +/// +/// Spelled here so that consensus code can name the contract without the optional contract +/// crates; the crate's own constant is pinned to this one by a test. +pub const DECISION_PROPOSALS_CONTRACT_ID: Identifier = Identifier(IdentifierBytes32([ + 40, 81, 10, 2, 166, 107, 62, 218, 6, 184, 111, 202, 230, 218, 170, 157, 181, 119, 102, 211, + 195, 114, 92, 200, 90, 88, 205, 30, 174, 49, 203, 200, +])); + +/// The name of the proposal document type. +pub const PROPOSAL_DOCUMENT_TYPE_NAME: &str = "proposal"; + +/// The name a proposal's create states its fund under, in its prefunded voting balance: the +/// proposal's own type. A contested create names its contested index there instead. +pub const DECISION_PROPOSAL_FUND_NAME: &str = PROPOSAL_DOCUMENT_TYPE_NAME; + +/// The properties of a proposal the poll is read from. +pub mod properties { + pub const SUPERMAJORITY_NUMERATOR: &str = "supermajorityNumerator"; + pub const SUPERMAJORITY_DENOMINATOR: &str = "supermajorityDenominator"; + pub const MINIMUM_VOTING_POWER: &str = "minimumVotingPower"; + pub const MINIMUM_SHARE: &str = "minimumShare"; + pub const MINIMUM_SHARE_NUMERATOR: &str = "minimumShare.numerator"; + pub const MINIMUM_SHARE_DENOMINATOR: &str = "minimumShare.denominator"; + pub const MINIMUM_SHARE_ROUNDING: &str = "minimumShare.rounding"; + pub const END_DATE: &str = "endDate"; +} + +/// Whether a document of `document_type_name` in the contract `contract_id` is a decision +/// proposal: a `proposal` of the decision proposals contract. Its create opens a yes/no poll and +/// states the decision proposal fund instead of a contest's. +pub fn is_decision_proposal(contract_id: &Identifier, document_type_name: &str) -> bool { + *contract_id == DECISION_PROPOSALS_CONTRACT_ID + && document_type_name == PROPOSAL_DOCUMENT_TYPE_NAME +} + +/// The fund a decision proposal's create must state, exactly: the fee version's +/// `decision_proposal_fund_required_amount`. It prefunds the votes on the poll; what is left +/// when the poll ends goes to processing fees. +pub fn decision_proposal_required_fund(platform_version: &PlatformVersion) -> Credits { + platform_version + .fee_version + .vote_resolution_fund_fees + .decision_proposal_fund_required_amount +} + +/// The yes/no poll a decision proposal opens and when it closes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecisionProposalVotePoll { + /// The poll: the proposal as its purpose, the proposal's rule. + pub vote_poll: YesNoVotePoll, + /// The proposal's `endDate`: when the poll closes. + pub end_date: TimestampMillis, +} + +impl DecisionProposalVotePoll { + /// The poll the proposal with `document_id` and `properties` opens, by the rule the + /// platform version selects (`dpp.voting_versions.decision_proposal_vote_poll`). + /// + /// The properties are expected to have passed the proposal's schema. A rule that makes no + /// poll the system limits allow is refused with [`InvalidDecisionProposalRuleError`]; the + /// end date is judged against the opening block by the caller + /// ([`YesNoVotePoll::end_date_window`]). + /// + /// # Parameters + /// - `document_id`: the proposal's id, the poll's purpose. + /// - `properties`: the proposal's properties. + /// - `platform_version`: the platform version, whose system limits bound the rule. + /// + /// # Returns + /// The poll and its end date, or the consensus error refusing the rule. + pub fn from_proposal( + document_id: Identifier, + properties: &BTreeMap, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { + match platform_version + .dpp + .voting_versions + .decision_proposal_vote_poll + { + Some(0) => Ok(Self::from_proposal_v0( + document_id, + properties, + platform_version, + )), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "DecisionProposalVotePoll::from_proposal".to_string(), + known_versions: vec![0], + received: version, + }), + None => Err(ProtocolError::UnknownVersionError( + "DecisionProposalVotePoll::from_proposal is not active before protocol version 15" + .to_string(), + )), + } + } + + /// Version 0: the threshold is `supermajorityNumerator / supermajorityDenominator`, and the + /// minimum is `minimumVotingPower` or `minimumShare`, exactly one of them, each held to + /// [`YesNoVotePoll::validate_parameters`]. + fn from_proposal_v0( + document_id: Identifier, + properties: &BTreeMap, + platform_version: &PlatformVersion, + ) -> ConsensusValidationResult { + let refused = |reason: String| { + ConsensusValidationResult::new_with_error( + InvalidDecisionProposalRuleError::new(document_id, reason).into(), + ) + }; + let read = || -> Result { + let integer = |path: &str| { + properties + .get_optional_integer_at_path::(path) + .map_err(|error| format!("`{path}` is not a whole number: {error}")) + }; + let narrow = |path: &str, value: Option| -> Result, String> { + value + .map(|value| { + u8::try_from(value) + .map_err(|_| format!("`{path}` is at most 255, got {value}")) + }) + .transpose() + }; + let required = |path: &str, value: Option| { + value.ok_or_else(|| format!("`{path}` is required")) + }; + let supermajority_numerator = required( + properties::SUPERMAJORITY_NUMERATOR, + narrow( + properties::SUPERMAJORITY_NUMERATOR, + integer(properties::SUPERMAJORITY_NUMERATOR)?, + )?, + )?; + let supermajority_denominator = required( + properties::SUPERMAJORITY_DENOMINATOR, + narrow( + properties::SUPERMAJORITY_DENOMINATOR, + integer(properties::SUPERMAJORITY_DENOMINATOR)?, + )?, + )?; + let minimum_voting_power = integer(properties::MINIMUM_VOTING_POWER)?; + let minimum_share = properties + .get(properties::MINIMUM_SHARE) + .filter(|share| !share.is_null()); + let minimum_voting_power = match (minimum_voting_power, minimum_share) { + (Some(voting_power), None) => YesNoMinimumVotingPower::Absolute { + voting_power: u32::try_from(voting_power).map_err(|_| { + format!( + "`{}` is at most {}, got {voting_power}", + properties::MINIMUM_VOTING_POWER, + u32::MAX + ) + })?, + }, + (None, Some(_)) => YesNoMinimumVotingPower::FractionOfTotal { + numerator: required( + properties::MINIMUM_SHARE_NUMERATOR, + narrow( + properties::MINIMUM_SHARE_NUMERATOR, + integer(properties::MINIMUM_SHARE_NUMERATOR)?, + )?, + )?, + denominator: required( + properties::MINIMUM_SHARE_DENOMINATOR, + narrow( + properties::MINIMUM_SHARE_DENOMINATOR, + integer(properties::MINIMUM_SHARE_DENOMINATOR)?, + )?, + )?, + rounding: rounding_from_name( + properties + .get_str_at_path(properties::MINIMUM_SHARE_ROUNDING) + .map_err(|_| { + format!("`{}` is required", properties::MINIMUM_SHARE_ROUNDING) + })?, + )?, + }, + (Some(_), Some(_)) => { + return Err(format!( + "set `{}` or `{}`, not both", + properties::MINIMUM_VOTING_POWER, + properties::MINIMUM_SHARE + )) + } + (None, None) => { + return Err(format!( + "set `{}` or `{}`", + properties::MINIMUM_VOTING_POWER, + properties::MINIMUM_SHARE + )) + } + }; + let end_date = integer(properties::END_DATE)? + .ok_or_else(|| format!("`{}` is required", properties::END_DATE))?; + let vote_poll = YesNoVotePoll { + purpose: YesNoVotePollPurpose::DecisionProposal { document_id }, + supermajority_numerator, + supermajority_denominator, + minimum_voting_power, + }; + vote_poll + .validate_parameters(platform_version) + .map_err(|error| match error { + ProtocolError::VoteError(reason) => reason, + other => other.to_string(), + })?; + Ok(Self { + vote_poll, + end_date, + }) + }; + match read() { + Ok(decision_proposal_vote_poll) => { + ConsensusValidationResult::new_with_data(decision_proposal_vote_poll) + } + Err(reason) => refused(reason), + } + } +} + +/// The rounding a proposal's `minimumShare.rounding` names, as `VotingPowerRounding` names it in +/// JSON. +fn rounding_from_name(name: &str) -> Result { + match name { + "down" => Ok(VotingPowerRounding::Down), + "up" => Ok(VotingPowerRounding::Up), + "downPlusOne" => Ok(VotingPowerRounding::DownPlusOne), + "upMinusOne" => Ok(VotingPowerRounding::UpMinusOne), + other => Err(format!( + "`{}` is one of down, up, downPlusOne and upMinusOne, got {other}", + properties::MINIMUM_SHARE_ROUNDING + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + + fn proposal(minimum: (&str, Value)) -> BTreeMap { + BTreeMap::from([ + ( + "title".to_string(), + Value::Text("Raise the limit?".to_string()), + ), + ( + properties::SUPERMAJORITY_NUMERATOR.to_string(), + Value::U8(2), + ), + ( + properties::SUPERMAJORITY_DENOMINATOR.to_string(), + Value::U8(3), + ), + (minimum.0.to_string(), minimum.1), + (properties::END_DATE.to_string(), Value::U64(5_000)), + ]) + } + + fn share(numerator: u8, denominator: u8, rounding: &str) -> Value { + Value::Map(vec![ + (Value::Text("numerator".to_string()), Value::U8(numerator)), + ( + Value::Text("denominator".to_string()), + Value::U8(denominator), + ), + ( + Value::Text("rounding".to_string()), + Value::Text(rounding.to_string()), + ), + ]) + } + + fn refusal(result: ConsensusValidationResult) -> String { + match result.errors.as_slice() { + [ConsensusError::BasicError(BasicError::InvalidDecisionProposalRuleError(error))] => { + error.reason().to_string() + } + other => panic!("expected one invalid rule error, got {other:?}"), + } + } + + #[test] + fn should_read_the_poll_a_proposal_opens() { + let platform_version = PlatformVersion::latest(); + let document_id = Identifier::new([7; 32]); + let result = DecisionProposalVotePoll::from_proposal( + document_id, + &proposal((properties::MINIMUM_VOTING_POWER, Value::U32(400))), + platform_version, + ) + .expect("active at 15"); + assert_eq!( + result.into_data().expect("a valid rule"), + DecisionProposalVotePoll { + vote_poll: YesNoVotePoll { + purpose: YesNoVotePollPurpose::DecisionProposal { document_id }, + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 400 }, + }, + end_date: 5_000, + } + ); + let result = DecisionProposalVotePoll::from_proposal( + document_id, + &proposal((properties::MINIMUM_SHARE, share(1, 3, "downPlusOne"))), + platform_version, + ) + .expect("active at 15"); + assert_eq!( + result + .into_data() + .expect("a valid rule") + .vote_poll + .minimum_voting_power, + YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 3, + rounding: VotingPowerRounding::DownPlusOne, + } + ); + } + + #[test] + fn should_refuse_a_rule_outside_the_limits() { + let platform_version = PlatformVersion::latest(); + let document_id = Identifier::new([7; 32]); + let refused = |properties: BTreeMap| { + refusal( + DecisionProposalVotePoll::from_proposal(document_id, &properties, platform_version) + .expect("active at 15"), + ) + }; + + // Neither minimum, and both. + let mut neither = proposal((properties::MINIMUM_VOTING_POWER, Value::U32(1))); + neither.remove(properties::MINIMUM_VOTING_POWER); + assert!(refused(neither).contains("set `minimumVotingPower` or `minimumShare`")); + let mut both = proposal((properties::MINIMUM_VOTING_POWER, Value::U32(1))); + both.insert(properties::MINIMUM_SHARE.to_string(), share(1, 3, "down")); + assert!(refused(both).contains("not both")); + + // A threshold below a simple majority. + let mut below_majority = proposal((properties::MINIMUM_VOTING_POWER, Value::U32(1))); + below_majority.insert( + properties::SUPERMAJORITY_NUMERATOR.to_string(), + Value::U8(127), + ); + below_majority.insert( + properties::SUPERMAJORITY_DENOMINATOR.to_string(), + Value::U8(255), + ); + assert!(refused(below_majority).contains("at least 50%")); + + // A minimum above its cap: 2,000 voting power, half the network. + let max_voting_power = platform_version + .system_limits + .max_yes_no_vote_poll_minimum_voting_power; + assert!(DecisionProposalVotePoll::from_proposal( + document_id, + &proposal(( + properties::MINIMUM_VOTING_POWER, + Value::U32(max_voting_power) + )), + platform_version, + ) + .expect("active at 15") + .is_valid()); + refused(proposal(( + properties::MINIMUM_VOTING_POWER, + Value::U32(max_voting_power + 1), + ))); + assert!(DecisionProposalVotePoll::from_proposal( + document_id, + &proposal((properties::MINIMUM_SHARE, share(1, 2, "up"))), + platform_version, + ) + .expect("active at 15") + .is_valid()); + refused(proposal((properties::MINIMUM_SHARE, share(51, 100, "up")))); + } + + #[test] + fn should_not_be_active_before_protocol_version_15() { + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + assert!(matches!( + DecisionProposalVotePoll::from_proposal( + Identifier::new([7; 32]), + &proposal((properties::MINIMUM_VOTING_POWER, Value::U32(1))), + platform_version, + ), + Err(ProtocolError::UnknownVersionError(_)) + )); + } + + #[test] + fn should_recognize_only_the_proposal_of_the_decision_proposals_contract() { + assert!(is_decision_proposal( + &DECISION_PROPOSALS_CONTRACT_ID, + PROPOSAL_DOCUMENT_TYPE_NAME + )); + assert!(!is_decision_proposal( + &DECISION_PROPOSALS_CONTRACT_ID, + "other" + )); + assert!(!is_decision_proposal( + &Identifier::new([1; 32]), + PROPOSAL_DOCUMENT_TYPE_NAME + )); + } + + #[cfg(feature = "decision-proposals-contract")] + #[test] + fn should_name_the_contract_as_its_crate_does() { + use data_contracts::decision_proposals_contract; + use decision_proposals_contract::v1::document_types::proposal; + + assert_eq!( + DECISION_PROPOSALS_CONTRACT_ID, + decision_proposals_contract::ID + ); + assert_eq!(PROPOSAL_DOCUMENT_TYPE_NAME, proposal::NAME); + assert_eq!( + properties::SUPERMAJORITY_NUMERATOR, + proposal::properties::SUPERMAJORITY_NUMERATOR + ); + assert_eq!( + properties::SUPERMAJORITY_DENOMINATOR, + proposal::properties::SUPERMAJORITY_DENOMINATOR + ); + assert_eq!( + properties::MINIMUM_VOTING_POWER, + proposal::properties::MINIMUM_VOTING_POWER + ); + assert_eq!( + properties::MINIMUM_SHARE, + proposal::properties::MINIMUM_SHARE + ); + assert_eq!(properties::END_DATE, proposal::properties::END_DATE); + for name in proposal::properties::minimum_share::ROUNDINGS { + assert!(rounding_from_name(name).is_ok(), "{name} is a rounding"); + } + } + + /// A create built from a proposal document, as the SDKs build one, states the decision + /// proposal fund under the proposal's name. + #[cfg(feature = "decision-proposals-contract")] + #[test] + fn should_state_the_decision_proposal_fund_on_a_proposal_create() { + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::document_type::methods::DocumentTypeV0Methods; + use crate::document::{Document, DocumentV0}; + use crate::system_data_contracts::{load_system_data_contract, SystemDataContract}; + + let platform_version = PlatformVersion::latest(); + let contract = + load_system_data_contract(SystemDataContract::DecisionProposals, platform_version) + .expect("the contract loads at 15"); + let document_type = contract + .document_type_for_name(PROPOSAL_DOCUMENT_TYPE_NAME) + .expect("the proposal type"); + let document: Document = DocumentV0 { + properties: proposal((properties::MINIMUM_VOTING_POWER, Value::U32(1))), + ..Default::default() + } + .into(); + assert_eq!( + document_type + .prefunded_voting_balance_for_document(&document, platform_version) + .expect("active"), + Some((DECISION_PROPOSAL_FUND_NAME.to_string(), 50_000_000_000)) + ); + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 6ad5a929ad2..816ead32e24 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -51,6 +51,9 @@ use crate::consensus::basic::data_contract::{ InvalidJsonSchemaRefError, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, UnknownDocumentActionTokenEffectError, }; +use crate::consensus::basic::decision_proposal::{ + DecisionProposalFundMismatchError, InvalidDecisionProposalRuleError, +}; use crate::consensus::basic::decode::{ ProtocolVersionParsingError, SerializedObjectParsingError, VersionError, }; @@ -875,6 +878,14 @@ pub enum BasicError { // (protocol version 15). #[error(transparent)] DataContractCompiledMaxSizeExceededError(DataContractCompiledMaxSizeExceededError), + + // Decision proposals (protocol version 15): a create stating the wrong fund, and one whose + // rule makes no poll the system limits allow. + #[error(transparent)] + DecisionProposalFundMismatchError(DecisionProposalFundMismatchError), + + #[error(transparent)] + InvalidDecisionProposalRuleError(InvalidDecisionProposalRuleError), } impl From for ConsensusError { @@ -1090,13 +1101,29 @@ mod tests { )), 206 ); - // A contract compiled over its cap (protocol version 15): the tail of the enum. + // A contract compiled over its cap (protocol version 15). assert_eq!( discriminant_of(BasicError::DataContractCompiledMaxSizeExceededError( DataContractCompiledMaxSizeExceededError::new(0, 0) )), 207 ); + // Decision proposals (protocol version 15): the tail of the enum. + assert_eq!( + discriminant_of(BasicError::DecisionProposalFundMismatchError( + DecisionProposalFundMismatchError::new(Identifier::from([1; 32]), 1, None) + )), + 208 + ); + assert_eq!( + discriminant_of(BasicError::InvalidDecisionProposalRuleError( + InvalidDecisionProposalRuleError::new( + Identifier::from([1; 32]), + "reason".to_string() + ) + )), + 209 + ); } /// The variants that shipped in 4.1 keep the discriminants they were released with, so an diff --git a/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/decision_proposal_fund_mismatch_error.rs b/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/decision_proposal_fund_mismatch_error.rs new file mode 100644 index 00000000000..a9fd9745cb6 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/decision_proposal_fund_mismatch_error.rs @@ -0,0 +1,84 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::fee::Credits; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// A decision proposal create (protocol version 15) that does not state exactly the decision +/// proposal fund under the proposal's name (11100). The fund is stated as a create's +/// prefunded voting balance, a name and an amount: the name must be `proposal`, the document +/// type's, and the amount the fee version's `decision_proposal_fund_required_amount`. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "decision proposal {document_id} must state a fund of exactly {required_fund} credits under `proposal`, it stated {}", + stated_fund_description(stated_fund) +)] +#[platform_serialize(unversioned)] +pub struct DecisionProposalFundMismatchError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_id: Identifier, + required_fund: Credits, + stated_fund: Option<(String, Credits)>, +} + +fn stated_fund_description(stated_fund: &Option<(String, Credits)>) -> String { + match stated_fund { + None => "none".to_string(), + Some((name, amount)) => format!("{amount} credits under `{name}`"), + } +} + +impl DecisionProposalFundMismatchError { + pub fn new( + document_id: Identifier, + required_fund: Credits, + stated_fund: Option<(String, Credits)>, + ) -> Self { + Self { + document_id, + required_fund, + stated_fund, + } + } + + pub fn document_id(&self) -> Identifier { + self.document_id + } + + /// The fund a decision proposal must state. + pub fn required_fund(&self) -> Credits { + self.required_fund + } + + /// The name and amount the create stated, `None` when it stated no fund. + pub fn stated_fund(&self) -> Option<&(String, Credits)> { + self.stated_fund.as_ref() + } +} + +impl From for ConsensusError { + fn from(err: DecisionProposalFundMismatchError) -> Self { + Self::BasicError(BasicError::DecisionProposalFundMismatchError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/invalid_decision_proposal_rule_error.rs b/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/invalid_decision_proposal_rule_error.rs new file mode 100644 index 00000000000..6c025464552 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/invalid_decision_proposal_rule_error.rs @@ -0,0 +1,60 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// A decision proposal (protocol version 15) whose rule makes no yes/no poll the system limits +/// allow (11101): a pass threshold below a simple majority or not below one, both or neither of +/// `minimumVotingPower` and `minimumShare`, or a minimum above its cap. `reason` says which. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("decision proposal {document_id} has an invalid rule: {reason}")] +#[platform_serialize(unversioned)] +pub struct InvalidDecisionProposalRuleError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_id: Identifier, + reason: String, +} + +impl InvalidDecisionProposalRuleError { + pub fn new(document_id: Identifier, reason: String) -> Self { + Self { + document_id, + reason, + } + } + + pub fn document_id(&self) -> Identifier { + self.document_id + } + + pub fn reason(&self) -> &str { + &self.reason + } +} + +impl From for ConsensusError { + fn from(err: InvalidDecisionProposalRuleError) -> Self { + Self::BasicError(BasicError::InvalidDecisionProposalRuleError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/mod.rs new file mode 100644 index 00000000000..05e08c07bee --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/mod.rs @@ -0,0 +1,5 @@ +mod decision_proposal_fund_mismatch_error; +mod invalid_decision_proposal_rule_error; + +pub use decision_proposal_fund_mismatch_error::*; +pub use invalid_decision_proposal_rule_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/mod.rs index 6b5b07859b8..5c3c01f3ee5 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/mod.rs @@ -15,6 +15,7 @@ pub mod unsupported_protocol_version_error; pub mod basic_error; pub mod contract_group; pub mod contract_moderation; +pub mod decision_proposal; pub mod group; pub mod invalid_identifier_error; pub mod json_schema_compilation_error; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index cdae5e712b1..03fd88fbf55 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -286,6 +286,10 @@ impl ErrorWithCode for BasicError { // Moderation Team Errors: 11000-11099 Self::ModerationCharterMalformedFieldError(_) => 11000, Self::ModerationCharterRewardSplitNotOneHundredError(_) => 11001, + + // Decision Proposal Errors: 11100-11149 + Self::DecisionProposalFundMismatchError(_) => 11100, + Self::InvalidDecisionProposalRuleError(_) => 11101, } } } @@ -528,6 +532,9 @@ impl ErrorWithCode for StateError { Self::ContractTeamActionAlreadyCompletedError(_) => 41210, Self::ContractTeamActionDocumentChangedError(_) => 41211, Self::ContractTeamMemberAddedAfterDocumentError(_) => 41212, + + // Decision Proposal Errors: 41300-41349 + Self::DecisionProposalEndDateOutOfRangeError(_) => 41300, } } } diff --git a/packages/rs-dpp/src/errors/consensus/state/decision_proposal/decision_proposal_end_date_out_of_range_error.rs b/packages/rs-dpp/src/errors/consensus/state/decision_proposal/decision_proposal_end_date_out_of_range_error.rs new file mode 100644 index 00000000000..1c41e2e3a4e --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/decision_proposal/decision_proposal_end_date_out_of_range_error.rs @@ -0,0 +1,80 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::prelude::TimestampMillis; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// A decision proposal (protocol version 15) whose end date is outside the window the block +/// creating it allows (41300): from `min_yes_no_vote_poll_duration_ms` to +/// `max_yes_no_vote_poll_duration_ms` after the block's time, both ends allowed. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "decision proposal {document_id} ends at {end_date}, it must end from {earliest_end_date} to {latest_end_date}" +)] +#[platform_serialize(unversioned)] +pub struct DecisionProposalEndDateOutOfRangeError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_id: Identifier, + end_date: TimestampMillis, + earliest_end_date: TimestampMillis, + latest_end_date: TimestampMillis, +} + +impl DecisionProposalEndDateOutOfRangeError { + pub fn new( + document_id: Identifier, + end_date: TimestampMillis, + earliest_end_date: TimestampMillis, + latest_end_date: TimestampMillis, + ) -> Self { + Self { + document_id, + end_date, + earliest_end_date, + latest_end_date, + } + } + + pub fn document_id(&self) -> Identifier { + self.document_id + } + + pub fn end_date(&self) -> TimestampMillis { + self.end_date + } + + pub fn earliest_end_date(&self) -> TimestampMillis { + self.earliest_end_date + } + + pub fn latest_end_date(&self) -> TimestampMillis { + self.latest_end_date + } +} + +impl From for ConsensusError { + fn from(err: DecisionProposalEndDateOutOfRangeError) -> Self { + Self::StateError(StateError::DecisionProposalEndDateOutOfRangeError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/decision_proposal/mod.rs b/packages/rs-dpp/src/errors/consensus/state/decision_proposal/mod.rs new file mode 100644 index 00000000000..f6fe6902342 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/decision_proposal/mod.rs @@ -0,0 +1,3 @@ +mod decision_proposal_end_date_out_of_range_error; + +pub use decision_proposal_end_date_out_of_range_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/state/mod.rs b/packages/rs-dpp/src/errors/consensus/state/mod.rs index 56bc2f0df10..3ea3d297f6b 100644 --- a/packages/rs-dpp/src/errors/consensus/state/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/mod.rs @@ -3,6 +3,7 @@ pub mod contract_group; pub mod contract_moderation; pub mod data_contract; pub mod data_trigger; +pub mod decision_proposal; pub mod document; pub mod group; pub mod identity; diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 8935295fd40..ff7e97306d0 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -124,6 +124,7 @@ use crate::consensus::state::voting::vote_poll_not_available_for_voting_error::V use crate::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use crate::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; use crate::consensus::state::voting::yes_no_vote_poll_not_available_for_voting_error::YesNoVotePollNotAvailableForVotingError; +use crate::consensus::state::decision_proposal::DecisionProposalEndDateOutOfRangeError; use super::document::document_timestamps_are_equal_error::DocumentTimestampsAreEqualError; @@ -750,6 +751,11 @@ pub enum StateError { // Yes/no vote polls (protocol version 15). #[error(transparent)] YesNoVotePollNotAvailableForVotingError(YesNoVotePollNotAvailableForVotingError), + + // Decision proposals (protocol version 15): an end date outside the window the block + // creating the proposal allows. + #[error(transparent)] + DecisionProposalEndDateOutOfRangeError(DecisionProposalEndDateOutOfRangeError), } impl From for ConsensusError { @@ -1681,7 +1687,7 @@ mod tests { )), 175 ); - // Yes/no vote polls (protocol version 15): the tail of the enum. + // Yes/no vote polls (protocol version 15). assert_eq!( discriminant_of(StateError::YesNoVotePollNotAvailableForVotingError( YesNoVotePollNotAvailableForVotingError::new( @@ -1691,5 +1697,12 @@ mod tests { )), 176 ); + // Decision proposals (protocol version 15): the tail of the enum. + assert_eq!( + discriminant_of(StateError::DecisionProposalEndDateOutOfRangeError( + DecisionProposalEndDateOutOfRangeError::new(identity_id, 1, 2, 3) + )), + 177 + ); } } diff --git a/packages/rs-dpp/src/lib.rs b/packages/rs-dpp/src/lib.rs index f893f1423ff..a1ae3e772e9 100644 --- a/packages/rs-dpp/src/lib.rs +++ b/packages/rs-dpp/src/lib.rs @@ -81,6 +81,7 @@ pub mod core_types; pub mod address_funds; pub mod contract_group; +pub mod decision_proposal; pub mod group; pub mod moderation_charter; pub mod shielded; diff --git a/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs b/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs index f5214066266..ab6d5c5a2fe 100644 --- a/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs +++ b/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs @@ -1,3 +1,4 @@ +use crate::prelude::TimestampMillis; #[cfg(feature = "json-conversion")] use crate::serialization::json_safe_fields; #[cfg(feature = "json-conversion")] @@ -16,6 +17,7 @@ use platform_version::version::PlatformVersion; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; use std::fmt; +use std::ops::RangeInclusive; /// Voting power: a masternode vote weighs 1, an evonode vote weighs 4. The sum over the whole /// masternode list fits a `u32`. @@ -274,9 +276,10 @@ impl YesNoVotePoll { /// Version 0 refuses a zero denominator, a zero numerator (any single yes would pass /// against every no), a numerator that is not below the denominator (yes could never exceed - /// the whole of yes plus no), and a minimum above its cap (a voting power, or a share of the - /// total with a zero denominator or above the allowed percentage). The purpose needs no - /// check: every variant has a fixed size. + /// the whole of yes plus no), a threshold below `min_yes_no_vote_poll_supermajority_percent` + /// (a poll passing with fewer yes than no), and a minimum above its cap (a voting power, or + /// a share of the total with a zero denominator or above the allowed percentage). The + /// purpose needs no check: every variant has a fixed size. fn validate_parameters_v0( &self, platform_version: &PlatformVersion, @@ -297,6 +300,17 @@ impl YesNoVotePoll { self.supermajority_numerator, self.supermajority_denominator ))); } + let min_percent = platform_version + .system_limits + .min_yes_no_vote_poll_supermajority_percent; + if (self.supermajority_numerator as u32) * 100 + < (self.supermajority_denominator as u32) * (min_percent as u32) + { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll supermajority is at least {}% of yes plus no, got {}/{}", + min_percent, self.supermajority_numerator, self.supermajority_denominator + ))); + } match self.minimum_voting_power { YesNoMinimumVotingPower::Absolute { voting_power } => { let max_voting_power = platform_version @@ -333,6 +347,57 @@ impl YesNoVotePoll { Ok(()) } + /// The end dates a yes/no poll opened in a block at `opening_time_ms` may have, by the rule + /// the platform version selects (`dpp.voting_versions.yes_no_vote_poll_parameters`). + /// + /// # Parameters + /// - `opening_time_ms`: the time of the block that opens the poll. + /// - `platform_version`: the platform version, whose system limits bound the window. + /// + /// # Returns + /// The earliest and the latest end date, both allowed. + pub fn end_date_window( + opening_time_ms: TimestampMillis, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { + match platform_version + .dpp + .voting_versions + .yes_no_vote_poll_parameters + { + Some(0) => Ok(Self::end_date_window_v0(opening_time_ms, platform_version)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "YesNoVotePoll::end_date_window".to_string(), + known_versions: vec![0], + received: version, + }), + None => Err(ProtocolError::UnknownVersionError( + "YesNoVotePoll::end_date_window is not active before protocol version 15" + .to_string(), + )), + } + } + + /// Version 0: from `min_yes_no_vote_poll_duration_ms` (and at least 1 ms) after the opening + /// block to `max_yes_no_vote_poll_duration_ms` after it. A poll that ends in the block that + /// opens it, or earlier, would close at the end of that block with no vote cast; one that + /// ends too soon would close before masternode operators see it; one that runs too long + /// would hold its fund and trees for as long. The latest end date is also at most + /// `i64::MAX`, past which the end date index (which flips its top bit, `encode_u64`) would + /// sort it before every block time. + fn end_date_window_v0( + opening_time_ms: TimestampMillis, + platform_version: &PlatformVersion, + ) -> RangeInclusive { + let limits = &platform_version.system_limits; + let earliest = + opening_time_ms.saturating_add(limits.min_yes_no_vote_poll_duration_ms.max(1)); + let latest = opening_time_ms + .saturating_add(limits.max_yes_no_vote_poll_duration_ms) + .min(i64::MAX as TimestampMillis); + earliest..=latest + } + /// The least yes plus no voting power the poll needs, given the total voting power of the /// masternode list in the block that closes it, by the rule the platform version selects /// (`dpp.voting_versions.yes_no_vote_poll_decision`). @@ -655,7 +720,7 @@ mod tests { } .validate_parameters(platform_version) .is_err()); - // A simple majority is a valid rule. + // A simple majority is a valid rule, the lowest one. assert!(YesNoVotePoll { supermajority_numerator: 1, supermajority_denominator: 2, @@ -663,6 +728,49 @@ mod tests { } .validate_parameters(platform_version) .is_ok()); + assert!(YesNoVotePoll { + supermajority_numerator: 128, + supermajority_denominator: 255, + ..poll() + } + .validate_parameters(platform_version) + .is_ok()); + // Below half, a poll could pass with fewer yes than no. + assert!(YesNoVotePoll { + supermajority_numerator: 127, + supermajority_denominator: 255, + ..poll() + } + .validate_parameters(platform_version) + .is_err()); + assert!(YesNoVotePoll { + supermajority_numerator: 1, + supermajority_denominator: 3, + ..poll() + } + .validate_parameters(platform_version) + .is_err()); + } + + #[test] + fn should_allow_end_dates_from_one_to_four_weeks_after_the_opening_block() { + let platform_version = PlatformVersion::latest(); + const WEEK: u64 = 604_800_000; + let opening = 1_000; + let window = + YesNoVotePoll::end_date_window(opening, platform_version).expect("active at 15"); + assert_eq!(window, (opening + WEEK)..=(opening + 4 * WEEK)); + assert!(!window.contains(&(opening + WEEK - 1))); + assert!(!window.contains(&(opening + 4 * WEEK + 1))); + // Near the end of time the latest end date stops at what the end date index can sort. + let late = YesNoVotePoll::end_date_window(i64::MAX as u64 - WEEK, platform_version) + .expect("active at 15"); + assert_eq!(late, (i64::MAX as u64)..=(i64::MAX as u64)); + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + assert!(matches!( + YesNoVotePoll::end_date_window(opening, platform_version), + Err(ProtocolError::UnknownVersionError(_)) + )); } #[test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index 849f66cf2b3..face98f61c6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -317,6 +317,7 @@ mod tests { moderated: false, consumed_documents: Vec::new(), derived_index_values: None, + decision_proposal: None, }) } @@ -597,6 +598,7 @@ mod tests { moderated: false, consumed_documents: Vec::new(), derived_index_values: None, + decision_proposal: None, }) } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v2/mod.rs new file mode 100644 index 00000000000..e35438a37a2 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v2/mod.rs @@ -0,0 +1,98 @@ +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::decision_proposal::DecisionProposalFundMismatchError; +use dpp::consensus::state::decision_proposal::DecisionProposalEndDateOutOfRangeError; +use dpp::dashcore::Network; +use dpp::decision_proposal::{ + decision_proposal_required_fund, DecisionProposalVotePoll, DECISION_PROPOSAL_FUND_NAME, +}; +use dpp::identifier::Identifier; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0}; +use crate::error::Error; +use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::advanced_structure_v1::DocumentCreateTransitionActionStructureValidationV1; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait DocumentCreateTransitionActionStructureValidationV2 { + fn validate_structure_v2( + &self, + owner_id: Identifier, + block_info: &BlockInfo, + network: Network, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DocumentCreateTransitionActionStructureValidationV2 for DocumentCreateTransitionAction { + /// Version 1, then a decision proposal (protocol version 15) must state exactly the + /// decision proposal fund under the proposal's name, its rule must make a yes/no poll within + /// the system limits, and it must end one to four weeks after the block. All three are on + /// the transition, judged against the block as v1's property rules are, so check_tx refuses + /// what a block would. + fn validate_structure_v2( + &self, + owner_id: Identifier, + block_info: &BlockInfo, + network: Network, + platform_version: &PlatformVersion, + ) -> Result { + let result = self.validate_structure_v1(owner_id, block_info, network, platform_version)?; + if !result.is_valid() { + return Ok(result); + } + + // -->> Introduced in V2 <<-- + let Some(decision_proposal) = self.decision_proposal() else { + return Ok(result); + }; + let document_id = self.base().id(); + + // The fund prefunds the poll's votes. Stated exactly, so a proposer signs what it pays. + let required_fund = decision_proposal_required_fund(platform_version); + let states_the_fund = matches!( + &decision_proposal.stated_fund, + Some((name, amount)) if name == DECISION_PROPOSAL_FUND_NAME && *amount == required_fund + ); + if !states_the_fund { + return Ok(SimpleConsensusValidationResult::new_with_error( + DecisionProposalFundMismatchError::new( + document_id, + required_fund, + decision_proposal.stated_fund.clone(), + ) + .into(), + )); + } + + // The rule: the schema validation in v1 has held every property to its type, so what is + // left is whether the rule makes a poll the system limits allow + let vote_poll = + DecisionProposalVotePoll::from_proposal(document_id, self.data(), platform_version) + .map_err(Error::Protocol)?; + if !vote_poll.is_valid() { + return Ok(SimpleConsensusValidationResult::new_with_errors( + vote_poll.errors, + )); + } + let vote_poll = vote_poll.into_data().map_err(Error::Protocol)?; + + // A poll ending too soon closes before masternode operators see it, and one ending too + // late holds its fund and trees for as long: from one to four weeks after this block + let end_date_window = YesNoVotePoll::end_date_window(block_info.time_ms, platform_version) + .map_err(Error::Protocol)?; + if !end_date_window.contains(&vote_poll.end_date) { + return Ok(SimpleConsensusValidationResult::new_with_error( + DecisionProposalEndDateOutOfRangeError::new( + document_id, + vote_poll.end_date, + *end_date_window.start(), + *end_date_window.end(), + ) + .into(), + )); + } + Ok(result) + // -->> End Introduced in V2 <<-- + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs index 84512e0e03c..af05a192850 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs @@ -13,11 +13,13 @@ use crate::execution::validation::state_transition::batch::action_validation::do use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::state_v2::DocumentCreateTransitionActionStateValidationV2; use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::advanced_structure_v0::DocumentCreateTransitionActionStructureValidationV0; use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::advanced_structure_v1::DocumentCreateTransitionActionStructureValidationV1; +use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::advanced_structure_v2::DocumentCreateTransitionActionStructureValidationV2; use crate::execution::validation::state_transition::batch::action_validation::document::document_reference_validation::ConsumedLookupDocument; use crate::platform_types::platform::PlatformStateRef; mod advanced_structure_v0; mod advanced_structure_v1; +mod advanced_structure_v2; mod state_v0; mod state_v1; mod state_v2; @@ -69,9 +71,12 @@ impl DocumentCreateTransitionActionValidation for DocumentCreateTransitionAction // V1 introduces the cross-check that the prefunded voting balance names the same // contested vote poll that the document itself resolves to 1 => self.validate_structure_v1(owner_id, block_info, network, platform_version), + // V2 holds a decision proposal (protocol version 15) to the decision proposal fund, + // a rule within the system limits and an end date one to four weeks away + 2 => self.validate_structure_v2(owner_id, block_info, network, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "DocumentCreateTransitionAction::validate_structure".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs index 6a51c5576f5..cf927c482b2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/accepted_token_pool_nullifiers.rs @@ -452,6 +452,7 @@ mod tests { moderated: false, consumed_documents: vec![], derived_index_values: None, + decision_proposal: None, } .into(), ), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests.rs index e7435eeb0a9..fbd5ccbe1a4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests.rs @@ -1,5 +1,6 @@ //! Votes on yes/no vote polls: the poll's life from opening to the record of its decision. +use crate::config::PlatformConfig; use crate::execution::check_tx::CheckTxLevel; use crate::execution::platform_events::voting::masternode_vote_strength; use crate::execution::validation::state_transition::state_transitions::tests::{ @@ -27,6 +28,7 @@ use dpp::consensus::basic::BasicError; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; use dpp::dashcore::hashes::Hash; +use dpp::dashcore::Network; use dpp::dashcore::ProTxHash; use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeType; use dpp::identifier::Identifier; @@ -1173,9 +1175,18 @@ async fn should_close_a_contested_poll_and_a_yes_no_poll_that_end_at_the_same_ti use dapi_grpc::platform::v0::get_contested_resource_vote_state_request::get_contested_resource_vote_state_request_v0::ResultType; let platform_version = PlatformVersion::latest(); - let mut platform = new_platform(); + // On mainnet a DPNS contest ends two weeks after its creation at the genesis block time, + // inside the window a yes/no poll opened then may end in (one to four weeks); a test + // network's 90 minutes would be too soon for it. + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .with_config(PlatformConfig { + network: Network::Mainnet, + ..Default::default() + }) + .build_with_mock_rpc() + .set_genesis_state(); let platform_state = platform.state.load(); - // The DPNS contest ends two weeks after its creation at the genesis block time. let (_contender_1, _contender_2, dpns_contract) = create_dpns_identity_name_contest( &mut platform, &platform_state, @@ -1324,6 +1335,7 @@ async fn should_only_remove_the_later_end_date_entry_of_a_poll_listed_twice() { end_date, &BlockInfo::default(), &mut None, + &mut None, &mut opening_operations, None, platform_version, @@ -1739,3 +1751,5 @@ async fn should_answer_the_poll_state_and_masternode_vote_queries_through_a_poll ); } } + +mod decision_proposal_tests; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests/decision_proposal_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests/decision_proposal_tests.rs new file mode 100644 index 00000000000..fa48dbd179c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests/decision_proposal_tests.rs @@ -0,0 +1,680 @@ +//! Decision proposals (protocol version 15): creating a `proposal` of the decision proposals +//! contract opens a yes/no vote poll with the proposal's rule, funded with the decision proposal +//! fund (0.5 Dash) its proposer pays. Masternodes vote on it like on any yes/no poll, and its +//! result is recorded when it closes; nothing acts on it. + +use super::*; +use crate::execution::validation::state_transition::state_transitions::tests::setup_identity; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::DataContract; +use dpp::decision_proposal::{ + decision_proposal_required_fund, DecisionProposalVotePoll, DECISION_PROPOSAL_FUND_NAME, + PROPOSAL_DOCUMENT_TYPE_NAME, +}; +use dpp::document::{Document, DocumentV0, DocumentV0Getters}; +use dpp::fee::fee_result::FeeResult; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::{Identity, SecurityLevel}; +use dpp::platform_value::{Bytes32, Value}; +use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; +use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransitionV0; +use dpp::state_transition::batch_transition::{ + BatchTransition, BatchTransitionV0, DocumentCreateTransition, +}; +use dpp::state_transition::StateTransition; +use drive::drive::document::query::QueryDocumentsOutcomeV0Methods; +use drive::query::DriveDocumentQuery; +use rand::prelude::StdRng; +use rand::SeedableRng; +use std::collections::BTreeMap; + +/// A week, in milliseconds: the shortest a proposal may run. +const WEEK_MS: u64 = 604_800_000; + +/// An identity making proposals, with the identity contract nonce its next transition uses. +struct Proposer { + identity: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + next_nonce: IdentityNonce, +} + +fn proposer(platform: &mut TempPlatform, seed: u64) -> Proposer { + // Enough for a few proposals and their fees + let (identity, signer, key) = setup_identity(platform, seed, 300_000_000_000); + Proposer { + identity, + signer, + key, + next_nonce: 1, + } +} + +fn decision_proposals_contract( + platform: &TempPlatform, + platform_version: &PlatformVersion, +) -> Arc { + platform + .drive + .cache + .system_data_contracts + .load_decision_proposals(platform_version) + .expect("expected the decision proposals contract at genesis") +} + +/// A proposal ending at `end_date` that passes on more than half of yes plus no, with at least +/// 5 voting power cast. +fn proposal(title: &str, end_date: u64) -> BTreeMap { + BTreeMap::from([ + ("title".to_string(), Value::Text(title.to_string())), + ( + "description".to_string(), + Value::Text("Yes raises it, no keeps it.".to_string()), + ), + ("supermajorityNumerator".to_string(), Value::U8(1)), + ("supermajorityDenominator".to_string(), Value::U8(2)), + ("minimumVotingPower".to_string(), Value::U32(5)), + ("endDate".to_string(), Value::U64(end_date)), + ]) +} + +/// A serialized create of a proposal holding `properties`, signed by `proposer`, stating +/// `stated_fund` (the decision proposal fund when `None`), and the id of the proposal. +async fn proposal_transition( + contract: &DataContract, + proposer: &mut Proposer, + properties: BTreeMap, + stated_fund: Option>, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> (Vec, Identifier) { + let document_type = contract + .document_type_for_name(PROPOSAL_DOCUMENT_TYPE_NAME) + .expect("expected the proposal document type"); + let entropy = Bytes32::random_with_rng(rng); + let nonce = proposer.next_nonce; + proposer.next_nonce += 1; + let mut document: Document = DocumentV0 { + owner_id: proposer.identity.id(), + properties, + ..Default::default() + } + .into(); + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected to set the document id"); + let id = document.id(); + let create_transition: DocumentCreateTransition = DocumentCreateTransitionV0 { + base: DocumentBaseTransition::from_document( + &document, + document_type, + None, + nonce, + platform_version, + None, + ) + .expect("expected a base transition"), + entropy: entropy.0, + data: document.properties_consumed(), + prefunded_voting_balance: stated_fund.unwrap_or_else(|| { + Some(( + DECISION_PROPOSAL_FUND_NAME.to_string(), + decision_proposal_required_fund(platform_version), + )) + }), + } + .into(); + let batch: BatchTransition = BatchTransitionV0 { + owner_id: proposer.identity.id(), + transitions: vec![create_transition.into()], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut transition: StateTransition = batch.into(); + transition + .sign_external( + &proposer.key, + &proposer.signer, + Some(|_, _| Ok(SecurityLevel::HIGH)), + ) + .await + .expect("expected to sign"); + ( + transition + .serialize_to_bytes() + .expect("expected to serialize the transition"), + id, + ) +} + +/// Processes one transition in a block at `time_ms` and returns its execution result. +fn process( + platform: &TempPlatform, + transition: Vec, + time_ms: u64, + platform_version: &PlatformVersion, +) -> StateTransitionExecutionResult { + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[transition], + &platform_state, + &BlockInfo { + time_ms, + height: 1, + core_height: 42, + epoch: Default::default(), + }, + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) +} + +fn process_valid( + platform: &TempPlatform, + transition: Vec, + time_ms: u64, + platform_version: &PlatformVersion, +) -> FeeResult { + match process(platform, transition, time_ms, platform_version) { + SuccessfulExecution { fee_result, .. } => fee_result, + other => panic!("expected the proposal to pass, got {other:?}"), + } +} + +fn process_refused( + platform: &TempPlatform, + transition: Vec, + time_ms: u64, + platform_version: &PlatformVersion, +) -> ConsensusError { + match process(platform, transition, time_ms, platform_version) { + StateTransitionExecutionResult::PaidConsensusError { error, .. } => error, + other => panic!("expected a paid refusal, got {other:?}"), + } +} + +/// The poll the proposal `document_id` holding `properties` opens. +fn proposal_poll( + document_id: Identifier, + properties: &BTreeMap, + platform_version: &PlatformVersion, +) -> YesNoVotePoll { + DecisionProposalVotePoll::from_proposal(document_id, properties, platform_version) + .expect("active at 15") + .into_data() + .expect("a valid rule") + .vote_poll +} + +fn prefunded_balance( + platform: &TempPlatform, + vote_poll: &YesNoVotePoll, + platform_version: &PlatformVersion, +) -> Option { + platform + .drive + .fetch_prefunded_specialized_balance( + vote_poll + .specialized_balance_id(platform_version) + .expect("expected the balance id") + .to_buffer(), + None, + platform_version, + ) + .expect("expected to read the balance") +} + +fn identity_balance( + platform: &TempPlatform, + identity_id: Identifier, + platform_version: &PlatformVersion, +) -> Credits { + platform + .drive + .fetch_identity_balance(identity_id.to_buffer(), None, platform_version) + .expect("expected to read the balance") + .expect("expected the identity") +} + +fn stored_proposal( + platform: &TempPlatform, + contract: &DataContract, + document_id: Identifier, +) -> Option { + let document_type = contract + .document_type_for_name(PROPOSAL_DOCUMENT_TYPE_NAME) + .expect("expected the proposal document type"); + platform + .drive + .query_documents( + DriveDocumentQuery::new_primary_key_single_item_query( + contract, + document_type, + document_id, + ), + None, + false, + None, + None, + ) + .expect("expected to query the proposal") + .documents_owned() + .pop() +} + +#[tokio::test] +async fn should_open_and_fund_the_poll_of_a_proposal_when_it_is_created() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let contract = decision_proposals_contract(&platform, platform_version); + let mut rng = StdRng::seed_from_u64(0xDEC1_5104); + let mut proposer = proposer(&mut platform, 1); + let balance_before = identity_balance(&platform, proposer.identity.id(), platform_version); + + let properties = proposal("Raise the limit?", END_DATE); + let (transition, document_id) = proposal_transition( + &contract, + &mut proposer, + properties.clone(), + None, + &mut rng, + platform_version, + ) + .await; + // The mempool admits it at both levels, the fee estimate of the opening included + for (level, verdict) in check_tx_verdicts(&platform, &transition) { + assert_eq!(verdict, Ok(true), "check_tx {level:?} admits the proposal"); + } + let fee_result = process_valid(&platform, transition, 0, platform_version); + + let fund = decision_proposal_required_fund(platform_version); + assert_eq!( + fund, 50_000_000_000, + "the decision proposal fund is 0.5 Dash" + ); + let vote_poll = proposal_poll(document_id, &properties, platform_version); + // The poll is open, with the proposal's rule, from this block, until the proposal's end date + let stored_info = poll_state(&platform, &vote_poll, platform_version) + .stored_info + .expect("expected the poll to be open"); + assert_eq!(stored_info.vote_poll(), &vote_poll); + assert!(matches!( + stored_info.status(), + YesNoVotePollStatus::Started(block) if block.time_ms == 0 + )); + let end_dates = vote_poll_end_dates(&platform.drive, platform_version); + assert_eq!( + end_dates.get(&END_DATE).map(|polls| polls.len()), + Some(1), + "the poll is listed under the proposal's end date" + ); + // funded by the proposer with the decision proposal fund, besides the create's fees + assert_eq!( + prefunded_balance(&platform, &vote_poll, platform_version), + Some(fund) + ); + assert_eq!( + balance_before - identity_balance(&platform, proposer.identity.id(), platform_version), + fund + fee_result.total_base_fee() + ); + // and the proposal is stored. + assert!(stored_proposal(&platform, &contract, document_id).is_some()); +} + +#[tokio::test] +async fn should_refuse_a_proposal_stating_any_other_fund() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let contract = decision_proposals_contract(&platform, platform_version); + let mut rng = StdRng::seed_from_u64(0xDEC1_5105); + let mut proposer = proposer(&mut platform, 2); + let fund = decision_proposal_required_fund(platform_version); + + for stated_fund in [ + None, + Some((DECISION_PROPOSAL_FUND_NAME.to_string(), fund - 1)), + Some((DECISION_PROPOSAL_FUND_NAME.to_string(), fund + 1)), + Some(("byEndDate".to_string(), fund)), + ] { + let properties = proposal("Raise the limit?", END_DATE); + let (transition, document_id) = proposal_transition( + &contract, + &mut proposer, + properties.clone(), + Some(stated_fund.clone()), + &mut rng, + platform_version, + ) + .await; + assert_eq!( + check_tx_verdicts(&platform, &transition)[0].1, + Ok(false), + "check_tx refuses a proposal stating {stated_fund:?}" + ); + let error = process_refused(&platform, transition, 0, platform_version); + assert_matches!( + error, + ConsensusError::BasicError(BasicError::DecisionProposalFundMismatchError(error)) + if error.document_id() == document_id + && error.required_fund() == fund + && error.stated_fund() == stated_fund.as_ref(), + "a proposal stating {stated_fund:?} is refused" + ); + let vote_poll = proposal_poll(document_id, &properties, platform_version); + assert!(poll_state(&platform, &vote_poll, platform_version) + .stored_info + .is_none()); + assert_eq!( + prefunded_balance(&platform, &vote_poll, platform_version), + None + ); + } + assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); +} + +#[tokio::test] +async fn should_refuse_a_proposal_whose_rule_is_outside_the_limits() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let contract = decision_proposals_contract(&platform, platform_version); + let mut rng = StdRng::seed_from_u64(0xDEC1_5106); + let mut proposer = proposer(&mut platform, 3); + let max_voting_power = platform_version + .system_limits + .max_yes_no_vote_poll_minimum_voting_power; + + let below_majority = { + let mut properties = proposal("Below a majority", END_DATE); + properties.insert("supermajorityNumerator".to_string(), Value::U8(1)); + properties.insert("supermajorityDenominator".to_string(), Value::U8(3)); + properties + }; + let unanimity = { + let mut properties = proposal("Unanimity", END_DATE); + properties.insert("supermajorityNumerator".to_string(), Value::U8(2)); + properties.insert("supermajorityDenominator".to_string(), Value::U8(2)); + properties + }; + let too_high_minimum = { + let mut properties = proposal("Too high a minimum", END_DATE); + properties.insert( + "minimumVotingPower".to_string(), + Value::U32(max_voting_power + 1), + ); + properties + }; + let both_minimums = { + let mut properties = proposal("Both minimums", END_DATE); + properties.insert( + "minimumShare".to_string(), + Value::Map(vec![ + (Value::Text("numerator".to_string()), Value::U8(1)), + (Value::Text("denominator".to_string()), Value::U8(3)), + ( + Value::Text("rounding".to_string()), + Value::Text("up".to_string()), + ), + ]), + ); + properties + }; + let no_minimum = { + let mut properties = proposal("No minimum", END_DATE); + properties.remove("minimumVotingPower"); + properties + }; + for properties in [ + below_majority, + unanimity, + too_high_minimum, + both_minimums, + no_minimum, + ] { + let title = properties["title"].clone(); + let (transition, document_id) = proposal_transition( + &contract, + &mut proposer, + properties, + None, + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, transition, 0, platform_version); + assert_matches!( + error, + ConsensusError::BasicError(BasicError::InvalidDecisionProposalRuleError(error)) + if error.document_id() == document_id, + "{title:?} is refused" + ); + } + assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); + + // At the limits: a simple majority, the largest fixed minimum. + let at_the_limits = { + let mut properties = proposal("At the limits", END_DATE); + properties.insert( + "minimumVotingPower".to_string(), + Value::U32(max_voting_power), + ); + properties + }; + let (transition, _) = proposal_transition( + &contract, + &mut proposer, + at_the_limits, + None, + &mut rng, + platform_version, + ) + .await; + process_valid(&platform, transition, 0, platform_version); +} + +#[tokio::test] +async fn should_hold_a_proposal_to_an_end_date_one_to_four_weeks_away() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let contract = decision_proposals_contract(&platform, platform_version); + let mut rng = StdRng::seed_from_u64(0xDEC1_5107); + let mut proposer = proposer(&mut platform, 4); + let block_time = 1_000; + + // The mempool refuses one ending far too soon, judged against the last block + let (transition, _) = proposal_transition( + &contract, + &mut proposer, + proposal("Far too soon", block_time + 1), + None, + &mut rng, + platform_version, + ) + .await; + assert_eq!( + check_tx_verdicts(&platform, &transition)[0].1, + Ok(false), + "check_tx refuses a proposal ending far too soon" + ); + + for end_date in [block_time + WEEK_MS - 1, block_time + 4 * WEEK_MS + 1] { + let (transition, document_id) = proposal_transition( + &contract, + &mut proposer, + proposal("Out of range", end_date), + None, + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, transition, block_time, platform_version); + assert_matches!( + error, + ConsensusError::StateError(StateError::DecisionProposalEndDateOutOfRangeError(error)) + if error.document_id() == document_id + && error.end_date() == end_date + && error.earliest_end_date() == block_time + WEEK_MS + && error.latest_end_date() == block_time + 4 * WEEK_MS, + "a proposal ending at {end_date} is refused" + ); + } + assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); + + for end_date in [block_time + WEEK_MS, block_time + 4 * WEEK_MS] { + let (transition, _) = proposal_transition( + &contract, + &mut proposer, + proposal("In range", end_date), + None, + &mut rng, + platform_version, + ) + .await; + process_valid(&platform, transition, block_time, platform_version); + } + let end_dates = vote_poll_end_dates(&platform.drive, platform_version); + assert_eq!( + end_dates.keys().copied().collect::>(), + vec![block_time + WEEK_MS, block_time + 4 * WEEK_MS] + ); +} + +#[tokio::test] +async fn should_open_one_poll_per_proposal_even_for_the_same_question() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let contract = decision_proposals_contract(&platform, platform_version); + let mut rng = StdRng::seed_from_u64(0xDEC1_5108); + let mut first = proposer(&mut platform, 5); + let mut second = proposer(&mut platform, 6); + + let properties = proposal("Raise the limit?", END_DATE); + let mut polls = vec![]; + for proposer in [&mut first, &mut second] { + let (transition, document_id) = proposal_transition( + &contract, + proposer, + properties.clone(), + None, + &mut rng, + platform_version, + ) + .await; + process_valid(&platform, transition, 0, platform_version); + polls.push(proposal_poll(document_id, &properties, platform_version)); + } + assert_ne!( + polls[0].unique_id(platform_version).expect("id"), + polls[1].unique_id(platform_version).expect("id") + ); + for vote_poll in &polls { + assert!(poll_state(&platform, vote_poll, platform_version) + .stored_info + .is_some()); + } +} + +#[tokio::test] +async fn should_record_the_result_of_a_proposal_voted_on_and_keep_the_proposal() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let contract = decision_proposals_contract(&platform, platform_version); + let mut rng = StdRng::seed_from_u64(0xDEC1_5109); + let mut proposer = proposer(&mut platform, 7); + + let properties = proposal("Raise the limit?", END_DATE); + let (transition, document_id) = proposal_transition( + &contract, + &mut proposer, + properties.clone(), + None, + &mut rng, + platform_version, + ) + .await; + process_valid(&platform, transition, 0, platform_version); + let vote_poll = proposal_poll(document_id, &properties, platform_version); + + // Two evonodes vote yes (4 each) and a masternode votes no (1): 8 of 9 is more than half, + // and 9 reaches the minimum of 5. + for (seed, evonode, vote_choice) in [ + (700, true, YesNoAbstainVoteChoice::Yes), + (701, true, YesNoAbstainVoteChoice::Yes), + (702, false, YesNoAbstainVoteChoice::No), + ] { + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, seed, evonode, platform_version); + perform_yes_no_vote( + &mut platform, + &vote_poll, + vote_choice, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect("expected the vote to be accepted"); + } + let fund = decision_proposal_required_fund(platform_version); + let vote_cost = platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_single_vote_cost; + assert_eq!( + prefunded_balance(&platform, &vote_poll, platform_version), + Some(fund - 3 * vote_cost) + ); + let processing_credits_before = processing_credits(&platform, platform_version); + + close_ended_polls(&mut platform, platform_version); + + let result = finished_result(&platform, &vote_poll, platform_version); + assert!(result.passed); + assert_eq!( + ( + result.yes_voting_power, + result.no_voting_power, + result.required_voting_power + ), + (8, 1, 5) + ); + // What the votes left of the fund goes to processing fees + assert_eq!( + prefunded_balance(&platform, &vote_poll, platform_version), + None + ); + assert_eq!( + processing_credits(&platform, platform_version) - processing_credits_before, + fund - 3 * vote_cost + ); + assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); + // The proposal stays, and the queries prove the result. + assert!(stored_proposal(&platform, &contract, document_id).is_some()); + let state = queried_poll_state( + &platform, + vote_poll.unique_id(platform_version).expect("id"), + platform_version, + ); + assert_matches!( + state.status, + Some(state_query_response::yes_no_vote_poll_state::Status::Finished(finished)) + if finished.passed + ); +} diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs index c8bb79eb43a..5644506d861 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs @@ -149,6 +149,7 @@ mod tests { moderated: false, consumed_documents: Vec::new(), derived_index_values: None, + decision_proposal: None, }) } diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/mod.rs index b4ec2281fe0..b5cf0f864cc 100644 --- a/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/mod.rs +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/mod.rs @@ -8,7 +8,9 @@ use dpp::block::block_info::BlockInfo; use dpp::prelude::TimestampMillis; use dpp::version::PlatformVersion; use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; -use grovedb::TransactionArg; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; impl Drive { /// Opens a yes/no vote poll that takes votes until `end_date`: its tree with the stored @@ -16,9 +18,9 @@ impl Drive { /// balance the votes are paid from is the caller's to fund, under the poll's /// `specialized_balance_id`. /// - /// Refuses parameters no poll can run with, an end date that is not after the opening block - /// or is further from it than `max_yes_no_vote_poll_duration_ms`, and a poll that already - /// exists. Not active before protocol version 15. + /// Refuses parameters no poll can run with, an end date outside the window the opening block + /// allows (`YesNoVotePoll::end_date_window`), and a poll that already exists. Not active + /// before protocol version 15. /// /// # Parameters /// - `vote_poll`: the poll to open. @@ -75,14 +77,17 @@ impl Drive { /// a contest, and two openings with no view of each other can list one poll under two end /// dates; it then closes at the first and its later entry is only removed. /// - /// They are stateful and have no estimated-costs path: a poll opens as a system action, - /// outside any fee estimate. A caller that runs them inside a fee estimate must first give - /// them one. + /// With `estimated_costs_only_with_layer_info` set, as a fee estimate runs them, they read no + /// state: every tree is inserted without checking it exists, and the poll is taken not to + /// exist yet, which the create of the decision proposal opening it guarantees (a document + /// id is never reused). /// /// # Parameters /// - `vote_poll`: the poll to open. /// - `end_date`: when the poll closes, in milliseconds. /// - `block_info`: the block that opens it. + /// - `estimated_costs_only_with_layer_info`: set to estimate the costs without reading + /// state, with the layers the operations touch added to it. /// - `previous_batch_operations`: the operations the batch already holds. /// - `batch_operations`: where the opening's operations are added. /// - `transaction`: the transaction to read in. @@ -96,6 +101,9 @@ impl Drive { vote_poll: &YesNoVotePoll, end_date: TimestampMillis, block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, previous_batch_operations: &mut Option<&mut Vec>, batch_operations: &mut Vec, transaction: TransactionArg, @@ -112,6 +120,7 @@ impl Drive { vote_poll, end_date, block_info, + estimated_costs_only_with_layer_info, previous_batch_operations, batch_operations, transaction, @@ -162,6 +171,12 @@ mod tests { use grovedb::batch::KeyInfoPath; use grovedb::MaybeTree; + /// An end date the window of a block at time 0 allows (`BlockInfo::default()`): two weeks + /// after it. + const END_DATE: u64 = 1_209_600_000; + /// A later end date that window allows. + const LATER_END_DATE: u64 = END_DATE + 1_000; + #[test] fn should_refuse_to_open_a_poll_a_batch_already_opens() { let drive = setup_drive_with_initial_state_structure(None); @@ -171,9 +186,10 @@ mod tests { drive .open_yes_no_vote_poll_operations( &vote_poll, - 1_000, + END_DATE, &BlockInfo::default(), &mut None, + &mut None, &mut batch_operations, None, platform_version, @@ -184,8 +200,9 @@ mod tests { drive .open_yes_no_vote_poll_operations( &vote_poll, - 2_000, + LATER_END_DATE, &BlockInfo::default(), + &mut None, &mut Some(&mut batch_operations), &mut vec![], None, @@ -201,7 +218,7 @@ mod tests { drive .open_yes_no_vote_poll( &poll(), - 1_000, + END_DATE, &BlockInfo::default(), None, platform_version, @@ -217,7 +234,7 @@ mod tests { drive .open_yes_no_vote_poll( &simple_majority, - 2_000, + LATER_END_DATE, &BlockInfo::default(), None, platform_version, @@ -236,7 +253,7 @@ mod tests { drive .open_yes_no_vote_poll( &test_yes_no_vote_poll_for_document([0xc2; 32]), - 1_000, + END_DATE, &BlockInfo::default(), None, platform_version, @@ -269,7 +286,7 @@ mod tests { drive .open_yes_no_vote_poll( &vote_poll, - 1_000, + END_DATE, &BlockInfo::default(), None, platform_version, @@ -295,7 +312,7 @@ mod tests { drive .open_yes_no_vote_poll( &poll(), - 1_000, + END_DATE, &BlockInfo::default(), None, platform_version, @@ -395,7 +412,7 @@ mod tests { ); not_active( drive.remove_vote_poll_end_date_query_operations( - &[(poll_id, 1_000)], + &[(poll_id, END_DATE)], &mut vec![], None, platform_version, @@ -464,32 +481,42 @@ mod tests { } #[test] - fn should_refuse_a_poll_that_ends_by_the_block_that_opens_it() { + fn should_refuse_a_poll_that_ends_outside_one_to_four_weeks_after_the_block_that_opens_it() { let drive = setup_drive_with_initial_state_structure(None); let platform_version = PlatformVersion::latest(); let block_info = BlockInfo { time_ms: 1_000, ..Default::default() }; + let min_duration = platform_version + .system_limits + .min_yes_no_vote_poll_duration_ms; let max_duration = platform_version .system_limits .max_yes_no_vote_poll_duration_ms; - // One that runs too long is refused too; the last two would also sort before every - // block time in the end date index. + // One that ends at once, too soon or too late is refused; the last two would also sort + // before every block time in the end date index. for end_date in [ 999, 1_000, + 1_000 + min_duration - 1, 1_000 + max_duration + 1, i64::MAX as u64 + 1, u64::MAX, ] { drive .open_yes_no_vote_poll(&poll(), end_date, &block_info, None, platform_version) - .expect_err("expected a poll that ends at once or too late to be refused"); + .expect_err("expected a poll ending outside its window to be refused"); } drive - .open_yes_no_vote_poll(&poll(), 1_001, &block_info, None, platform_version) - .expect("expected a poll ending after the opening block to open"); + .open_yes_no_vote_poll( + &poll(), + 1_000 + min_duration, + &block_info, + None, + platform_version, + ) + .expect("expected a poll running exactly the shortest duration to open"); let longest_poll = test_yes_no_vote_poll_for_document([0xd0; 32]); drive .open_yes_no_vote_poll( @@ -518,8 +545,9 @@ mod tests { drive .open_yes_no_vote_poll_operations( &vote_poll, - 1_000, + END_DATE, &BlockInfo::default(), + &mut None, &mut Some(&mut batch_operations), &mut opening_operations, None, @@ -540,7 +568,7 @@ mod tests { let end_dates = vote_poll_end_dates(&drive, platform_version); assert_eq!(end_dates.len(), 1); assert_eq!( - end_dates[&1_000], + end_dates[&END_DATE], [ poll().unique_id(platform_version).expect("id"), other_poll.unique_id(platform_version).expect("id") @@ -564,7 +592,7 @@ mod tests { .add_vote_poll_end_date_query_operations( Some([7; 32]), contested_poll.clone(), - 1_000, + END_DATE, &BlockInfo::default(), &mut None, &mut None, @@ -577,8 +605,9 @@ mod tests { drive .open_yes_no_vote_poll_operations( &poll(), - 1_000, + END_DATE, &BlockInfo::default(), + &mut None, &mut Some(&mut batch_operations), &mut opening_operations, None, @@ -598,7 +627,7 @@ mod tests { let end_dates = vote_poll_end_dates(&drive, platform_version); assert_eq!( - end_dates[&1_000], + end_dates[&END_DATE], [ contested_poll.unique_id(platform_version).expect("id"), poll().unique_id(platform_version).expect("id") @@ -610,7 +639,7 @@ mod tests { .grove .get( &vote_end_date_queries_tree_path(), - encode_u64(1_000).as_slice(), + encode_u64(END_DATE).as_slice(), None, &platform_version.drive.grove_version, ) @@ -635,7 +664,7 @@ mod tests { .add_vote_poll_end_date_query_operations( Some([7; 32]), contested_poll.clone(), - 1_000, + END_DATE, &BlockInfo::default(), &mut None, &mut None, @@ -658,7 +687,7 @@ mod tests { drive .batch_delete_up_tree_while_empty( KeyInfoPath::from_known_owned_path( - vote_contested_resource_end_date_queries_at_time_tree_path_vec(1_000), + vote_contested_resource_end_date_queries_at_time_tree_path_vec(END_DATE), ), contested_poll .unique_id(platform_version) @@ -678,8 +707,9 @@ mod tests { drive .open_yes_no_vote_poll_operations( &poll(), - 1_000, + END_DATE, &BlockInfo::default(), + &mut None, &mut Some(&mut batch_operations), &mut opening_operations, None, @@ -699,7 +729,7 @@ mod tests { let end_dates = vote_poll_end_dates(&drive, platform_version); assert_eq!( - end_dates[&1_000], + end_dates[&END_DATE], [poll().unique_id(platform_version).expect("id")] .into_iter() .collect() @@ -720,9 +750,10 @@ mod tests { drive .open_yes_no_vote_poll_operations( &poll(), - 1_000, + END_DATE, &BlockInfo::default(), &mut None, + &mut None, &mut batch_operations, None, platform_version, @@ -733,7 +764,7 @@ mod tests { .add_vote_poll_end_date_query_operations( Some([7; 32]), contested_poll.clone(), - 1_000, + END_DATE, &BlockInfo::default(), &mut None, &mut Some(&mut batch_operations), @@ -755,7 +786,7 @@ mod tests { let end_dates = vote_poll_end_dates(&drive, platform_version); assert_eq!( - end_dates[&1_000], + end_dates[&END_DATE], [ contested_poll.unique_id(platform_version).expect("id"), poll().unique_id(platform_version).expect("id") diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/v0/mod.rs index 4a80d28f163..6caca88c093 100644 --- a/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/v0/mod.rs +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/v0/mod.rs @@ -1,5 +1,6 @@ use crate::drive::votes::paths::{ - vote_decisions_active_polls_tree_path_vec, vote_decisions_tree_path_vec, YesNoVotePollPaths, + vote_decisions_active_polls_tree_path, vote_decisions_active_polls_tree_path_vec, + vote_decisions_tree_path, vote_decisions_tree_path_vec, vote_root_path, YesNoVotePollPaths, VOTE_DECISIONS_SUBTREE_KEYS, }; use crate::drive::votes::YesNoAbstainVoteChoiceToKeyTrait; @@ -8,6 +9,7 @@ use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::util::grove_operations::BatchInsertTreeApplyType; use crate::util::object_size_info::{DriveKeyInfo, PathKeyInfo}; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; use dpp::block::block_info::BlockInfo; use dpp::prelude::TimestampMillis; use dpp::version::PlatformVersion; @@ -16,7 +18,16 @@ use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollS use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; use dpp::voting::vote_polls::VotePoll; use dpp::ProtocolError; -use grovedb::{TransactionArg, TreeType}; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::ApproximateElements; +use grovedb::EstimatedLayerSizes::{AllSubtrees, Mix}; +use grovedb::EstimatedSumTrees::{AllSumTrees, NoSumTrees}; +use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; +use std::collections::HashMap; + +/// What a yes/no poll's serialized stored info takes, about: the poll with its rule, and its +/// status once it is finished, with two blocks. +const ESTIMATED_YES_NO_VOTE_POLL_STORED_INFO_SIZE: u32 = 160; impl Drive { pub(super) fn open_yes_no_vote_poll_v0( @@ -33,6 +44,7 @@ impl Drive { end_date, block_info, &mut None, + &mut None, &mut batch_operations, transaction, platform_version, @@ -52,37 +64,87 @@ impl Drive { vote_poll: &YesNoVotePoll, end_date: TimestampMillis, block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, previous_batch_operations: &mut Option<&mut Vec>, batch_operations: &mut Vec, transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(), Error> { vote_poll.validate_parameters(platform_version)?; - // A poll that ends in the block that opens it, or earlier, would close at the end of - // this block with no vote cast. One that runs longer than the system limit would hold - // its fund and trees for as long. The limit also keeps the end date below `i64::MAX`, - // past which the end date index (which flips its top bit, `encode_u64`) would sort it - // before every block time. - let max_duration = platform_version - .system_limits - .max_yes_no_vote_poll_duration_ms; - if end_date <= block_info.time_ms - || end_date - block_info.time_ms > max_duration - || end_date > i64::MAX as TimestampMillis - { + // The end date must be in the window the opening block allows; validation of what opens + // the poll refused any other with a consensus error before. + let end_date_window = YesNoVotePoll::end_date_window(block_info.time_ms, platform_version)?; + if !end_date_window.contains(&end_date) { return Err(Error::Protocol(Box::new(ProtocolError::VoteError( format!( - "yes/no vote poll {} must end after the block that opens it (at {}), at \ - most {} ms later and at most at {}, not at {}", + "yes/no vote poll {} opened at {} must end from {} to {}, not at {}", vote_poll, block_info.time_ms, - max_duration, - i64::MAX, + end_date_window.start(), + end_date_window.end(), end_date ), )))); } let poll_id = vote_poll.unique_id(platform_version)?; + let poll_path = vote_poll.poll_path_vec(platform_version)?; + + // Estimating, nothing is read: every tree is inserted as new. + let tree_apply_type = if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + // The votes tree holds the contested resources, the decisions and the end dates. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(vote_root_path()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(3), + estimated_layer_sizes: AllSubtrees(1, NoSumTrees, None), + }, + ); + // The decisions tree holds the active polls and the identity votes. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(vote_decisions_tree_path()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(2), + estimated_layer_sizes: AllSubtrees(1, NoSumTrees, None), + }, + ); + // The active polls, a tree per poll under its id. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(vote_decisions_active_polls_tree_path()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(100), + estimated_layer_sizes: AllSubtrees(DEFAULT_HASH_SIZE_U8, NoSumTrees, None), + }, + ); + // The poll's tree: its stored info and its three vote sum trees. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(poll_path.clone()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(4), + estimated_layer_sizes: Mix { + subtrees_size: Some((1, AllSumTrees, None, 3)), + items_size: Some((1, ESTIMATED_YES_NO_VOTE_POLL_STORED_INFO_SIZE, None, 1)), + items_with_sum_item_size: None, + references_size: None, + references_with_sum_item_size: None, + }, + }, + ); + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type: TreeType::NormalTree, + tree_type: TreeType::NormalTree, + flags_len: 0, + } + } else { + BatchInsertTreeApplyType::StatefulBatchInsertTree + }; // The decisions trees, when the chain lacks them: genesis and the upgrade to protocol // version 15 create them, but a chain that reached 15 before they existed did not. An @@ -95,7 +157,7 @@ impl Drive { )), TreeType::NormalTree, None, - BatchInsertTreeApplyType::StatefulBatchInsertTree, + tree_apply_type, transaction, previous_batch_operations, batch_operations, @@ -112,7 +174,7 @@ impl Drive { )), TreeType::NormalTree, None, - BatchInsertTreeApplyType::StatefulBatchInsertTree, + tree_apply_type, transaction, previous_batch_operations, batch_operations, @@ -136,7 +198,6 @@ impl Drive { batch_operations.append(&mut stored_info_operations); // One sum tree per choice; the sums are the tallies. - let poll_path = vote_poll.poll_path_vec(platform_version)?; for vote_choice in YesNoAbstainVoteChoice::ALL { self.batch_insert_empty_sum_tree( poll_path.iter().map(|segment| segment.as_slice()), @@ -158,7 +219,7 @@ impl Drive { VotePoll::YesNoVotePoll(vote_poll.clone()), end_date, block_info, - &mut None, + estimated_costs_only_with_layer_info, previous_batch_operations, batch_operations, transaction, diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/document/document_create_transition/v1.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/document/document_create_transition/v1.rs index a89fcd9c750..420e41bb7bf 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/document/document_create_transition/v1.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/batch/document/document_create_transition/v1.rs @@ -1,13 +1,17 @@ use crate::drive::document::derived_index_values::set_derived_index_values; +use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; use crate::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{ DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0, DocumentFromCreateTransitionAction, }; -use crate::util::batch::drive_op_batch::PrefundedSpecializedBalanceOperationType; +use crate::util::batch::drive_op_batch::{ + PrefundedSpecializedBalanceOperationType, VotePollOperationType, +}; use crate::util::batch::DriveOperation::{ DocumentOperation, IdentityOperation, PrefundedSpecializedBalanceOperation, + VotePollOperation, }; use crate::util::batch::{DocumentOperationType, DriveOperation, IdentityOperationType}; use crate::util::object_size_info::DataContractInfo::DataContractFetchInfo; @@ -15,6 +19,7 @@ use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; use crate::util::object_size_info::{DocumentTypeInfo, OwnedDocumentInfo}; use crate::util::storage_flags::StorageFlags; use dpp::block::epoch::Epoch; +use dpp::decision_proposal::DecisionProposalVotePoll; use dpp::document::Document; use dpp::prelude::Identifier; use dpp::version::PlatformVersion; @@ -24,6 +29,11 @@ use std::borrow::Cow; /// document the create consumes is deleted from what document reference validation 1 read of /// it (`ForceDeleteReadDocument`, `AddDocumentAndDeleteReadConsumed`), without reading it /// again, where generation 0 deletes it by its id and reads it once more. +/// +/// A decision proposal (its contract exists from protocol version 15) also opens the yes/no +/// poll it states: the owner's stated fund moves to the poll's prefunded balance, as a +/// contest's does, and the poll is opened after the document. The poll is read from the +/// proposal here, as check_tx's fee estimate converts a create no state validation has seen. pub(super) fn into_high_level_batch_drive_operations_v1<'b>( mut action: DocumentCreateTransitionAction, epoch: &Epoch, @@ -55,6 +65,30 @@ pub(super) fn into_high_level_batch_drive_operations_v1<'b>( // create of an earlier version. let derived_index_values = action.derived_index_values().cloned(); + // The poll a decision proposal opens, and the fund it pays in: structure validation 2 held + // the fund to the decision proposal fund and the rule to the system limits. + let decision_proposal_opening = action + .decision_proposal() + .map(|decision_proposal| { + let Some((_, fund)) = decision_proposal.stated_fund else { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a decision proposal reaches its conversion with the fund it states", + ))); + }; + let vote_poll = DecisionProposalVotePoll::from_proposal( + action.base().id(), + action.data(), + platform_version, + )?; + if !vote_poll.is_valid() { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a decision proposal reaches its conversion with a rule structure validation accepted", + ))); + } + Ok((vote_poll.into_data()?, fund)) + }) + .transpose()?; + let mut document = Document::try_from_owned_create_transition_action(action, owner_id, platform_version)?; if let Some(values) = derived_index_values { @@ -73,6 +107,25 @@ pub(super) fn into_high_level_batch_drive_operations_v1<'b>( ops.extend(document_creation_token_cost_operations); + if let Some((decision_proposal_vote_poll, fund)) = &decision_proposal_opening { + // The poll's votes are paid from its prefunded balance, under its id + ops.push(PrefundedSpecializedBalanceOperation( + PrefundedSpecializedBalanceOperationType::CreateNewPrefundedBalance { + prefunded_specialized_balance_id: decision_proposal_vote_poll + .vote_poll + .specialized_balance_id(platform_version)?, + add_balance: *fund, + }, + )); + // which the proposer pays + ops.push(IdentityOperation( + IdentityOperationType::RemoveFromIdentityBalance { + identity_id: owner_id.into_buffer(), + balance_to_remove: *fund, + }, + )); + } + if let Some((contested_document_resource_vote_poll, credits)) = maybe_prefunded_voting_balance { let prefunded_specialized_balance_id = contested_document_resource_vote_poll.specialized_balance_id()?; @@ -153,5 +206,15 @@ pub(super) fn into_high_level_batch_drive_operations_v1<'b>( )); } + if let Some((decision_proposal_vote_poll, _)) = decision_proposal_opening { + // The poll, with its stored info, its vote trees and its end date entry + ops.push(VotePollOperation( + VotePollOperationType::OpenYesNoVotePoll { + vote_poll: decision_proposal_vote_poll.vote_poll, + end_date: decision_proposal_vote_poll.end_date, + }, + )); + } + Ok(ops) } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs index 7d49a0d462b..cd2df2af739 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs @@ -151,6 +151,12 @@ impl DocumentCreateTransitionActionAccessorsV0 for DocumentCreateTransitionActio } } + fn decision_proposal(&self) -> Option<&DecisionProposalCreate> { + match self { + DocumentCreateTransitionAction::V0(v0) => v0.decision_proposal.as_deref(), + } + } + fn property_constraint_aggregates(&self) -> &BTreeMap { match self { DocumentCreateTransitionAction::V0(v0) => { diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs index e4631b42b0b..e25335ee705 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs @@ -68,6 +68,19 @@ pub struct DocumentCreateTransitionActionV0 { /// reading them again. `None` when the action is built and on a type declaring none, and /// boxed, since only such a create holds any and the action is one variant of a large enum. pub derived_index_values: Option>>, + /// Set for the create of a decision proposal (protocol version 15), a `proposal` of the + /// decision proposals contract, and `None` for any other create: the fund it states. Boxed, + /// since only such a create holds one. + pub decision_proposal: Option>, +} + +/// What a decision proposal's create carries beside its document (protocol version 15). +#[derive(Debug, Clone, PartialEq)] +pub struct DecisionProposalCreate { + /// The fund the create states, as the name and the amount of its prefunded voting + /// balance; `None` when it states none. Structure validation requires exactly the decision + /// proposal fund under the proposal's name. + pub stated_fund: Option<(String, Credits)>, } /// A document of the create's own contract that the create deletes because it revealed it: @@ -172,6 +185,10 @@ pub trait DocumentCreateTransitionActionAccessorsV0 { /// Records the values of the document type's derived index properties, once the /// reference validation has fetched the documents they are read from fn set_derived_index_values(&mut self, values: BTreeMap); + + /// What a decision proposal's create carries beside its document; `None` for any other + /// create + fn decision_proposal(&self) -> Option<&DecisionProposalCreate>; } /// documents from create transition v0 diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs index 882d706dede..a71ab6e0c41 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs @@ -7,6 +7,7 @@ use grovedb::TransactionArg; use std::sync::Arc; use dpp::prelude::{ConsensusValidationResult, UserFeeIncrease}; use dpp::ProtocolError; +use dpp::decision_proposal::is_decision_proposal; use dpp::state_transition::batch_transition::document_create_transition::v0::DocumentCreateTransitionV0; use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; @@ -18,7 +19,7 @@ use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::BatchedTransitionAction; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionAccessorsV0}; -use crate::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionActionV0; +use crate::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DecisionProposalCreate, DocumentCreateTransitionActionV0}; use crate::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; @@ -89,8 +90,19 @@ impl DocumentCreateTransitionActionV0 { let document_type_indexes = document_type.indexes(); + // A decision proposal (protocol version 15; its contract does not exist before it) + // states the decision proposal fund, which names its own type rather than a contested + // index: it is kept as stated for validation to judge, and opens no contest. + let decision_proposal = + is_decision_proposal(&base.data_contract_id(), base.document_type_name()).then(|| { + Box::new(DecisionProposalCreate { + stated_fund: prefunded_voting_balance.clone(), + }) + }); + let prefunded_voting_balances_by_vote_poll = prefunded_voting_balance .as_ref() + .filter(|_| decision_proposal.is_none()) .map(|(index_name, credits)| { let index = document_type_indexes.get(index_name).ok_or( ProtocolError::UnknownContestedIndexResolution(format!( @@ -168,6 +180,7 @@ impl DocumentCreateTransitionActionV0 { moderated: false, consumed_documents: Vec::new(), derived_index_values: None, + decision_proposal, } .into(), )) diff --git a/packages/rs-drive/src/state_transition_action/batch/tests.rs b/packages/rs-drive/src/state_transition_action/batch/tests.rs index 144bbad17a8..5137621e45e 100644 --- a/packages/rs-drive/src/state_transition_action/batch/tests.rs +++ b/packages/rs-drive/src/state_transition_action/batch/tests.rs @@ -259,6 +259,7 @@ fn make_create_v0() -> DocumentCreateTransitionActionV0 { moderated: false, consumed_documents: Vec::new(), derived_index_values: None, + decision_proposal: None, } } @@ -2964,6 +2965,7 @@ fn stamp_test_create_action(protocol_version: u32) -> DocumentCreateTransitionAc moderated: false, consumed_documents: Vec::new(), derived_index_values: None, + decision_proposal: None, }) } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index 02097715048..09aec9d423a 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -12,6 +12,7 @@ mod prefunded_specialized_balance; mod shielded; mod system; mod token; +mod vote_poll; mod withdrawals; use crate::util::batch::GroveDbOpBatch; @@ -39,6 +40,7 @@ pub use prefunded_specialized_balance::PrefundedSpecializedBalanceOperationType; pub use shielded::ShieldedPoolOperationType; pub use system::SystemOperationType; pub use token::TokenOperationType; +pub use vote_poll::VotePollOperationType; pub use withdrawals::WithdrawalOperationType; use grovedb::{EstimatedLayerInformation, TransactionArg}; @@ -116,6 +118,8 @@ pub enum DriveOperation<'a> { AddressFundsOperation(AddressFundsOperationType), /// A shielded pool operation ShieldedPoolOperation(ShieldedPoolOperationType), + /// A vote poll operation: the opening of a yes/no vote poll + VotePollOperation(VotePollOperationType), /// A single low level groveDB operation GroveDBOperation(QualifiedGroveDbOp), /// Multiple low level groveDB operations @@ -196,6 +200,14 @@ impl DriveLowLevelOperationConverter for DriveOperation<'_> { platform_version, ) } + DriveOperation::VotePollOperation(vote_poll_operation_type) => vote_poll_operation_type + .into_low_level_drive_operations( + drive, + estimated_costs_only_with_layer_info, + block_info, + transaction, + platform_version, + ), DriveOperation::GroveDBOperation(op) => Ok(vec![GroveOperation(op)]), DriveOperation::GroveDBOpBatch(operations) => Ok(operations .operations diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/vote_poll.rs b/packages/rs-drive/src/util/batch/drive_op_batch/vote_poll.rs new file mode 100644 index 00000000000..e0280497530 --- /dev/null +++ b/packages/rs-drive/src/util/batch/drive_op_batch/vote_poll.rs @@ -0,0 +1,58 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::batch::drive_op_batch::DriveLowLevelOperationConverter; +use dpp::block::block_info::BlockInfo; +use dpp::prelude::TimestampMillis; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use platform_version::version::PlatformVersion; +use std::collections::HashMap; + +/// Operations on vote polls +#[derive(Clone, Debug)] +pub enum VotePollOperationType { + /// Opens a yes/no vote poll that takes votes until `end_date` (protocol version 15), as + /// [`Drive::open_yes_no_vote_poll_operations`] does. The poll's fund is credited by a + /// prefunded balance operation of its own. + OpenYesNoVotePoll { + /// The poll to open + vote_poll: YesNoVotePoll, + /// When the poll closes, in milliseconds + end_date: TimestampMillis, + }, +} + +impl DriveLowLevelOperationConverter for VotePollOperationType { + fn into_low_level_drive_operations( + self, + drive: &Drive, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match self { + VotePollOperationType::OpenYesNoVotePoll { + vote_poll, + end_date, + } => { + let mut batch_operations = vec![]; + drive.open_yes_no_vote_poll_operations( + &vote_poll, + end_date, + block_info, + estimated_costs_only_with_layer_info, + &mut None, + &mut batch_operations, + transaction, + platform_version, + )?; + Ok(batch_operations) + } + } + } +} diff --git a/packages/rs-drive/src/util/test_helpers/mod.rs b/packages/rs-drive/src/util/test_helpers/mod.rs index d5d214144a1..80f379bef5c 100644 --- a/packages/rs-drive/src/util/test_helpers/mod.rs +++ b/packages/rs-drive/src/util/test_helpers/mod.rs @@ -272,8 +272,9 @@ pub(crate) fn test_yes_no_vote_poll_for_document(document_id: [u8; 32]) -> YesNo } #[cfg(all(test, feature = "server"))] -/// Opens `vote_poll` to end at 1_000, written straight to Drive at the default block with no -/// validation, and registers each `(voter, strength, choice)` vote on it as a first vote. +/// Opens `vote_poll` to end two weeks after the default block (at 1_209_600_000), written +/// straight to Drive with no validation, and registers each `(voter, strength, choice)` vote on +/// it as a first vote. pub(crate) fn open_yes_no_vote_poll_with_votes( drive: &Drive, vote_poll: &YesNoVotePoll, @@ -283,7 +284,7 @@ pub(crate) fn open_yes_no_vote_poll_with_votes( drive .open_yes_no_vote_poll( vote_poll, - 1_000, + 1_209_600_000, &BlockInfo::default(), None, platform_version, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs index fef1a6aab79..a548fc4b4e1 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs @@ -22,4 +22,8 @@ pub struct DPPVotingVersions { /// (`YesNoVotePoll::required_voting_power` and `YesNoVotePoll::passes`). `None` (not /// active) before protocol version 15. pub yes_no_vote_poll_decision: OptionalFeatureVersion, + /// How a decision proposal's yes/no poll is read from the proposal, and its fund and end + /// date judged (`decision_proposal::DecisionProposal`). `None` (not active) before protocol + /// version 15. + pub decision_proposal_vote_poll: OptionalFeatureVersion, } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs index 6daa84e921c..870e20c0348 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs @@ -8,4 +8,5 @@ pub const VOTING_VERSION_V1: DPPVotingVersions = DPPVotingVersions { yes_no_vote_poll_id: None, yes_no_vote_poll_parameters: None, yes_no_vote_poll_decision: None, + decision_proposal_vote_poll: None, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs index 86931cd3d8e..d0b6d28aad3 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs @@ -8,4 +8,5 @@ pub const VOTING_VERSION_V2: DPPVotingVersions = DPPVotingVersions { yes_no_vote_poll_id: None, yes_no_vote_poll_parameters: None, yes_no_vote_poll_decision: None, + decision_proposal_vote_poll: None, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v3.rs index 9eaaf9c6353..5bd1d9a5021 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v3.rs @@ -2,12 +2,14 @@ use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; use crate::version::dpp_versions::dpp_voting_versions::DPPVotingVersions; /// Voting versions 3. Introduced in protocol version 15 with the yes/no poll kind: its stored -/// info, id, parameter rules and decision rules are `Some(0)` (`None`, not active, in every -/// earlier table). Everything else matches `VOTING_VERSION_V2`. +/// info, id, parameter rules and decision rules, and the decision proposal that opens one, are +/// `Some(0)` (`None`, not active, in every earlier table). Everything else matches +/// `VOTING_VERSION_V2`. pub const VOTING_VERSION_V3: DPPVotingVersions = DPPVotingVersions { yes_no_vote_poll_stored_info_version: Some(0), yes_no_vote_poll_id: Some(0), yes_no_vote_poll_parameters: Some(0), yes_no_vote_poll_decision: Some(0), + decision_proposal_vote_poll: Some(0), ..VOTING_VERSION_V2 }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs index 42c89fae102..be36896eedc 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs @@ -25,6 +25,11 @@ use super::{ /// at their widest, a bound on what a block measures once it restores the stored contract's /// creation fields. It reads no state, so it covers an update to a contract created earlier in /// the same block too. Mempool admission only: a block runs v0's state validation. +/// - document create `structure_validation` 2: v1, then a create of the decision proposals +/// contract's `proposal` (protocol version 15) must state exactly the decision proposal fund +/// under the proposal's name, its rule must make a yes/no poll within the system limits, and +/// it must end one to four weeks after the block. The create conversion 1 opens and funds its +/// poll. /// - `document_reference_validation` 1: v0, but the `refersTo` lookup of a reference declaring /// `consume` reads the commitment it finds with its stored element's storage flags /// (`query_documents_with_flags`) and keeps that read in the `ConsumedDocument`, so the create @@ -56,6 +61,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { advanced_structure: 2, // a tradeable token must be transferable, checked in check_tx too document_reference_validation: 1, // a consumed commitment keeps what its lookup read + document_create_transition_structure_validation: 2, // a decision proposal states its fund, a rule within the limits and an end date one to four weeks away data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { triggers: DriveAbciValidationDataTriggerVersions { create_domain_data_trigger: 2, diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 453d84d8d44..af53b2bbd09 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -66,8 +66,8 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_yes_no_vote_poll_minimum_voting_power: 2000, max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks - min_yes_no_vote_poll_duration_ms: 604_800_000, // one week - min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority + min_yes_no_vote_poll_duration_ms: 604_800_000, // one week + min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority yes_no_vote_poll_masternode_vote_strength: 1, yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index a4a18ac05cf..5caf3a95d55 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -47,8 +47,8 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_yes_no_vote_poll_minimum_voting_power: 2000, max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks - min_yes_no_vote_poll_duration_ms: 604_800_000, // one week - min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority + min_yes_no_vote_poll_duration_ms: 604_800_000, // one week + min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority yes_no_vote_poll_masternode_vote_strength: 1, yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index a132f262cbb..62fe0cf5d86 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -49,8 +49,8 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_yes_no_vote_poll_minimum_voting_power: 2000, max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks - min_yes_no_vote_poll_duration_ms: 604_800_000, // one week - min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority + min_yes_no_vote_poll_duration_ms: 604_800_000, // one week + min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority yes_no_vote_poll_masternode_vote_strength: 1, yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 764c01b8c2e..d4d6a6c0393 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -170,8 +170,8 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_yes_no_vote_poll_minimum_voting_power: 2000, max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks - min_yes_no_vote_poll_duration_ms: 604_800_000, // one week - min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority + min_yes_no_vote_poll_duration_ms: 604_800_000, // one week + min_yes_no_vote_poll_supermajority_percent: 50, // a simple majority yes_no_vote_poll_masternode_vote_strength: 1, yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 22d1bed5857..ac20d2ff7a6 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -214,9 +214,9 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// 1 (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) looks up the previous answer, and /// `DRIVE_VERSION_V10` registers and sweeps the votes. Opening a yes/no poll /// (`Drive::open_yes_no_vote_poll`; every `yes_no` drive method is `Some(0)`) is active -/// from this version, for an end date at most `max_yes_no_vote_poll_duration_ms` -/// (four weeks, a `SystemLimits` field read only by the opener) after the -/// opening block, and its end date entry goes through +/// from this version, for an end date from `min_yes_no_vote_poll_duration_ms` (one week) +/// to `max_yes_no_vote_poll_duration_ms` (four weeks) after the opening block +/// (`YesNoVotePoll::end_date_window`), and its end date entry goes through /// `add_vote_poll_end_date_query_operations` 1, which adds only the entry when /// the batch already queues the end date's tree with other storage flags. /// Every other yes/no method is active from this version too and `None` @@ -225,8 +225,8 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// voting), removing finished polls from the end date index /// (`remove_vote_poll_end_date_query_operations`) and the proof verifiers /// (`DRIVE_VERIFY_METHOD_VERSIONS_V4`). -/// Nothing calls the opener yet, so at this version every yes/no vote is -/// refused for want of a poll. +/// A poll's pass threshold is at least `min_yes_no_vote_poll_supermajority_percent` (50, +/// a simple majority) of yes plus no. Decision proposals (note 12) open the polls. /// /// 11. **A consume deletes what its lookup read**: document reference validation 1 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) reads the commitment that the `refersTo` lookup of @@ -240,13 +240,31 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// A consume costs one document read less: a DPNS v3 name create's processing fee drops by /// 10,660 credits. Storage fees and refunds are unchanged. Protocol v14 and earlier keep /// document reference validation 0 and the create conversion 0. +/// +/// 12. **Decision proposals**: the decision proposals system contract +/// (`SystemDataContract::DecisionProposals`, `SYSTEM_DATA_CONTRACT_VERSIONS_V4`'s +/// `decision_proposals` 1) is registered at genesis from this version (`create_genesis_state` +/// 1, `>= 15`) and inserted by `transition_to_version_15`. Creating its `proposal` opens a +/// yes/no poll (note 10) whose purpose is the proposal and whose rule the proposal states: +/// the create states the decision proposal fund (`FEE_VERSION4`'s +/// `decision_proposal_fund_required_amount`, 0.5 Dash) as its prefunded voting balance, +/// under the name `proposal`. Document create structure validation 2 +/// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuses any other fund, a rule outside the limits +/// (`DecisionProposalVotePoll::from_proposal`, `VOTING_VERSION_V3`'s +/// `decision_proposal_vote_poll`) and an end date outside one to four weeks after the block, +/// so check_tx refuses what a block would; the create conversion 1 reads the poll from the +/// proposal, +/// moves the fund from the proposer to the poll's prefunded balance and opens the poll +/// (`DriveOperation::VotePollOperation`, which estimates the opening's costs too, as +/// check_tx's fee estimate converts a create no state validation has seen). Signaling +/// only: nothing acts on a result. Protocol v14 and earlier have no such contract. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: the yes/no poll's tally, record, clean-up and finish hook are Some(0) (note 10); verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: identity update advanced_structure 1 refuses unpaid an identity update whose added key's proof of possession does not verify (note 2); create_domain_data_trigger 2 (note 3); contract update transform_into_action 1 holds an update to the compiled size cap in check_tx (note 8); batch advanced_structure 2 refuses a token config update on a read-only contract (note 9); masternode vote transform_into_action 1 looks up a masternode's previous answer on a yes/no poll (note 10); document_reference_validation 1 keeps what a consumed commitment's lookup read (note 11) + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: identity update advanced_structure 1 refuses unpaid an identity update whose added key's proof of possession does not verify (note 2); create_domain_data_trigger 2 (note 3); contract update transform_into_action 1 holds an update to the compiled size cap in check_tx (note 8); batch advanced_structure 2 refuses a token config update on a read-only contract (note 9); masternode vote transform_into_action 1 looks up a masternode's previous answer on a yes/no poll (note 10); document_reference_validation 1 keeps what a consumed commitment's lookup read (note 11); document create structure_validation 2 holds a decision proposal to its fund, rule and end date (note 12) withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, @@ -261,13 +279,13 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { contract_versions: CONTRACT_VERSIONS_V7, // changed: parse_property_constraints 1 lets countOf and sumOf match byte array keys (note 4); validate_document 2 checks documents natively (note 6); check_document_type_schema 1 checks schemas natively at registration (note 7); contract_storage_serialization_version 2 stores contracts compiled (note 8) document_versions: DOCUMENT_VERSIONS_V4, identity_versions: IDENTITY_VERSIONS_V1, - voting_versions: VOTING_VERSION_V3, // changed: the yes/no poll's stored info, id, parameter rules and decision rules are active (note 10) + voting_versions: VOTING_VERSION_V3, // changed: the yes/no poll's stored info, id, parameter rules and decision rules are active (note 10), and the decision proposal's poll (note 12) token_versions: TOKEN_VERSIONS_V3, asset_lock_versions: DPP_ASSET_LOCK_VERSIONS_V1, methods: DPP_METHOD_VERSIONS_V3, factory_versions: DPP_FACTORY_VERSIONS_V1, }, - system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V4, // changed: DPNS v3 checks a name's create with keywords (note 3) + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V4, // changed: DPNS v3 checks a name's create with keywords (note 3); the decision proposals contract (note 12) fee_version: FEE_VERSION4, // changed: decision proposals prefund their poll with 0.5 Dash (note 12) system_limits: SYSTEM_LIMITS_V5, // changed: max_compiled_contract_size caps a contract stored compiled at 1 MiB (note 8) consensus: ConsensusVersions { diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index ebeda1b906c..f6ef58f370a 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -193,6 +193,10 @@ use dpp::consensus::state::voting::vote_poll_not_available_for_voting_error::Vot use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; use dpp::consensus::state::voting::yes_no_vote_poll_not_available_for_voting_error::YesNoVotePollNotAvailableForVotingError; +use dpp::consensus::state::decision_proposal::DecisionProposalEndDateOutOfRangeError; +use dpp::consensus::basic::decision_proposal::{ + DecisionProposalFundMismatchError, InvalidDecisionProposalRuleError, +}; use crate::errors::consensus::basic::data_contract::{ DataContractErrorWasm, DataContractHaveNewUniqueIndexErrorWasm, @@ -812,6 +816,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::YesNoVotePollNotAvailableForVotingError(e) => { generic_consensus_error!(YesNoVotePollNotAvailableForVotingError, e).into() } + StateError::DecisionProposalEndDateOutOfRangeError(e) => { + generic_consensus_error!(DecisionProposalEndDateOutOfRangeError, e).into() + } } } @@ -1431,6 +1438,12 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::DataContractCompiledMaxSizeExceededError(e) => { generic_consensus_error!(DataContractCompiledMaxSizeExceededError, e).into() } + BasicError::DecisionProposalFundMismatchError(e) => { + generic_consensus_error!(DecisionProposalFundMismatchError, e).into() + } + BasicError::InvalidDecisionProposalRuleError(e) => { + generic_consensus_error!(InvalidDecisionProposalRuleError, e).into() + } } } From 9f9c93153a64057f38acf1cbd1e236db5ac55e7b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 14:05:26 +0300 Subject: [PATCH 3/7] feat(platform): decision proposals contract JS package, CI lists and docs The @dashevo/decision-proposals-contract workspace (package.json, system ids, README and a unit spec of the proposal schema), the contract in the root, Docker, codecov and CI package lists the moderation charters contract is in, its row in the book's system contracts table, the 0.5 Dash decision proposal fund in the book's contest funds table, and a protocol guide for decision proposals. Co-Authored-By: Claude Opus 5.5 --- .codecov.yml | 1 + .../package-filters/js-packages-direct.yml | 3 + .../js-packages-no-workflows.yml | 4 + .github/package-filters/js-packages.yml | 5 + .../package-filters/rs-packages-direct.yml | 5 + .../rs-packages-no-workflows.yml | 6 + .github/package-filters/rs-packages.yml | 7 + .../package-filters/test-suite-triggers.yml | 1 + .github/workflows/tests-rs-workspace.yml | 1 + .github/workflows/tests.yml | 1 + .pnp.cjs | 21 ++ Dockerfile | 5 + book/src/architecture/overview.md | 2 +- book/src/fees/overview.md | 25 +- docs/protocol/decision-proposals.md | 104 +++++++ package.json | 2 + .../decision-proposals-contract/README.md | 62 ++++ .../lib/systemIds.js | 4 + .../decision-proposals-contract/package.json | 27 ++ .../unit/decisionProposalsContract.spec.js | 286 ++++++++++++++++++ yarn.lock | 14 + 21 files changed, 576 insertions(+), 10 deletions(-) create mode 100644 docs/protocol/decision-proposals.md create mode 100644 packages/decision-proposals-contract/README.md create mode 100644 packages/decision-proposals-contract/lib/systemIds.js create mode 100644 packages/decision-proposals-contract/package.json create mode 100644 packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js diff --git a/.codecov.yml b/.codecov.yml index ca5bab92ed9..a648e2f4ac8 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -55,6 +55,7 @@ ignore: - "packages/keyword-search-contract/src/**" - "packages/app-connect-contract/src/**" - "packages/moderation-charters-contract/src/**" + - "packages/decision-proposals-contract/src/**" - "packages/masternode-reward-shares-contract/src/**" - "packages/token-history-contract/src/**" - "packages/wallet-utils-contract/src/**" diff --git a/.github/package-filters/js-packages-direct.yml b/.github/package-filters/js-packages-direct.yml index b170753427f..9b5ccfcbb8e 100644 --- a/.github/package-filters/js-packages-direct.yml +++ b/.github/package-filters/js-packages-direct.yml @@ -16,6 +16,9 @@ '@dashevo/moderation-charters-contract': - packages/moderation-charters-contract/** +'@dashevo/decision-proposals-contract': + - packages/decision-proposals-contract/** + '@dashevo/dashpay-contract': - packages/dashpay-contract/** diff --git a/.github/package-filters/js-packages-no-workflows.yml b/.github/package-filters/js-packages-no-workflows.yml index ac39c720953..77e36fe7755 100644 --- a/.github/package-filters/js-packages-no-workflows.yml +++ b/.github/package-filters/js-packages-no-workflows.yml @@ -16,6 +16,9 @@ '@dashevo/moderation-charters-contract': &moderation-charters-contract - packages/moderation-charters-contract/** +'@dashevo/decision-proposals-contract': &decision-proposals-contract + - packages/decision-proposals-contract/** + '@dashevo/dashpay-contract': &dashpay-contract - packages/dashpay-contract/** @@ -43,6 +46,7 @@ - *keyword-search-contract - *app-connect-contract - *moderation-charters-contract + - *decision-proposals-contract - packages/rs-platform-serialization/** - packages/rs-platform-serialization-derive/** - packages/rs-platform-value/** diff --git a/.github/package-filters/js-packages.yml b/.github/package-filters/js-packages.yml index 550e5633385..65bcfadfe1d 100644 --- a/.github/package-filters/js-packages.yml +++ b/.github/package-filters/js-packages.yml @@ -22,6 +22,10 @@ - .github/workflows/tests* - packages/moderation-charters-contract/** +'@dashevo/decision-proposals-contract': &decision-proposals-contract + - .github/workflows/tests* + - packages/decision-proposals-contract/** + '@dashevo/dashpay-contract': &dashpay-contract - .github/workflows/tests* - packages/dashpay-contract/** @@ -55,6 +59,7 @@ - *keyword-search-contract - *app-connect-contract - *moderation-charters-contract + - *decision-proposals-contract - packages/rs-platform-serialization/** - packages/rs-platform-serialization-derive/** - packages/rs-platform-value/** diff --git a/.github/package-filters/rs-packages-direct.yml b/.github/package-filters/rs-packages-direct.yml index 25201a06fe1..dceed7da6f7 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -28,6 +28,11 @@ moderation-charters-contract: - packages/moderation-charters-contract/schema/** - packages/moderation-charters-contract/Cargo.toml +decision-proposals-contract: + - packages/decision-proposals-contract/src/** + - packages/decision-proposals-contract/schema/** + - packages/decision-proposals-contract/Cargo.toml + dashpay-contract: - packages/dashpay-contract/src/** - packages/dashpay-contract/schema/** diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index 934d478c05a..5a696583b99 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -28,6 +28,11 @@ moderation-charters-contract: &moderation-charters-contract - packages/moderation-charters-contract/schema/** - packages/moderation-charters-contract/Cargo.toml +decision-proposals-contract: &decision-proposals-contract + - packages/decision-proposals-contract/src/** + - packages/decision-proposals-contract/schema/** + - packages/decision-proposals-contract/Cargo.toml + dashpay-contract: &dashpay-contract - packages/dashpay-contract/src/** - packages/dashpay-contract/schema/** @@ -63,6 +68,7 @@ data-contracts: &data-contracts - *document-history-contract - *app-connect-contract - *moderation-charters-contract + - *decision-proposals-contract dpp: &dpp - packages/rs-dpp/** diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 854e8c8bfdc..5228992762f 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -34,6 +34,12 @@ moderation-charters-contract: &moderation-charters-contract - packages/moderation-charters-contract/schema/** - packages/moderation-charters-contract/Cargo.toml +decision-proposals-contract: &decision-proposals-contract + - .github/workflows/tests* + - packages/decision-proposals-contract/src/** + - packages/decision-proposals-contract/schema/** + - packages/decision-proposals-contract/Cargo.toml + dashpay-contract: &dashpay-contract - .github/workflows/tests* - packages/dashpay-contract/src/** @@ -75,6 +81,7 @@ data-contracts: &data-contracts - *document-history-contract - *app-connect-contract - *moderation-charters-contract + - *decision-proposals-contract dpp: &dpp - .github/workflows/tests* diff --git a/.github/package-filters/test-suite-triggers.yml b/.github/package-filters/test-suite-triggers.yml index 778c99990ba..769afeb55d4 100644 --- a/.github/package-filters/test-suite-triggers.yml +++ b/.github/package-filters/test-suite-triggers.yml @@ -32,6 +32,7 @@ run: - packages/keyword-search-contract/** - packages/app-connect-contract/** - packages/moderation-charters-contract/** + - packages/decision-proposals-contract/** - packages/wallet-utils-contract/** # Local network scripts and action - .github/actions/local-network/** diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 38809956445..f10d59a301e 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -480,6 +480,7 @@ jobs: --package keyword-search-contract \ --package app-connect-contract \ --package moderation-charters-contract \ + --package decision-proposals-contract \ --all-features \ --locked \ -E '(not test(~shield) or (package(=platform-wallet-storage) and not test(~wallet::shielded::))) and (not binary_id(=drive-abci::strategy_tests) or test(~comprehensive_mixed_operations) or test(~process_proposal_collision))' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d8de57042fe..78d566cd103 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -352,6 +352,7 @@ jobs: - packages/swift-sdk/** - packages/app-connect-contract/** - packages/moderation-charters-contract/** + - packages/decision-proposals-contract/** - packages/dapi-grpc/** - packages/dashpay-contract/** - packages/data-contracts/** diff --git a/.pnp.cjs b/.pnp.cjs index 9f46daef6e8..3defac0e455 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -42,6 +42,10 @@ const RAW_RUNTIME_STATE = "name": "@dashevo/dashpay-contract",\ "reference": "workspace:packages/dashpay-contract"\ },\ + {\ + "name": "@dashevo/decision-proposals-contract",\ + "reference": "workspace:packages/decision-proposals-contract"\ + },\ {\ "name": "@dashevo/document-history-contract",\ "reference": "workspace:packages/document-history-contract"\ @@ -126,6 +130,7 @@ const RAW_RUNTIME_STATE = ["@dashevo/dapi-grpc", ["workspace:packages/dapi-grpc"]],\ ["@dashevo/dash-spv", ["workspace:packages/dash-spv"]],\ ["@dashevo/dashpay-contract", ["workspace:packages/dashpay-contract"]],\ + ["@dashevo/decision-proposals-contract", ["workspace:packages/decision-proposals-contract"]],\ ["@dashevo/document-history-contract", ["workspace:packages/document-history-contract"]],\ ["@dashevo/dpns-contract", ["workspace:packages/dpns-contract"]],\ ["@dashevo/evo-sdk", ["workspace:packages/js-evo-sdk"]],\ @@ -2763,6 +2768,22 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@dashevo/decision-proposals-contract", [\ + ["workspace:packages/decision-proposals-contract", {\ + "packageLocation": "./packages/decision-proposals-contract/",\ + "packageDependencies": [\ + ["@dashevo/decision-proposals-contract", "workspace:packages/decision-proposals-contract"],\ + ["@dashevo/wasm-dpp", "workspace:packages/wasm-dpp"],\ + ["chai", "npm:4.3.10"],\ + ["dirty-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:2.0.1"],\ + ["eslint", "virtual:de32c10d523830f1843784ae863166d6ef2e074b6da9615f2b3296a1f90385ed3f59e274e3957326ba7cf3442d82470d9e1ec01e6720989a570c075c95d90dbc#npm:9.39.2"],\ + ["mocha", "npm:11.1.0"],\ + ["sinon", "npm:18.0.1"],\ + ["sinon-chai", "virtual:5066f1efd4c78a5ddf1dc175fd2039811919d09bb6f7aa5f2b46141ac45f2e6a675ff6260802f91c4f0e827a9565804d3931db690e7aa741774d17536ffb79fb#npm:3.7.0"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@dashevo/docker-compose", [\ ["npm:0.24.4", {\ "packageLocation": "./.yarn/cache/@dashevo-docker-compose-npm-0.24.4-1e0677c86b-7ffd574221.zip/node_modules/@dashevo/docker-compose/",\ diff --git a/Dockerfile b/Dockerfile index 4868fea42d9..08839c2f01b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -405,6 +405,7 @@ COPY --parents \ packages/keyword-search-contract \ packages/app-connect-contract \ packages/moderation-charters-contract \ + packages/decision-proposals-contract \ packages/data-contracts \ packages/strategy-tests \ packages/simple-signer \ @@ -530,6 +531,7 @@ COPY --parents \ packages/keyword-search-contract \ packages/app-connect-contract \ packages/moderation-charters-contract \ + packages/decision-proposals-contract \ packages/withdrawals-contract \ packages/masternode-reward-shares-contract \ packages/dpns-contract \ @@ -716,6 +718,7 @@ COPY --parents \ packages/keyword-search-contract \ packages/app-connect-contract \ packages/moderation-charters-contract \ + packages/decision-proposals-contract \ packages/masternode-reward-shares-contract \ packages/dpns-contract \ packages/data-contracts \ @@ -863,6 +866,7 @@ COPY --from=build-dashmate-helper /platform/packages/document-history-contract p COPY --from=build-dashmate-helper /platform/packages/keyword-search-contract packages/keyword-search-contract COPY --from=build-dashmate-helper /platform/packages/app-connect-contract packages/app-connect-contract COPY --from=build-dashmate-helper /platform/packages/moderation-charters-contract packages/moderation-charters-contract +COPY --from=build-dashmate-helper /platform/packages/decision-proposals-contract packages/decision-proposals-contract COPY --from=build-dashmate-helper /platform/packages/withdrawals-contract packages/withdrawals-contract COPY --from=build-dashmate-helper /platform/packages/masternode-reward-shares-contract packages/masternode-reward-shares-contract COPY --from=build-dashmate-helper /platform/packages/dpns-contract packages/dpns-contract @@ -968,6 +972,7 @@ COPY --parents \ packages/keyword-search-contract \ packages/app-connect-contract \ packages/moderation-charters-contract \ + packages/decision-proposals-contract \ packages/withdrawals-contract \ packages/masternode-reward-shares-contract \ packages/dpns-contract \ diff --git a/book/src/architecture/overview.md b/book/src/architecture/overview.md index d001c411ec7..a6c05c9b5a5 100644 --- a/book/src/architecture/overview.md +++ b/book/src/architecture/overview.md @@ -281,7 +281,7 @@ Here is a simplified view of every Rust workspace member, grouped by role: | **gRPC definitions** | `dapi-grpc` | | **WASM bindings** | `wasm-dpp`, `wasm-dpp2`, `wasm-sdk`, `wasm-drive-verify` | | **iOS/FFI** | `rs-sdk-ffi` | -| **System contracts** | `dpns-contract`, `dashpay-contract`, `withdrawals-contract`, `masternode-reward-shares-contract`, `wallet-utils-contract`, `token-history-contract`, `keyword-search-contract`, `document-history-contract`, `app-connect-contract`, `moderation-charters-contract`, `data-contracts` | +| **System contracts** | `dpns-contract`, `dashpay-contract`, `withdrawals-contract`, `masternode-reward-shares-contract`, `wallet-utils-contract`, `token-history-contract`, `keyword-search-contract`, `document-history-contract`, `app-connect-contract`, `moderation-charters-contract`, `decision-proposals-contract`, `data-contracts` | | **Tooling** | `dashmate` (JS), `strategy-tests`, `simple-signer`, `check-features`, `json-schema-compatibility-validator` | | **Other** | `dash-platform-macros`, `rs-dash-event-bus`, `rs-platform-wallet`, `dash-platform-balance-checker`, `rs-dapi` | diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index fe80fecbf40..2a0ca963e16 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -151,15 +151,22 @@ contest's prefunded balance, each vote takes a fixed cost from it, and what is left when the contest ends is released as processing fees. The amounts are `VoteResolutionFundFees` in the fee version: -| Component | Protocol versions 1 to 13 | Protocol version 14 | -|---|---|---| -| Contested document fund (DPNS and every other contest) | 0.2 Dash | 0.1 Dash | -| Moderation election fund (an `electedCharter` application) | none exist | 0.5 Dash | -| One vote | 0.0001 Dash | 0.00002 Dash | - -`required_vote_resolution_fund` in `rs-dpp` picks between the two funds; the -schedules before 14 carry the contested document amount in the moderation -field, so the choice changes nothing there. +| Component | Protocol versions 1 to 13 | Protocol version 14 | Protocol version 15 | +|---|---|---|---| +| Contested document fund (DPNS and every other contest) | 0.2 Dash | 0.1 Dash | 0.1 Dash | +| Moderation election fund (an `electedCharter` application) | none exist | 0.5 Dash | 0.5 Dash | +| Decision proposal fund (a `proposal` on the decision proposals contract) | none exist | none exist | 0.5 Dash | +| One vote | 0.0001 Dash | 0.00002 Dash | 0.00002 Dash | + +`required_vote_resolution_fund` in `rs-dpp` picks between the two contest +funds; the schedules before 14 carry the contested document amount in the +moderation field, so the choice changes nothing there. + +A decision proposal (see `docs/protocol/decision-proposals.md`) opens a yes/no +vote poll rather than a contest, and its fund +(`decision_proposal_fund_required_amount`, added in `FEE_VERSION4` for +protocol version 15) prefunds that poll's votes the same way. The schedules +before 15 carry 0 there, unread. ## User Fee Increase diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md new file mode 100644 index 00000000000..0c9d44b931a --- /dev/null +++ b/docs/protocol/decision-proposals.md @@ -0,0 +1,104 @@ +# Decision proposals + +A decision proposal is a yes/no question that any identity puts to the +masternodes. Creating one opens a masternode yes/no vote poll on it, which the +proposal's owner funds. The result is signaling only: nothing on Platform acts +on it. + +The proposals live in the decision proposals system contract. It activates at +protocol version 15: a chain born at 15 registers it at genesis +(`create_genesis_state` v1, behind a `>= 15` version branch), an older chain +inserts it on the upgrade to 15 (`transition_to_version_15`), and the Drive +system contract cache and the trusted context provider serve it from 15 on. + +- Contract ID: `3iNyoNwjCTkb7XS3RHyWUtLx7pgC6AFAEQNNDHzxqngF` +- Owner: the all-zero system identity +- Registry entry: `SystemDataContract::DecisionProposals = 11` +- Schema version: 1 +- Document types: `proposal` + +## `proposal` + +The type is immutable (`documentsMutable: false`) and undeletable +(`canBeDeleted: false`), so masternodes vote on what the proposal said when it +opened, and the proposal stays for as long as its result is read. Additional +properties are rejected. + +| Property | Type | Meaning | +| --- | --- | --- | +| `title` | string, 1 to 128 characters and at most 512 bytes (`maxBytes`), required | The question, in one line | +| `description` | string, 1 to 4096 characters and at most 4096 bytes, required | What a yes and a no mean | +| `url` | string, `format: uri`, 1 to 512 characters and at most 512 bytes | Where the proposal is discussed | +| `supermajorityNumerator` | integer 1 to 255, required | With the denominator, the share of yes plus no that yes must exceed | +| `supermajorityDenominator` | integer 1 to 255, required | | +| `minimumVotingPower` | integer 0 to 4294967295 | A fixed minimum: the least yes plus no voting power the proposal needs | +| `minimumShare` | object of `numerator` (0 to 255), `denominator` (1 to 255) and `rounding`, all required | A minimum as a share of the masternode list's total voting power in the block that closes the poll, PoSe-banned masternodes left out | +| `endDate` | integer, milliseconds since the Unix epoch, required | When voting closes | + +A proposal sets `minimumVotingPower` or `minimumShare`. `rounding` says how +the share rounds to a whole voting power: `down` (1/3 of 10 is 3), `up` (1/3 of +10 is 4), `downPlusOne` (1/3 of 9 is 4; half the total this way is a strict +majority of it) or `upMinusOne` (1/3 of 9 is 2, never below zero). + +Indexes: + +| Index | Properties | Lists | +| --- | --- | --- | +| `byEndDate` | `endDate`, `$createdAt` | The proposals closing soonest first | +| `byOwner` | `$ownerId`, `$createdAt` | An identity's proposals, in filing order | +| `byCreatedAt` | `$createdAt` | The newest proposals | + +None is unique: anyone may ask the same question again. + +## Opening the poll + +A `proposal` create opens a yes/no vote poll in the same batch. The poll's +purpose is `YesNoVotePollPurpose::DecisionProposal { document_id }`, the +proposal's document id, and its id is derived from that purpose alone, so a +proposal has exactly one poll. Its rule is read from the proposal: + +- it passes when the yes voting power is strictly more than + `supermajorityNumerator / supermajorityDenominator` of the yes plus no voting + power, abstentions left out of both sides, +- and the yes plus no voting power reaches the minimum. + +A masternode votes with 1 and an evonode with 4. The rule is written to the +poll's stored info when it opens and cannot change after that; a vote names the +poll by id alone. + +Opening the poll prefunds the masternode votes on it: 0.5 Dash +(`decision_proposal_fund_required_amount` in `VoteResolutionFundFees`, set in +`FEE_VERSION4`, protocol version 15) leaves the owner's balance for the poll's +prefunded balance. Each vote takes a fixed cost from it (0.00002 Dash), so the +fund pays for 25,000 votes, more than every masternode casting all of its votes +on it. What the votes leave is released as processing fees when the poll ends. + +## Limits + +The create is refused unless the rule is within these limits, which +`SystemLimits` carries: + +| Limit | Value | Field | +| --- | --- | --- | +| Threshold | at least a simple majority: the fraction is at least 1/2, and below 1 | `min_yes_no_vote_poll_supermajority_percent` (50) | +| Fixed minimum | at most 2,000 voting power | `max_yes_no_vote_poll_minimum_voting_power` | +| Share minimum | at most 50% of the network's total voting power | `max_yes_no_vote_poll_minimum_voting_power_percent_of_total` | +| End date | one to four weeks after the block that opens the poll | `min_yes_no_vote_poll_duration_ms`, `max_yes_no_vote_poll_duration_ms` | + +A threshold of at least a simple majority means a proposal cannot pass with +fewer yes than no. The one-week floor gives masternode operators time to see a +proposal before it closes. + +## Reading the result + +The poll closes in a block whose time has reached its end date. Its result, +whether it passed with the yes, no and abstain voting power cast and the +voting power it needed, stays in the poll's stored info. Read it with +`getYesNoVotePollState`, naming the poll by its id (`YesNoVotePoll::unique_id` +of the `DecisionProposal` purpose): the response carries the poll as it was +opened, its rule included, and once it has finished, the result and the blocks +it opened and closed in. + +The result is signaling only. Nothing on Platform acts on a passed or failed +proposal (`on_yes_no_vote_poll_finished` does nothing for this purpose); what +it means is up to the people who read it. diff --git a/package.json b/package.json index ebf942edca9..d8a6a5ca3aa 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:document-history-contract": "ultra -r --filter \"packages/@(document-history-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", "test:app-connect-contract": "ultra -r --filter \"packages/@(app-connect-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", "test:moderation-charters-contract": "ultra -r --filter \"packages/@(moderation-charters-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", + "test:decision-proposals-contract": "ultra -r --filter \"packages/@(decision-proposals-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", "test:dapi-client": "ultra -r --filter \"packages/@(js-dapi-client|wallet-lib|js-dash-sdk|platform-test-suite)\" test", "test:sdk": "ultra -r --filter \"packages/@(js-dash-sdk|platform-test-suite)\" test", "test:spv": "ultra -r --filter \"packages/@(dash-spv|js-dapi-client)\" test", @@ -81,6 +82,7 @@ "packages/keyword-search-contract", "packages/app-connect-contract", "packages/moderation-charters-contract", + "packages/decision-proposals-contract", "packages/wasm-drive-verify", "packages/wasm-sdk", "packages/js-evo-sdk" diff --git a/packages/decision-proposals-contract/README.md b/packages/decision-proposals-contract/README.md new file mode 100644 index 00000000000..881bb584b3a --- /dev/null +++ b/packages/decision-proposals-contract/README.md @@ -0,0 +1,62 @@ +# Decision Proposals Contract + +The decision proposals system contract holds yes/no questions that anyone puts +to the masternodes. It activates at protocol version 15, registered at genesis +by chains born at 15 and inserted by the upgrade to 15, and has the same ID on +every network: `3iNyoNwjCTkb7XS3RHyWUtLx7pgC6AFAEQNNDHzxqngF`. + +Creating a proposal opens a masternode yes/no vote poll on it. The poll is +signaling only: nothing on Platform acts on its result, which stays readable +with `getYesNoVotePollState`. + +## `proposal` + +The only document type. Any identity may create one. It is immutable and +undeletable, so masternodes vote on what the proposal said when it opened. + +| Property | Type | Meaning | +| --- | --- | --- | +| `title` | string, 1 to 128 characters and at most 512 bytes, required | The question, in one line | +| `description` | string, 1 to 4096 characters and at most 4096 bytes, required | What a yes and a no mean | +| `url` | string, a URI of 1 to 512 characters and at most 512 bytes | Where the proposal is discussed | +| `supermajorityNumerator` | integer 1 to 255, required | The proposal passes when yes is more than `supermajorityNumerator / supermajorityDenominator` of yes plus no | +| `supermajorityDenominator` | integer 1 to 255, required | | +| `minimumVotingPower` | integer 0 to 4294967295 | The least yes plus no voting power the proposal needs (a masternode votes with 1, an evonode with 4) | +| `minimumShare` | object | The least yes plus no voting power the proposal needs, as a share of the masternode list's total when the poll closes: `numerator` (0 to 255), `denominator` (1 to 255) and `rounding`, one of `down`, `up`, `downPlusOne` and `upMinusOne`, all three required | +| `endDate` | integer, milliseconds since the Unix epoch, required | When voting closes | + +A proposal sets `minimumVotingPower` or `minimumShare`. Additional properties +are rejected, in the proposal and in `minimumShare`. + +Indexes: `byEndDate` (`endDate`, `$createdAt`) lists the proposals closing +soonest first; `byOwner` (`$ownerId`, `$createdAt`) lists an identity's +proposals; `byCreatedAt` (`$createdAt`) lists the newest. + +## The poll + +At protocol version 15, a `proposal` create opens a yes/no vote poll for the +proposal and prefunds the masternode votes on it with 0.5 Dash from the +owner's balance (`decision_proposal_fund_required_amount`). What the votes +leave is released as processing fees when the poll ends. + +The poll's rule is read from the proposal and must be within the protocol's +limits, or the create is refused: + +- the threshold is at least a simple majority: `supermajorityNumerator / + supermajorityDenominator` is at least one half and below one; +- the minimum is at most 2,000 voting power, or at most 50% of the network's + total voting power; +- the end date is one to four weeks after the block that opens the poll. + +See [the protocol guide](../../docs/protocol/decision-proposals.md) for +details. + +## Install + +```sh +npm install @dashevo/decision-proposals-contract +``` + +## License + +[MIT](LICENSE) © Dash Core Group, Inc. diff --git a/packages/decision-proposals-contract/lib/systemIds.js b/packages/decision-proposals-contract/lib/systemIds.js new file mode 100644 index 00000000000..ec4b1626250 --- /dev/null +++ b/packages/decision-proposals-contract/lib/systemIds.js @@ -0,0 +1,4 @@ +module.exports = { + ownerId: '11111111111111111111111111111111', + contractId: '3iNyoNwjCTkb7XS3RHyWUtLx7pgC6AFAEQNNDHzxqngF', +}; diff --git a/packages/decision-proposals-contract/package.json b/packages/decision-proposals-contract/package.json new file mode 100644 index 00000000000..80e7454ab00 --- /dev/null +++ b/packages/decision-proposals-contract/package.json @@ -0,0 +1,27 @@ +{ + "name": "@dashevo/decision-proposals-contract", + "version": "5.0.1", + "description": "A system contract for decision proposals: yes/no questions put to the masternodes", + "scripts": { + "lint": "eslint .", + "test": "yarn run test:unit", + "test:unit": "mocha 'test/unit/**/*.spec.js'" + }, + "contributors": [ + { + "name": "Samuel Westrich", + "email": "sam@dash.org", + "url": "https://github.com/quantumexplorer" + } + ], + "license": "MIT", + "devDependencies": { + "@dashevo/wasm-dpp": "workspace:*", + "chai": "^4.3.10", + "dirty-chai": "^2.0.1", + "eslint": "^9.18.0", + "mocha": "^11.1.0", + "sinon": "^18.0.1", + "sinon-chai": "^3.7.0" + } +} diff --git a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js new file mode 100644 index 00000000000..9e08dcb2ba4 --- /dev/null +++ b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js @@ -0,0 +1,286 @@ +const crypto = require('crypto'); + +const { + DashPlatformProtocol, + JsonSchemaError, +} = require('@dashevo/wasm-dpp'); +const generateRandomIdentifier = require('@dashevo/wasm-dpp/lib/test/utils/generateRandomIdentifierAsync'); + +const { expect } = require('chai'); +const decisionProposalsContractDocumentsSchema = require('../../schema/v1/decision-proposals-contract-documents.json'); + +const expectJsonSchemaError = (validationResult, errorCount = 1) => { + const errors = validationResult.getErrors(); + expect(errors) + .to + .have + .length(errorCount); + + const error = validationResult.getErrors()[0]; + expect(error) + .to + .be + .instanceof(JsonSchemaError); + + return error; +}; + +const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000; + +describe('Decision Proposals Contract', () => { + let dpp; + let dataContract; + let identityId; + + beforeEach(async () => { + dpp = new DashPlatformProtocol( + { generate: () => crypto.randomBytes(32) }, + ); + + identityId = await generateRandomIdentifier(); + + dataContract = dpp.dataContract.create( + identityId, + BigInt(1), + decisionProposalsContractDocumentsSchema, + ); + }); + + const validate = (raw) => { + const document = dpp.document.create(dataContract, identityId, 'proposal', raw); + return document.validate(dpp.protocolVersion); + }; + + const rawProposal = () => ({ + title: 'Should evonodes run the new indexer by default?', + description: 'Yes: the next release turns it on. No: it stays opt-in.', + url: 'https://www.dash.org/forum/threads/indexer-default.1/', + supermajorityNumerator: 2, + supermajorityDenominator: 3, + minimumVotingPower: 1000, + endDate: Date.now() + TWO_WEEKS_MS, + }); + + const rawProposalWithShare = () => { + const raw = rawProposal(); + delete raw.minimumVotingPower; + raw.minimumShare = { + numerator: 1, + denominator: 10, + rounding: 'up', + }; + + return raw; + }; + + it('should have a valid contract definition', async () => { + expect(() => dpp.dataContract.create( + identityId, + BigInt(1), + decisionProposalsContractDocumentsSchema, + )) + .to + .not + .throw(); + }); + + it('should have one document type', () => { + expect(Object.keys(decisionProposalsContractDocumentsSchema)).to.deep.equal(['proposal']); + }); + + describe('proposal', () => { + it('should be valid with a fixed minimum voting power', () => { + expect(validate(rawProposal()).isValid()).to.be.true(); + }); + + it('should be valid with a minimum share of the network', () => { + expect(validate(rawProposalWithShare()).isValid()).to.be.true(); + }); + + it('should not need a url', () => { + const raw = rawProposal(); + delete raw.url; + + expect(validate(raw).isValid()).to.be.true(); + }); + + it('should be immutable and undeletable, so masternodes vote on what it said when it opened', () => { + const { proposal } = decisionProposalsContractDocumentsSchema; + + expect(proposal.documentsMutable).to.be.false(); + expect(proposal.canBeDeleted).to.be.false(); + }); + + it('should index proposals by end date, by owner and by creation time', () => { + const { indices } = decisionProposalsContractDocumentsSchema.proposal; + + expect(indices.map(({ name }) => name)).to.deep.equal([ + 'byEndDate', + 'byOwner', + 'byCreatedAt', + ]); + indices.forEach(({ unique }) => expect(unique).to.be.undefined()); + }); + + ['title', 'description', 'supermajorityNumerator', 'supermajorityDenominator', 'endDate'].forEach((property) => { + it(`should require ${property}`, () => { + const raw = rawProposal(); + delete raw[property]; + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal(property); + }); + }); + + it('should not have additional properties', () => { + const raw = rawProposal(); + raw.outcome = 'yes'; + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('additionalProperties'); + expect(error.params.additionalProperties).to.deep.equal(['outcome']); + }); + + describe('title', () => { + it('should refuse an empty title', () => { + const raw = rawProposal(); + raw.title = ''; + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('minLength'); + }); + + it('should accept 128 characters', () => { + const raw = rawProposal(); + raw.title = 'a'.repeat(128); + + expect(validate(raw).isValid()).to.be.true(); + }); + + it('should refuse 129 characters', () => { + const raw = rawProposal(); + raw.title = 'a'.repeat(129); + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('maxLength'); + }); + }); + + describe('description', () => { + it('should refuse 4097 characters', () => { + const raw = rawProposal(); + raw.description = 'a'.repeat(4097); + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('maxLength'); + }); + + it('should refuse more than 4096 bytes within 4096 characters', () => { + // 2049 two-byte characters: within `maxLength`, which counts characters, but + // 4098 bytes, over `maxBytes` (DocumentPropertyMaxBytesExceededError, 10421) + const raw = rawProposal(); + raw.description = 'é'.repeat(2049); + + const errors = validate(raw).getErrors(); + + expect(errors).to.have.length(1); + expect(errors[0].getCode()).to.equal(10421); + }); + + it('should accept 4096 bytes in two-byte characters', () => { + const raw = rawProposal(); + raw.description = 'é'.repeat(2048); + + expect(validate(raw).isValid()).to.be.true(); + }); + }); + + describe('supermajority', () => { + ['supermajorityNumerator', 'supermajorityDenominator'].forEach((property) => { + it(`should refuse a ${property} of 0`, () => { + const raw = rawProposal(); + raw[property] = 0; + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('minimum'); + }); + + it(`should refuse a ${property} over 255`, () => { + const raw = rawProposal(); + raw[property] = 256; + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('maximum'); + }); + }); + }); + + describe('minimumVotingPower', () => { + it('should refuse a value over u32', () => { + const raw = rawProposal(); + raw.minimumVotingPower = 4294967296; + + expect(validate(raw).isValid()).to.be.false(); + }); + }); + + describe('minimumShare', () => { + ['down', 'up', 'downPlusOne', 'upMinusOne'].forEach((rounding) => { + it(`should accept the rounding ${rounding}`, () => { + const raw = rawProposalWithShare(); + raw.minimumShare.rounding = rounding; + + expect(validate(raw).isValid()).to.be.true(); + }); + }); + + it('should refuse an unknown rounding', () => { + const raw = rawProposalWithShare(); + raw.minimumShare.rounding = 'nearest'; + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('enum'); + }); + + ['numerator', 'denominator', 'rounding'].forEach((property) => { + it(`should require ${property}`, () => { + const raw = rawProposalWithShare(); + delete raw.minimumShare[property]; + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal(property); + }); + }); + + it('should refuse a denominator of 0', () => { + const raw = rawProposalWithShare(); + raw.minimumShare.denominator = 0; + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('minimum'); + }); + + it('should not have additional properties', () => { + const raw = rawProposalWithShare(); + raw.minimumShare.total = 3000; + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('additionalProperties'); + expect(error.params.additionalProperties).to.deep.equal(['total']); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 4df9d72f564..baa2a99c1ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1727,6 +1727,20 @@ __metadata: languageName: unknown linkType: soft +"@dashevo/decision-proposals-contract@workspace:packages/decision-proposals-contract": + version: 0.0.0-use.local + resolution: "@dashevo/decision-proposals-contract@workspace:packages/decision-proposals-contract" + dependencies: + "@dashevo/wasm-dpp": "workspace:*" + chai: "npm:^4.3.10" + dirty-chai: "npm:^2.0.1" + eslint: "npm:^9.18.0" + mocha: "npm:^11.1.0" + sinon: "npm:^18.0.1" + sinon-chai: "npm:^3.7.0" + languageName: unknown + linkType: soft + "@dashevo/docker-compose@npm:^0.24.4": version: 0.24.4 resolution: "@dashevo/docker-compose@npm:0.24.4" From 5d746e2356982e7360182b59ca3fb49436889dc4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 14:45:19 +0300 Subject: [PATCH 4/7] feat(sdk): serve the decision proposals contract from the trusted context provider at protocol 15 Behind the decision-proposals-contract feature (and all-system-contracts), the provider loads the decision proposals system contract from protocol version 15 on and reports it absent below, like the app-connect and moderation charters contracts from 14. Co-Authored-By: Claude Opus 5.5 --- .../src/provider.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 9e68e6ebf8a..aa8d94ef27b 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -24,11 +24,17 @@ use dpp::data_contract::TokenConfiguration; feature = "document-history-contract", feature = "app-connect-contract", feature = "moderation-charters-contract", + feature = "decision-proposals-contract", feature = "all-system-contracts" ))] use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; #[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] use dpp::version::feature_initial_protocol_versions::APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION; +#[cfg(any( + feature = "decision-proposals-contract", + feature = "all-system-contracts" +))] +use dpp::version::feature_initial_protocol_versions::DECISION_PROPOSALS_CONTRACT_INITIAL_PROTOCOL_VERSION; #[cfg(any( feature = "moderation-charters-contract", feature = "all-system-contracts" @@ -1050,6 +1056,7 @@ impl ContextProvider for TrustedHttpContextProvider { feature = "document-history-contract", feature = "app-connect-contract", feature = "moderation-charters-contract", + feature = "decision-proposals-contract", feature = "all-system-contracts" ))] { @@ -1194,6 +1201,28 @@ impl ContextProvider for TrustedHttpContextProvider { )) }); } + + #[cfg(any( + feature = "decision-proposals-contract", + feature = "all-system-contracts" + ))] + // Below protocol version 15 the decision proposals contract is absent. + if *id == SystemDataContract::DecisionProposals.id() + && platform_version.protocol_version + >= DECISION_PROPOSALS_CONTRACT_INITIAL_PROTOCOL_VERSION + { + return load_system_data_contract( + SystemDataContract::DecisionProposals, + platform_version, + ) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load DecisionProposals contract: {}", + e + )) + }); + } } // If not found in known contracts or system contracts, delegate to fallback provider if available @@ -2518,6 +2547,38 @@ mod tests { assert_eq!(contract.id(), id); } + /// The decision proposals system contract is served only from its activation version + /// on, protocol version 15, like the app-connect and moderation charters contracts from 14. + #[cfg(any( + feature = "decision-proposals-contract", + feature = "all-system-contracts" + ))] + #[test] + fn should_serve_decision_proposals_only_from_protocol_15() { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::version::PlatformVersion; + + // A numeric loopback URL avoids DNS; contract lookups make no HTTP requests. + let provider = TrustedHttpContextProvider::new_with_url( + Network::Testnet, + "https://127.0.0.1".to_string(), + NonZeroUsize::new(100).unwrap(), + ) + .unwrap(); + let id = SystemDataContract::DecisionProposals.id(); + + assert!(provider + .get_data_contract(&id, PlatformVersion::get(14).unwrap()) + .expect("a pre-activation lookup must not error") + .is_none()); + + let contract = provider + .get_data_contract(&id, PlatformVersion::latest()) + .expect("the lookup must succeed at protocol version 15") + .expect("the decision proposals contract must be served at protocol version 15"); + assert_eq!(contract.id(), id); + } + #[test] fn test_domain_resolution_check() { // Test with a domain that should resolve (using localhost) From 8d439d93987cc4bee49484eca3e37835094d0dae Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 14:45:28 +0300 Subject: [PATCH 5/7] test(drive-abci): decision proposals registration at genesis and on the upgrade to 15 The contract is in a genesis state born at 15 and not at 14, is inserted by transition_to_version_15, is stored byte for byte as a chain born at 15 stores it (no storage flags, same trees), and its registration rolls back and retries to the same root through the upgrade dispatcher. It is one more sibling in the genesis contracts tree from protocol 15, which reshapes that tree and moves the byte-billed contract reads of the fee pins at the latest version: deletion and replacement -47_280, DPNS domain creates +95_920 each, the commit/reveal fixture's contract -45_920, token burn and direct purchase -48_640, the sponsored shielded token payments +45_920, and the single-entry shielded GroveDB root pins. Confirmed by gating the genesis registration off, which restores every old value. Co-Authored-By: Claude Opus 5.5 --- .../create_genesis_state/v1/mod.rs | 44 +++++ .../mod.rs | 113 ++++++++++++ .../v0/mod.rs | 161 ++++++++++++++++++ .../tests/document/commit_reveal_lookup.rs | 15 +- .../batch/tests/document/deletion.rs | 6 +- .../batch/tests/document/dpns.rs | 6 +- .../batch/tests/document/dpns_v3.rs | 7 +- .../batch/tests/document/replacement.rs | 6 +- .../batch/tests/token/burn/mod.rs | 5 +- .../batch/tests/token/direct_selling/mod.rs | 5 +- .../token/shielded/nullifier_batch_guard.rs | 24 +-- 11 files changed, 367 insertions(+), 25 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs index 48097ff92a5..3c58125521b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs @@ -271,5 +271,49 @@ mod tests { } } } + + /// The decision proposals contract joins the genesis state at protocol version 15, + /// behind its own branch, and is absent from the genesis state of a chain born at 14, + /// which a replaying node must reproduce byte for byte. + #[test] + pub fn should_register_the_decision_proposals_contract_only_from_protocol_version_15() { + let decision_proposals_id = SystemDataContract::DecisionProposals.id(); + + for (platform_version, expected) in [ + (PlatformVersion::get(14).expect("protocol 14"), false), + (PlatformVersion::latest(), true), + ] { + let initial_protocol_version = platform_version.protocol_version; + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(initial_protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let stored = platform + .drive + .fetch_contract( + decision_proposals_id.to_buffer(), + None, + None, + None, + platform_version, + ) + .value + .expect("expected to query the decision proposals contract"); + + assert_eq!( + stored.is_some(), + expected, + "decision proposals contract presence in a genesis state born at protocol version {initial_protocol_version}" + ); + + if let Some(stored) = stored { + assert_eq!(stored.contract.id(), decision_proposals_id); + assert_eq!(stored.contract.owner_id(), Identifier::from([0u8; 32])); + assert!(stored.contract.document_type_for_name("proposal").is_ok()); + assert_eq!(stored.contract.document_types().len(), 1); + } + } + } } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs index eb260b6dbe0..fb8bc3b0e3d 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs @@ -382,4 +382,117 @@ mod tests { Some(1) ); } + + #[test] + fn should_rollback_and_retry_decision_proposals_registration_through_the_upgrade_dispatcher() { + let previous_version = PlatformVersion::get(14).expect("protocol 14"); + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 100, + epoch: Epoch::new(1).expect("epoch"), + }; + let id = SystemDataContract::DecisionProposals.id(); + let original_root = platform + .drive + .grove + .root_hash(None, &previous_version.drive.grove_version) + .unwrap() + .expect("original root"); + let mut first_candidate_root = None; + + for commit in [false, true] { + // The block proposal path clears this cache before retrying a candidate. + platform.drive.cache.data_contracts.clear_block_cache(); + let transaction = platform.drive.grove.start_transaction(); + platform + .perform_events_on_first_block_of_protocol_change( + &platform_state, + &block_info, + &transaction, + previous_version.protocol_version, + platform_version, + ) + .expect("protocol upgrade through the dispatcher"); + assert_eq!( + platform + .drive + .fetch_contract_version(id.to_buffer(), Some(&transaction), platform_version) + .expect("registered version"), + Some(1) + ); + let candidate_root = platform + .drive + .grove + .root_hash(Some(&transaction), &platform_version.drive.grove_version) + .unwrap() + .expect("candidate root"); + assert_ne!(candidate_root, original_root); + // Compiling the new schema must not expose it to old-version cache readers. + assert!(platform + .drive + .cache + .system_data_contracts + .find_by_id(id, platform_version) + .expect("new cache lookup") + .is_some()); + assert!(platform + .drive + .cache + .system_data_contracts + .find_by_id(id, previous_version) + .expect("old cache lookup") + .is_none()); + if commit { + assert_eq!( + Some(candidate_root), + first_candidate_root, + "retry after rollback must produce the same state" + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commit retry"); + platform + .drive + .cache + .data_contracts + .merge_and_clear_block_cache(); + } else { + first_candidate_root = Some(candidate_root); + drop(transaction); + platform.drive.cache.data_contracts.clear_block_cache(); + assert_eq!( + platform + .drive + .grove + .root_hash(None, &previous_version.drive.grove_version) + .unwrap() + .expect("committed root after rollback"), + original_root + ); + assert!(platform + .drive + .fetch_contract(id.to_buffer(), None, None, None, previous_version) + .value + .expect("old committed state") + .is_none()); + } + } + assert_eq!( + platform + .drive + .fetch_contract_version(id.to_buffer(), None, platform_version) + .expect("committed contract version"), + Some(1) + ); + } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index ea739c7ddd3..78094eaa3b1 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -970,6 +970,7 @@ mod tests { use crate::test::helpers::setup::TestPlatformBuilder; use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; + use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::platform_serialization::PLATFORM_BINCODE_CONFIG; use dpp::version::PlatformVersion; use drive::drive::credit_pools::epochs::epochs_root_tree_key_constants::KEY_LIFETIME_STORAGE_FEE_POOLS; @@ -1845,6 +1846,93 @@ mod tests { .is_some()); } + #[test] + fn should_insert_decision_proposals_on_transition_to_version_15() { + // A chain born at protocol version 14 has no decision proposals contract: it is + // neither in that genesis state nor active for the system contract cache. + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_version_14 = PlatformVersion::get(14).expect("expected platform version 14"); + let platform_version = PlatformVersion::latest(); + let decision_proposals_id = SystemDataContract::DecisionProposals.id(); + + let transaction = platform.drive.grove.start_transaction(); + + assert!( + platform + .drive + .fetch_contract( + decision_proposals_id.to_buffer(), + None, + None, + Some(&transaction), + platform_version_14, + ) + .value + .expect("expected to query the decision proposals contract") + .is_none(), + "the decision proposals contract must not exist before transition_to_version_15" + ); + assert!(platform + .drive + .cache + .system_data_contracts + .find_by_id(decision_proposals_id, platform_version_14) + .expect("expected the pre-activation lookup to succeed") + .is_none()); + + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 100, + epoch: Epoch::new(1).expect("expected epoch"), + }; + + platform + .transition_to_version_15(&block_info, &transaction, platform_version) + .expect("expected the transition to succeed"); + + let stored = platform + .drive + .fetch_contract( + decision_proposals_id.to_buffer(), + None, + None, + Some(&transaction), + platform_version, + ) + .value + .expect("expected to fetch the decision proposals contract") + .expect("the decision proposals contract must exist after transition_to_version_15"); + + assert_eq!(stored.contract.id(), decision_proposals_id); + assert_eq!(stored.contract.owner_id(), Identifier::from([0u8; 32])); + assert!(stored.contract.document_type_for_name("proposal").is_ok()); + assert_eq!(stored.contract.document_types().len(), 1); + assert_eq!( + platform + .drive + .fetch_contract_version( + decision_proposals_id.to_buffer(), + Some(&transaction), + platform_version + ) + .expect("expected to read the version item"), + Some(stored.contract.version()), + "the decision proposals contract has its version item after the transition" + ); + assert!(platform + .drive + .cache + .system_data_contracts + .find_by_id(decision_proposals_id, platform_version) + .expect("expected the post-activation lookup to succeed") + .is_some()); + } + /// Reads the `status` enum of the stored withdrawals contract's `withdrawal` document /// type, the contract-level record of which statuses a withdrawal may carry. fn stored_withdrawal_status_enum( @@ -2761,6 +2849,79 @@ mod tests { } } + /// The decision proposals contract the upgrade to 15 registers is stored without storage + /// flags, as a chain born at 15 stores it at genesis. Both chains store it at protocol + /// version 15, so compiled, and its contract element and every tree created with it are + /// byte-identical on the two. It has no contested index, so nothing under the active polls. + #[test] + fn should_store_the_decision_proposals_contract_as_a_chain_born_at_15_does() { + // The version the transition activates, the one a chain born at 15 stores its genesis + // contracts with + let platform_version = PlatformVersion::latest(); + let born_at_15 = TestPlatformBuilder::new() + .with_initial_protocol_version(15) + .build_with_mock_rpc() + .set_genesis_state(); + let upgraded = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = upgraded.drive.grove.start_transaction(); + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 100, + epoch: Epoch::new(1).expect("expected epoch"), + }; + upgraded + .transition_to_version_15(&block_info, &transaction, platform_version) + .expect("expected version 15 transition to succeed"); + + let element = |platform: &Platform, + transaction: Option<&Transaction>, + path: &[Vec], + key: &[u8]| { + platform + .drive + .grove_get_raw( + path.into(), + key, + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to read the element") + .expect("expected the element to exist") + }; + + let contracts = vec![vec![RootTree::DataContractDocuments as u8]]; + let id = SystemDataContract::DecisionProposals.id().to_buffer(); + let upgraded_element = element(&upgraded, Some(&transaction), &contracts, &id); + assert_eq!( + upgraded_element.get_flags(), + &None, + "the decision proposals contract is stored without storage flags" + ); + assert_eq!( + element(&born_at_15, None, &contracts, &id), + upgraded_element, + "the decision proposals contract differs between a chain born at version 15 and one \ + upgraded to it" + ); + + let mut root_path = contracts.clone(); + root_path.push(id.to_vec()); + let diffs = collect_subtree_diffs(&born_at_15, &upgraded, &transaction, root_path); + assert!( + diffs.is_empty(), + "the trees of the decision proposals contract differ between a chain born at \ + version 15 and one upgraded to it:\n{}", + diffs.join("\n"), + ); + } + /// Merk's shape depends on insertion order, and genesis builds the withdrawals tree's first /// keys in its batch while the upgrade adds the trees of version 14 to an existing one: /// both insert those trees one at a time through `insert_withdrawal_limit_trees`, so the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs index 121ee287490..016cb66294e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs @@ -1763,9 +1763,15 @@ mod commit_reveal_lookup_tests { actual_fees.processing_fee, )); } + // A chain born at 15 also holds the decision proposals contract, one more sibling + // registered at genesis in the contracts tree, which reshapes it so the written post + // reads its contract for fewer bytes (-45_920). The lookups cost the same. + let ((found_at_14, found_storage_at_14), missing_at_14) = fees[0]; assert_eq!( - fees[0], fees[1], - "keyed by owner {keyed_by_owner}: the fees at 14, then at 15" + ((found_at_14 - 45_920, found_storage_at_14), missing_at_14), + fees[1], + "keyed by owner {keyed_by_owner}: the fees at 14 less the genesis shift, then at \ + 15" ); } } @@ -3001,7 +3007,10 @@ mod commit_reveal_lookup_tests { /// it consumes from what its lookup read, without reading it again. #[tokio::test] async fn should_pin_the_fees_of_a_reveal_choosing_the_first_of_its_preorders() { - for (protocol_version, accepted_processing_fee) in [(14, 3_574_720), (15, 3_562_700)] { + // At 15 the contracts tree also holds the decision proposals contract, one more sibling + // registered at genesis, which reshapes it so the fixture contract's node is billed + // fewer bytes to reach (-45_920) + for (protocol_version, accepted_processing_fee) in [(14, 3_574_720), (15, 3_516_780)] { let platform_version = PlatformVersion::get(protocol_version).expect("expected the platform version"); let mut fixture = CommitRevealFixture::new_at(platform_version, |contract| { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs index 981acfa8853..e34a17c55b8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs @@ -17,8 +17,10 @@ mod deletion_tests { // increases byte-billed contract-tree reads. // The app-connect and moderation charters contracts each add one sibling to the // genesis contracts tree, increasing the bytes billed when reading that tree - // (protocol 14 only). - 1817440, + // (protocol 14 only). The decision proposals contract adds another at protocol + // 15, which reshapes the tree so the contract's node is billed fewer bytes to + // reach (-47_280). + 1770160, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs index b13506a59a0..28382d96db3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns.rs @@ -36,8 +36,10 @@ mod dpns_tests { // The app-connect contract adds one sibling to the genesis contracts tree, // increasing the bytes billed when reading that tree (protocol 14 only). The // moderation charters contract adds another, which reshapes the tree so the - // DPNS contract's node is billed fewer bytes to reach (-287_760). - 5_761_600, + // DPNS contract's node is billed fewer bytes to reach (-287_760). The decision + // proposals contract adds a third at protocol 15, which reshapes it back to as + // many bytes as with the app-connect contract alone (+287_760, 95_920 a create). + 6_049_360, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs index 95d2997325a..b70dcd7332e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/dpns_v3.rs @@ -1565,13 +1565,16 @@ mod dpns_v3_tests { measured.push((fee_result.processing_fee, fee_result.storage_fee, refund)); } } + // At 15 each create also reads the DPNS contract through a contracts tree holding the + // decision proposals contract, one more sibling registered at genesis, which reshapes + // the tree so the DPNS contract's node is billed more bytes to reach (+95_920 a create). assert_eq!( measured, vec![ (1_159_720, 31_158_000, 0), (1_846_280, 42_687_000, 0), - (5_495_080, 11_558_570, 0), - (3_643_720, 42_687_000, 0) + (5_591_000, 11_558_570, 0), + (3_739_640, 42_687_000, 0) ] ); } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index e8e126d7000..7ea76a4008f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -525,8 +525,10 @@ mod replacement_tests { // increases byte-billed contract-tree reads. // The app-connect and moderation charters contracts each add one sibling to the // genesis contracts tree, increasing the bytes billed when reading that tree - // (protocol 14 only). - 1546140, + // (protocol 14 only). The decision proposals contract adds another at protocol + // 15, which reshapes the tree so the contract's node is billed fewer bytes to + // reach (-47_280). + 1498860, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs index 24f4a46a3ab..5c543bf6fca 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs @@ -3963,7 +3963,10 @@ mod token_burn_tests { // +740 per document write from protocol version 14: the contract's version item is // one more node to rehash; -12_820: the documents expirations tree joins `Misc` // beside the token supplies tree the burn rewrites, reshaping the `Misc` Merk - 4_356_200, + // PROTOCOL_VERSION_15: -48_640: the decision proposals contract adds one + // sibling to the genesis contracts tree, which reshapes the tree so the token + // contract's node is billed fewer bytes to reach + 4_307_560, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs index 26606e65afa..911fe3476d8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs @@ -31,7 +31,10 @@ mod token_selling_tests { // reads the total supply even though the token sets no max supply. 12_820 credits // less in fees: the documents expirations tree joins `Misc` beside the token // supplies tree the purchase rewrites, reshaping the `Misc` Merk - 699_868_049_840, + // PROTOCOL_VERSION_15: 48_640 credits less in fees: the decision proposals + // contract adds one sibling to the genesis contracts tree, which reshapes the tree + // so the token contract's node is billed fewer bytes to reach + 699_868_098_480, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index 5afe657671d..b82be18ea49 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -734,7 +734,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "24e77c57edbf5294e7b43df5af21fcd055697120124f3912021c2e41c954f4e0" + "20aee245dfbc8e2e11ebc96f8611a111c2cf2d2e5fefda74f9ed200e6963d733" ); assert_eq!( pool.platform @@ -1869,9 +1869,9 @@ async fn should_preserve_a_real_single_payment_sponsor_on_first_check_recheck_an ); let (estimated_storage, estimated_processing, actual_storage, actual_processing) = if shielded { - (171_126_000, 213_217_600, 155_142_000, 92_739_560) + (171_126_000, 213_217_600, 155_142_000, 92_785_480) } else { - (138_024_000, 117_541_680, 126_468_000, 3_870_440) + (138_024_000, 117_541_680, 126_468_000, 3_916_360) }; assert_eq!( result.execution_results().as_slice(), @@ -2158,39 +2158,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "6a823c2db94612652926e6b9c1ae7feec9c2547dfd4cfc4e474318da1c16d59c", - "a80509bb285ede04d5c921e432a6d38021bfa2b38e2855cf8f0e735fd20285d9", + "5824d7c4ef8a0a2647bb4279fd58b5af9f887f365a834128790540d34d0ba157", ), "valid-payment" => ( "a1308c6fe05b0a60cfee4fa0d43f7bda5856b02f7d11f3499fd1acd866328aa3", - "f6694678f1b835c476af2012fd217c67524f4f8d3ca5e4f9facbcec0b205081b", + "5973320e1f3fac680efe3c2b0f116bf0bc0999c3ebabee08c4ee1ffaaba748d4", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "4a1e0a2664ef5d529920510c003340cf5ecdc443d7478b08974a821076a7c8cc", + "e4999371a652f43c3c4b06f0147fddb1428ca80952b92b961a265fb072bd0854", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "a3cbb2d69941b402cb5fc9c564da81d4716a168355fe4ce4d3ae530d99e40247", + "ead0ffc03bdf4ac1f230a9905e8820362d49a146707965dcede24716c84e1fb5", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "20b47800939b417e433c695208924af0582df67c33edbf085f1fba297c9e6a06", + "6087b4932adbb3ebd731b3ca644436140cd12481b94c1f507217a5bbe5638428", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "5d22e1accc47d79db4b31a93e35a88b70e04412884239c4e04a05a0e2dde7f95", + "878b679cb356092ee0ab8b634d219fe4e220323a6f63433c6fc73fcd8edfda88", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "bc346f8d036f51e82f5f7af69a9ece71f34501e21c848f1d9df7e17c76dd847f", + "8d48331809845d9483dd4df498ef831f1c55ca732e7888f4a3bae93e9d316f9c", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "2b6f1d4e5b67d252c1aa76c466e716106107a98ec96d64eda488c37216611cd9", + "f3798a1c7dc8b233ec19132a73cbd294c6d2156cf2d8e0c29d53858647dc59ff", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "764422c7f8a87ad8165a7d38c795dcb57ea67d092fa751e7f0c50c06d52fbba9", + "1698e36bc7b12d83c418aebd057c358b517d973adbc0cd987ba12c89cd960ec6", ), _ => unreachable!("enumerated characterization case"), }; From 38938580c07c2d412b41d7a06e6bdf1ac93ef650 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 14:50:24 +0300 Subject: [PATCH 6/7] docs(platform): decision proposals state their fund and exactly one minimum; restore fee pin at 15 Co-Authored-By: Claude Opus 5.5 --- docs/protocol/decision-proposals.md | 11 ++++++++++- packages/decision-proposals-contract/README.md | 2 +- .../tests/restore_constraints.rs | 5 +++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md index 0c9d44b931a..30856a6331a 100644 --- a/docs/protocol/decision-proposals.md +++ b/docs/protocol/decision-proposals.md @@ -35,7 +35,7 @@ properties are rejected. | `minimumShare` | object of `numerator` (0 to 255), `denominator` (1 to 255) and `rounding`, all required | A minimum as a share of the masternode list's total voting power in the block that closes the poll, PoSe-banned masternodes left out | | `endDate` | integer, milliseconds since the Unix epoch, required | When voting closes | -A proposal sets `minimumVotingPower` or `minimumShare`. `rounding` says how +A proposal sets exactly one of `minimumVotingPower` and `minimumShare`; a proposal setting both or neither is refused. `rounding` says how the share rounds to a whole voting power: `down` (1/3 of 10 is 3), `up` (1/3 of 10 is 4), `downPlusOne` (1/3 of 9 is 4; half the total this way is a strict majority of it) or `upMinusOne` (1/3 of 9 is 2, never below zero). @@ -66,6 +66,15 @@ A masternode votes with 1 and an evonode with 4. The rule is written to the poll's stored info when it opens and cannot change after that; a vote names the poll by id alone. +The create states the fund it pays as its prefunded voting balance: the name +`proposal` and exactly the decision proposal fund. The transition builders fill +it in for a proposal, as they do for a contested document. Any other fund is +refused (`DecisionProposalFundMismatchError`, 11100), as is a rule outside the +limits below (`InvalidDecisionProposalRuleError`, 11101) and an end date outside +them (`DecisionProposalEndDateOutOfRangeError`, 41300). The mempool judges the +end date against the last block and a block against its own time, so a proposal +should not end at the very edge of the window. + Opening the poll prefunds the masternode votes on it: 0.5 Dash (`decision_proposal_fund_required_amount` in `VoteResolutionFundFees`, set in `FEE_VERSION4`, protocol version 15) leaves the owner's balance for the poll's diff --git a/packages/decision-proposals-contract/README.md b/packages/decision-proposals-contract/README.md index 881bb584b3a..dfb69e42a0b 100644 --- a/packages/decision-proposals-contract/README.md +++ b/packages/decision-proposals-contract/README.md @@ -25,7 +25,7 @@ undeletable, so masternodes vote on what the proposal said when it opened. | `minimumShare` | object | The least yes plus no voting power the proposal needs, as a share of the masternode list's total when the poll closes: `numerator` (0 to 255), `denominator` (1 to 255) and `rounding`, one of `down`, `up`, `downPlusOne` and `upMinusOne`, all three required | | `endDate` | integer, milliseconds since the Unix epoch, required | When voting closes | -A proposal sets `minimumVotingPower` or `minimumShare`. Additional properties +A proposal sets exactly one of `minimumVotingPower` and `minimumShare`; a proposal setting both or neither is refused. Additional properties are rejected, in the proposal and in `minimumShare`. Indexes: `byEndDate` (`endDate`, `$createdAt`) lists the proposals closing diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/restore_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/restore_constraints.rs index 25f815fe160..676f95277f8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/restore_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/restore_constraints.rs @@ -414,8 +414,9 @@ async fn should_preserve_the_rule_free_restore_fee_and_document() { FeeResult { storage_fee: 9_612_000, // from protocol version 15 the restore reads its contract stored compiled (format - // version 2), about 1.8 times its bytes - processing_fee: 1_396_960, + // version 2), about 1.8 times its bytes; the decision proposals contract registered at + // genesis from 15 adds a sibling to the contracts tree the restore's path crosses + processing_fee: 1_442_880, ..Default::default() } ); From 5cc4fa41f80fbcf6a0c889db1cba04607061bab8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 30 Oct 2026 20:25:57 +0300 Subject: [PATCH 7/7] feat(platform): decision proposals state their rule as two percentages and their text stays editable for a day - The rule is `passPercent` (50 to 80: yes must be strictly more than that share of yes plus no) and `minimumTurnoutPercent` (0 to 50 of the network's voting power, rounded up), replacing the supermajority fraction and the two kinds of minimum. The poll stores p/100 and a share minimum rounded up, as before. - `title`, `description` and `url` may be edited for min(1 day, run / 5) after creation (conditional `immutable` entries); the rule never changes; every version is kept (`documentsKeepHistory`). - The decision proposal fund counts in the balance a batch uses (`all_conflicting_index_collateral_voting_funds`), so an owner who cannot pay both the fund and the fees is refused before anything is written. - Review notes: the minimum-duration limit names `end_date_window`, the window documents when it is empty, and the poll-opening conversion says why it needs no earlier batch operations. - Re-pinned the shielded nullifier guard's settled hashes and root (the genesis contract changed) and the valid-payment characterization (the create action gained `decision_proposal`). Co-Authored-By: Claude Opus 5.5 --- docs/protocol/decision-proposals.md | 50 ++- .../decision-proposals-contract/README.md | 24 +- ...decision-proposals-contract-documents.json | 161 ++++--- .../decision-proposals-contract/src/lib.rs | 90 ++-- .../decision-proposals-contract/src/v1/mod.rs | 29 +- .../unit/decisionProposalsContract.spec.js | 126 ++---- packages/rs-dpp/src/decision_proposal/mod.rs | 393 ++++++++---------- .../invalid_decision_proposal_rule_error.rs | 6 +- .../voting/vote_polls/yes_no_vote_poll/mod.rs | 4 +- .../token/shielded/nullifier_batch_guard.rs | 22 +- .../yes_no_tests/decision_proposal_tests.rs | 339 +++++++++++---- .../state_transition_action/batch/v0/mod.rs | 17 +- .../util/batch/drive_op_batch/vote_poll.rs | 7 + .../src/version/system_limits/mod.rs | 3 +- .../rs-platform-version/src/version/v15.rs | 5 +- 15 files changed, 721 insertions(+), 555 deletions(-) diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md index 30856a6331a..a9e81102521 100644 --- a/docs/protocol/decision-proposals.md +++ b/docs/protocol/decision-proposals.md @@ -19,27 +19,29 @@ system contract cache and the trusted context provider serve it from 15 on. ## `proposal` -The type is immutable (`documentsMutable: false`) and undeletable -(`canBeDeleted: false`), so masternodes vote on what the proposal said when it -opened, and the proposal stays for as long as its result is read. Additional -properties are rejected. +The type is undeletable (`canBeDeleted: false`), so the proposal stays for as +long as its result is read. Its rule (`passPercent`, `minimumTurnoutPercent` +and `endDate`) is listed under `immutable` and never changes, so the poll always runs by the +rule it opened with. The owner may edit `title`, `description` and `url` until +`min(1 day, (endDate - $createdAt) / 5)` after the proposal was created: each is +an `immutable` entry whose condition, +`{ "greaterThan": [{ "subtract": ["$updatedAt", "$createdAt"] }, { "min": [86400000, { "divide": [{ "subtract": ["endDate", "$createdAt"] }, 5] }] }] }`, +freezes it once that time has passed. With proposals running at least a week, a +fifth is at least 1.4 days, so the day comes first today. An edit after the +window, or of the rule at any time, is refused +(`DocumentImmutablePropertyChangedError`, 40128). The type keeps its history +(`documentsKeepHistory: true`), so every version stays readable and a voter can +see the text as it was when it voted. Additional properties are rejected. | Property | Type | Meaning | | --- | --- | --- | | `title` | string, 1 to 128 characters and at most 512 bytes (`maxBytes`), required | The question, in one line | | `description` | string, 1 to 4096 characters and at most 4096 bytes, required | What a yes and a no mean | | `url` | string, `format: uri`, 1 to 512 characters and at most 512 bytes | Where the proposal is discussed | -| `supermajorityNumerator` | integer 1 to 255, required | With the denominator, the share of yes plus no that yes must exceed | -| `supermajorityDenominator` | integer 1 to 255, required | | -| `minimumVotingPower` | integer 0 to 4294967295 | A fixed minimum: the least yes plus no voting power the proposal needs | -| `minimumShare` | object of `numerator` (0 to 255), `denominator` (1 to 255) and `rounding`, all required | A minimum as a share of the masternode list's total voting power in the block that closes the poll, PoSe-banned masternodes left out | +| `passPercent` | integer 50 to 80, required | The share of yes plus no, in percent, that yes must be strictly more than | +| `minimumTurnoutPercent` | integer 0 to 50, required | The least yes plus no voting power the proposal needs, as a percent of the masternode list's total voting power in the block that closes the poll (PoSe-banned masternodes left out), rounded up | | `endDate` | integer, milliseconds since the Unix epoch, required | When voting closes | -A proposal sets exactly one of `minimumVotingPower` and `minimumShare`; a proposal setting both or neither is refused. `rounding` says how -the share rounds to a whole voting power: `down` (1/3 of 10 is 3), `up` (1/3 of -10 is 4), `downPlusOne` (1/3 of 9 is 4; half the total this way is a strict -majority of it) or `upMinusOne` (1/3 of 9 is 2, never below zero). - Indexes: | Index | Properties | Lists | @@ -57,10 +59,12 @@ purpose is `YesNoVotePollPurpose::DecisionProposal { document_id }`, the proposal's document id, and its id is derived from that purpose alone, so a proposal has exactly one poll. Its rule is read from the proposal: -- it passes when the yes voting power is strictly more than - `supermajorityNumerator / supermajorityDenominator` of the yes plus no voting - power, abstentions left out of both sides, -- and the yes plus no voting power reaches the minimum. +- it passes when the yes voting power is strictly more than `passPercent` + percent of the yes plus no voting power, abstentions left out of both sides + (a supermajority of `passPercent / 100`), so at 50 a tie fails, +- and the yes plus no voting power reaches `minimumTurnoutPercent` percent of + the network's total, rounded up (a share minimum of `minimumTurnoutPercent / + 100`, rounded `up`). A masternode votes with 1 and an evonode with 4. The rule is written to the poll's stored info when it opens and cannot change after that; a vote names the @@ -84,14 +88,14 @@ on it. What the votes leave is released as processing fees when the poll ends. ## Limits -The create is refused unless the rule is within these limits, which -`SystemLimits` carries: +The create is refused unless the rule is within these limits. The schema holds +the percentages to their ranges; the poll's own limits, which `SystemLimits` +carries, back them up: -| Limit | Value | Field | +| Limit | Value | Where | | --- | --- | --- | -| Threshold | at least a simple majority: the fraction is at least 1/2, and below 1 | `min_yes_no_vote_poll_supermajority_percent` (50) | -| Fixed minimum | at most 2,000 voting power | `max_yes_no_vote_poll_minimum_voting_power` | -| Share minimum | at most 50% of the network's total voting power | `max_yes_no_vote_poll_minimum_voting_power_percent_of_total` | +| Pass threshold | 50 to 80 percent | the schema; `min_yes_no_vote_poll_supermajority_percent` (50) for every poll | +| Turnout minimum | 0 to 50 percent of the network | the schema; `max_yes_no_vote_poll_minimum_voting_power_percent_of_total` (50) for every poll | | End date | one to four weeks after the block that opens the poll | `min_yes_no_vote_poll_duration_ms`, `max_yes_no_vote_poll_duration_ms` | A threshold of at least a simple majority means a proposal cannot pass with diff --git a/packages/decision-proposals-contract/README.md b/packages/decision-proposals-contract/README.md index dfb69e42a0b..2bbc876c5d6 100644 --- a/packages/decision-proposals-contract/README.md +++ b/packages/decision-proposals-contract/README.md @@ -11,22 +11,24 @@ with `getYesNoVotePollState`. ## `proposal` -The only document type. Any identity may create one. It is immutable and -undeletable, so masternodes vote on what the proposal said when it opened. +The only document type. Any identity may create one, and nobody may delete it. +Its rule (the threshold, the minimum and the end date) never changes, so +masternodes always vote by the rule the poll opened with. Its owner may edit the +`title`, `description` and `url` for a day after creating it, or for a fifth of +the time it runs if that is shorter; after that they freeze too. Every version +is kept (`documentsKeepHistory`), so a voter can see the text as it was when it +voted. | Property | Type | Meaning | | --- | --- | --- | | `title` | string, 1 to 128 characters and at most 512 bytes, required | The question, in one line | | `description` | string, 1 to 4096 characters and at most 4096 bytes, required | What a yes and a no mean | | `url` | string, a URI of 1 to 512 characters and at most 512 bytes | Where the proposal is discussed | -| `supermajorityNumerator` | integer 1 to 255, required | The proposal passes when yes is more than `supermajorityNumerator / supermajorityDenominator` of yes plus no | -| `supermajorityDenominator` | integer 1 to 255, required | | -| `minimumVotingPower` | integer 0 to 4294967295 | The least yes plus no voting power the proposal needs (a masternode votes with 1, an evonode with 4) | -| `minimumShare` | object | The least yes plus no voting power the proposal needs, as a share of the masternode list's total when the poll closes: `numerator` (0 to 255), `denominator` (1 to 255) and `rounding`, one of `down`, `up`, `downPlusOne` and `upMinusOne`, all three required | +| `passPercent` | integer 50 to 80, required | The proposal passes when yes is strictly more than this percent of yes plus no | +| `minimumTurnoutPercent` | integer 0 to 50, required | The least yes plus no voting power the proposal needs, as a percent of the masternode list's total voting power when the poll closes, rounded up (a masternode votes with 1, an evonode with 4) | | `endDate` | integer, milliseconds since the Unix epoch, required | When voting closes | -A proposal sets exactly one of `minimumVotingPower` and `minimumShare`; a proposal setting both or neither is refused. Additional properties -are rejected, in the proposal and in `minimumShare`. +Additional properties are rejected. Indexes: `byEndDate` (`endDate`, `$createdAt`) lists the proposals closing soonest first; `byOwner` (`$ownerId`, `$createdAt`) lists an identity's @@ -42,10 +44,8 @@ leave is released as processing fees when the poll ends. The poll's rule is read from the proposal and must be within the protocol's limits, or the create is refused: -- the threshold is at least a simple majority: `supermajorityNumerator / - supermajorityDenominator` is at least one half and below one; -- the minimum is at most 2,000 voting power, or at most 50% of the network's - total voting power; +- `passPercent` is 50 to 80: at 50, yes must be strictly more than no; +- `minimumTurnoutPercent` is at most 50; - the end date is one to four weeks after the block that opens the poll. See [the protocol guide](../../docs/protocol/decision-proposals.md) for diff --git a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json index 3b134efcd13..e53d3f8d135 100644 --- a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json +++ b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json @@ -1,7 +1,8 @@ { "proposal": { "type": "object", - "documentsMutable": false, + "documentsMutable": true, + "documentsKeepHistory": true, "canBeDeleted": false, "indices": [ { @@ -61,77 +62,129 @@ "description": "Where the proposal is discussed", "position": 2 }, - "supermajorityNumerator": { + "passPercent": { "type": "integer", - "minimum": 1, - "maximum": 255, - "description": "The proposal passes when yes is more than supermajorityNumerator / supermajorityDenominator of yes plus no", + "minimum": 50, + "maximum": 80, + "description": "The proposal passes when the yes voting power is strictly more than this percent of the yes plus no voting power", "position": 3 }, - "supermajorityDenominator": { - "type": "integer", - "minimum": 1, - "maximum": 255, - "position": 4 - }, - "minimumVotingPower": { + "minimumTurnoutPercent": { "type": "integer", "minimum": 0, - "maximum": 4294967295, - "description": "The least yes plus no voting power the proposal needs (a masternode votes with 1, an evonode with 4); set this or minimumShare", - "position": 5 - }, - "minimumShare": { - "type": "object", - "properties": { - "numerator": { - "type": "integer", - "minimum": 0, - "maximum": 255, - "position": 0 - }, - "denominator": { - "type": "integer", - "minimum": 1, - "maximum": 255, - "position": 1 - }, - "rounding": { - "type": "string", - "enum": [ - "down", - "up", - "downPlusOne", - "upMinusOne" - ], - "position": 2 - } - }, - "required": [ - "numerator", - "denominator", - "rounding" - ], - "additionalProperties": false, - "description": "The least yes plus no voting power the proposal needs, as a share of the masternode list's total when it closes; set this or minimumVotingPower", - "position": 6 + "maximum": 50, + "description": "The least yes plus no voting power the proposal needs, as a percent of the masternode list's total voting power when it closes, rounded up", + "position": 4 }, "endDate": { "type": "integer", "minimum": 0, "description": "When voting closes, in milliseconds since the Unix epoch", - "position": 7 + "position": 5 } }, "required": [ "$createdAt", + "$updatedAt", "title", "description", - "supermajorityNumerator", - "supermajorityDenominator", + "passPercent", + "minimumTurnoutPercent", "endDate" ], + "immutable": [ + "passPercent", + "minimumTurnoutPercent", + "endDate", + { + "property": "title", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + { + "min": [ + 86400000, + { + "divide": [ + { + "subtract": [ + "endDate", + "$createdAt" + ] + }, + 5 + ] + } + ] + } + ] + } + }, + { + "property": "description", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + { + "min": [ + 86400000, + { + "divide": [ + { + "subtract": [ + "endDate", + "$createdAt" + ] + }, + 5 + ] + } + ] + } + ] + } + }, + { + "property": "url", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + { + "min": [ + 86400000, + { + "divide": [ + { + "subtract": [ + "endDate", + "$createdAt" + ] + }, + 5 + ] + } + ] + } + ] + } + } + ], "additionalProperties": false, - "description": "A yes/no question put to the masternodes. Creating one opens its vote poll, funded by its owner" + "description": "A yes/no question put to the masternodes. Creating one opens its vote poll, funded by its owner. Its title, description and url may be edited for a day after it is created, or a fifth of the time it runs if that is shorter; its rule never changes" } } diff --git a/packages/decision-proposals-contract/src/lib.rs b/packages/decision-proposals-contract/src/lib.rs index ac9f9e3a7b6..a1d3f2aa5ac 100644 --- a/packages/decision-proposals-contract/src/lib.rs +++ b/packages/decision-proposals-contract/src/lib.rs @@ -44,6 +44,7 @@ pub fn load_documents_schemas(platform_version: &PlatformVersion) -> Result = share[minimum_share::ROUNDING]["enum"] - .as_array() - .expect("rounding is an enum") - .iter() - .filter_map(Value::as_str) - .collect(); - assert_eq!(roundings, minimum_share::ROUNDINGS); + let declared: BTreeSet<&str> = properties.keys().map(String::as_str).collect(); + assert_eq!( + declared, + BTreeSet::from([ + proposal::properties::TITLE, + proposal::properties::DESCRIPTION, + proposal::properties::URL, + proposal::properties::PASS_PERCENT, + proposal::properties::MINIMUM_TURNOUT_PERCENT, + proposal::properties::END_DATE, + ]) + ); + // A pass threshold of 50 to 80 percent, a turnout of at most half the network + let bounds = |property: &str| { + ( + properties[property]["minimum"].as_u64(), + properties[property]["maximum"].as_u64(), + ) + }; + assert_eq!( + bounds(proposal::properties::PASS_PERCENT), + (Some(50), Some(80)) + ); + assert_eq!( + bounds(proposal::properties::MINIMUM_TURNOUT_PERCENT), + (Some(0), Some(50)) + ); let indexes: Vec<&str> = proposal_type["indices"] .as_array() .expect("proposal has indexes") @@ -112,9 +109,36 @@ mod tests { proposal::indexes::BY_CREATED_AT ] ); - // What masternodes vote on cannot change after it opens. - assert_eq!(proposal_type["documentsMutable"], Value::Bool(false)); + // The rule masternodes vote by never changes; the text may be edited for a while, and + // every version is kept. A proposal is never deleted. + assert_eq!(proposal_type["documentsMutable"], Value::Bool(true)); + assert_eq!(proposal_type["documentsKeepHistory"], Value::Bool(true)); assert_eq!(proposal_type["canBeDeleted"], Value::Bool(false)); + let immutable = proposal_type["immutable"] + .as_array() + .expect("proposal lists immutable properties"); + for property in [ + proposal::properties::PASS_PERCENT, + proposal::properties::MINIMUM_TURNOUT_PERCENT, + proposal::properties::END_DATE, + ] { + assert!( + immutable.contains(&Value::String(property.to_string())), + "{property} never changes" + ); + } + for property in [ + proposal::properties::TITLE, + proposal::properties::DESCRIPTION, + proposal::properties::URL, + ] { + assert!( + immutable + .iter() + .any(|entry| entry["property"] == property && entry["when"].is_object()), + "{property} freezes once its edit window ends" + ); + } } #[test] diff --git a/packages/decision-proposals-contract/src/v1/mod.rs b/packages/decision-proposals-contract/src/v1/mod.rs index ad69acb437f..10db7484bde 100644 --- a/packages/decision-proposals-contract/src/v1/mod.rs +++ b/packages/decision-proposals-contract/src/v1/mod.rs @@ -3,8 +3,9 @@ use serde_json::Value; pub mod document_types { /// A yes/no question put to the masternodes. Creating one opens its vote poll, which its - /// owner funds; the poll's rule is read from the proposal. Immutable and undeletable, so - /// masternodes vote on what the proposal said when it was created. + /// owner funds; the poll's rule is read from the proposal and never changes. The owner may + /// edit the title, description and url for a day, or a fifth of the time the proposal runs + /// if that is shorter, and every version is kept. Undeletable. pub mod proposal { pub const NAME: &str = "proposal"; @@ -12,25 +13,13 @@ pub mod document_types { pub const TITLE: &str = "title"; pub const DESCRIPTION: &str = "description"; pub const URL: &str = "url"; - pub const SUPERMAJORITY_NUMERATOR: &str = "supermajorityNumerator"; - pub const SUPERMAJORITY_DENOMINATOR: &str = "supermajorityDenominator"; - /// A fixed minimum voting power. A proposal sets this or `MINIMUM_SHARE`. - pub const MINIMUM_VOTING_POWER: &str = "minimumVotingPower"; - /// A minimum voting power as a share of the masternode list's total when the poll - /// closes. A proposal sets this or `MINIMUM_VOTING_POWER`. - pub const MINIMUM_SHARE: &str = "minimumShare"; + /// The proposal passes when yes is strictly more than this percent of yes plus no + /// (50 to 80). + pub const PASS_PERCENT: &str = "passPercent"; + /// The least yes plus no voting power the proposal needs, as a percent of the + /// masternode list's total voting power when it closes, rounded up (0 to 50). + pub const MINIMUM_TURNOUT_PERCENT: &str = "minimumTurnoutPercent"; pub const END_DATE: &str = "endDate"; - - /// The keys of the `minimumShare` object. - pub mod minimum_share { - pub const NUMERATOR: &str = "numerator"; - pub const DENOMINATOR: &str = "denominator"; - /// One of `ROUNDINGS`. - pub const ROUNDING: &str = "rounding"; - - /// The values of `rounding`, as `VotingPowerRounding` names them in JSON. - pub const ROUNDINGS: [&str; 4] = ["down", "up", "downPlusOne", "upMinusOne"]; - } } pub mod indexes { diff --git a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js index 9e08dcb2ba4..c996249466c 100644 --- a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js +++ b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js @@ -55,24 +55,11 @@ describe('Decision Proposals Contract', () => { title: 'Should evonodes run the new indexer by default?', description: 'Yes: the next release turns it on. No: it stays opt-in.', url: 'https://www.dash.org/forum/threads/indexer-default.1/', - supermajorityNumerator: 2, - supermajorityDenominator: 3, - minimumVotingPower: 1000, + passPercent: 60, + minimumTurnoutPercent: 10, endDate: Date.now() + TWO_WEEKS_MS, }); - const rawProposalWithShare = () => { - const raw = rawProposal(); - delete raw.minimumVotingPower; - raw.minimumShare = { - numerator: 1, - denominator: 10, - rounding: 'up', - }; - - return raw; - }; - it('should have a valid contract definition', async () => { expect(() => dpp.dataContract.create( identityId, @@ -89,14 +76,10 @@ describe('Decision Proposals Contract', () => { }); describe('proposal', () => { - it('should be valid with a fixed minimum voting power', () => { + it('should be valid', () => { expect(validate(rawProposal()).isValid()).to.be.true(); }); - it('should be valid with a minimum share of the network', () => { - expect(validate(rawProposalWithShare()).isValid()).to.be.true(); - }); - it('should not need a url', () => { const raw = rawProposal(); delete raw.url; @@ -104,11 +87,19 @@ describe('Decision Proposals Contract', () => { expect(validate(raw).isValid()).to.be.true(); }); - it('should be immutable and undeletable, so masternodes vote on what it said when it opened', () => { + it('should keep its rule fixed, its text editable for a while and every version', () => { const { proposal } = decisionProposalsContractDocumentsSchema; - expect(proposal.documentsMutable).to.be.false(); + expect(proposal.documentsMutable).to.be.true(); + expect(proposal.documentsKeepHistory).to.be.true(); expect(proposal.canBeDeleted).to.be.false(); + expect(proposal.immutable.filter((entry) => typeof entry === 'string')).to.deep.equal([ + 'passPercent', + 'minimumTurnoutPercent', + 'endDate', + ]); + expect(proposal.immutable.filter((entry) => typeof entry === 'object') + .map(({ property }) => property)).to.deep.equal(['title', 'description', 'url']); }); it('should index proposals by end date, by owner and by creation time', () => { @@ -122,7 +113,7 @@ describe('Decision Proposals Contract', () => { indices.forEach(({ unique }) => expect(unique).to.be.undefined()); }); - ['title', 'description', 'supermajorityNumerator', 'supermajorityDenominator', 'endDate'].forEach((property) => { + ['title', 'description', 'passPercent', 'minimumTurnoutPercent', 'endDate'].forEach((property) => { it(`should require ${property}`, () => { const raw = rawProposal(); delete raw[property]; @@ -201,85 +192,38 @@ describe('Decision Proposals Contract', () => { }); }); - describe('supermajority', () => { - ['supermajorityNumerator', 'supermajorityDenominator'].forEach((property) => { - it(`should refuse a ${property} of 0`, () => { - const raw = rawProposal(); - raw[property] = 0; - - const error = expectJsonSchemaError(validate(raw)); - - expect(error.keyword).to.equal('minimum'); + // [property, lowest, highest]: a pass threshold of 50 to 80 percent, a turnout of at most + // half the network + [['passPercent', 50, 80], ['minimumTurnoutPercent', 0, 50]].forEach(([property, lowest, highest]) => { + describe(property, () => { + it(`should accept ${lowest} and ${highest}`, () => { + [lowest, highest].forEach((value) => { + const raw = rawProposal(); + raw[property] = value; + + expect(validate(raw).isValid()).to.be.true(); + }); }); - it(`should refuse a ${property} over 255`, () => { + it(`should refuse ${highest + 1}`, () => { const raw = rawProposal(); - raw[property] = 256; + raw[property] = highest + 1; const error = expectJsonSchemaError(validate(raw)); expect(error.keyword).to.equal('maximum'); }); - }); - }); - - describe('minimumVotingPower', () => { - it('should refuse a value over u32', () => { - const raw = rawProposal(); - raw.minimumVotingPower = 4294967296; - - expect(validate(raw).isValid()).to.be.false(); - }); - }); - - describe('minimumShare', () => { - ['down', 'up', 'downPlusOne', 'upMinusOne'].forEach((rounding) => { - it(`should accept the rounding ${rounding}`, () => { - const raw = rawProposalWithShare(); - raw.minimumShare.rounding = rounding; - - expect(validate(raw).isValid()).to.be.true(); - }); - }); - - it('should refuse an unknown rounding', () => { - const raw = rawProposalWithShare(); - raw.minimumShare.rounding = 'nearest'; - - const error = expectJsonSchemaError(validate(raw)); - expect(error.keyword).to.equal('enum'); - }); - - ['numerator', 'denominator', 'rounding'].forEach((property) => { - it(`should require ${property}`, () => { - const raw = rawProposalWithShare(); - delete raw.minimumShare[property]; + if (lowest > 0) { + it(`should refuse ${lowest - 1}`, () => { + const raw = rawProposal(); + raw[property] = lowest - 1; - const error = expectJsonSchemaError(validate(raw)); - - expect(error.keyword).to.equal('required'); - expect(error.params.missingProperty).to.equal(property); - }); - }); - - it('should refuse a denominator of 0', () => { - const raw = rawProposalWithShare(); - raw.minimumShare.denominator = 0; - - const error = expectJsonSchemaError(validate(raw)); - - expect(error.keyword).to.equal('minimum'); - }); - - it('should not have additional properties', () => { - const raw = rawProposalWithShare(); - raw.minimumShare.total = 3000; - - const error = expectJsonSchemaError(validate(raw)); + const error = expectJsonSchemaError(validate(raw)); - expect(error.keyword).to.equal('additionalProperties'); - expect(error.params.additionalProperties).to.deep.equal(['total']); + expect(error.keyword).to.equal('minimum'); + }); + } }); }); }); diff --git a/packages/rs-dpp/src/decision_proposal/mod.rs b/packages/rs-dpp/src/decision_proposal/mod.rs index 149965c2a42..335fb0ac957 100644 --- a/packages/rs-dpp/src/decision_proposal/mod.rs +++ b/packages/rs-dpp/src/decision_proposal/mod.rs @@ -41,13 +41,11 @@ pub const DECISION_PROPOSAL_FUND_NAME: &str = PROPOSAL_DOCUMENT_TYPE_NAME; /// The properties of a proposal the poll is read from. pub mod properties { - pub const SUPERMAJORITY_NUMERATOR: &str = "supermajorityNumerator"; - pub const SUPERMAJORITY_DENOMINATOR: &str = "supermajorityDenominator"; - pub const MINIMUM_VOTING_POWER: &str = "minimumVotingPower"; - pub const MINIMUM_SHARE: &str = "minimumShare"; - pub const MINIMUM_SHARE_NUMERATOR: &str = "minimumShare.numerator"; - pub const MINIMUM_SHARE_DENOMINATOR: &str = "minimumShare.denominator"; - pub const MINIMUM_SHARE_ROUNDING: &str = "minimumShare.rounding"; + /// The proposal passes when yes is strictly more than this percent of yes plus no. + pub const PASS_PERCENT: &str = "passPercent"; + /// The least yes plus no voting power the proposal needs, as a percent of the masternode + /// list's total voting power when the poll closes, rounded up. + pub const MINIMUM_TURNOUT_PERCENT: &str = "minimumTurnoutPercent"; pub const END_DATE: &str = "endDate"; } @@ -121,109 +119,41 @@ impl DecisionProposalVotePoll { } } - /// Version 0: the threshold is `supermajorityNumerator / supermajorityDenominator`, and the - /// minimum is `minimumVotingPower` or `minimumShare`, exactly one of them, each held to - /// [`YesNoVotePoll::validate_parameters`]. + /// Version 0: the poll passes when yes is strictly more than `passPercent` percent of yes + /// plus no (a supermajority of `passPercent / 100`), with yes plus no at least + /// `minimumTurnoutPercent` percent of the masternode list's total voting power when it + /// closes, rounded up. The rule is held to [`YesNoVotePoll::validate_parameters`]; the + /// contract's schema holds the percentages to 50 to 80 and 0 to 50. fn from_proposal_v0( document_id: Identifier, properties: &BTreeMap, platform_version: &PlatformVersion, ) -> ConsensusValidationResult { - let refused = |reason: String| { - ConsensusValidationResult::new_with_error( - InvalidDecisionProposalRuleError::new(document_id, reason).into(), - ) - }; let read = || -> Result { - let integer = |path: &str| { - properties + let percent = |path: &str| -> Result { + let value = properties .get_optional_integer_at_path::(path) - .map_err(|error| format!("`{path}` is not a whole number: {error}")) - }; - let narrow = |path: &str, value: Option| -> Result, String> { - value - .map(|value| { - u8::try_from(value) - .map_err(|_| format!("`{path}` is at most 255, got {value}")) - }) - .transpose() + .map_err(|error| format!("`{path}` is not a whole number: {error}"))? + .ok_or_else(|| format!("`{path}` is required"))?; + u8::try_from(value).map_err(|_| format!("`{path}` is at most 255, got {value}")) }; - let required = |path: &str, value: Option| { - value.ok_or_else(|| format!("`{path}` is required")) - }; - let supermajority_numerator = required( - properties::SUPERMAJORITY_NUMERATOR, - narrow( - properties::SUPERMAJORITY_NUMERATOR, - integer(properties::SUPERMAJORITY_NUMERATOR)?, - )?, - )?; - let supermajority_denominator = required( - properties::SUPERMAJORITY_DENOMINATOR, - narrow( - properties::SUPERMAJORITY_DENOMINATOR, - integer(properties::SUPERMAJORITY_DENOMINATOR)?, - )?, - )?; - let minimum_voting_power = integer(properties::MINIMUM_VOTING_POWER)?; - let minimum_share = properties - .get(properties::MINIMUM_SHARE) - .filter(|share| !share.is_null()); - let minimum_voting_power = match (minimum_voting_power, minimum_share) { - (Some(voting_power), None) => YesNoMinimumVotingPower::Absolute { - voting_power: u32::try_from(voting_power).map_err(|_| { - format!( - "`{}` is at most {}, got {voting_power}", - properties::MINIMUM_VOTING_POWER, - u32::MAX - ) - })?, - }, - (None, Some(_)) => YesNoMinimumVotingPower::FractionOfTotal { - numerator: required( - properties::MINIMUM_SHARE_NUMERATOR, - narrow( - properties::MINIMUM_SHARE_NUMERATOR, - integer(properties::MINIMUM_SHARE_NUMERATOR)?, - )?, - )?, - denominator: required( - properties::MINIMUM_SHARE_DENOMINATOR, - narrow( - properties::MINIMUM_SHARE_DENOMINATOR, - integer(properties::MINIMUM_SHARE_DENOMINATOR)?, - )?, - )?, - rounding: rounding_from_name( - properties - .get_str_at_path(properties::MINIMUM_SHARE_ROUNDING) - .map_err(|_| { - format!("`{}` is required", properties::MINIMUM_SHARE_ROUNDING) - })?, - )?, - }, - (Some(_), Some(_)) => { - return Err(format!( - "set `{}` or `{}`, not both", - properties::MINIMUM_VOTING_POWER, - properties::MINIMUM_SHARE - )) - } - (None, None) => { - return Err(format!( - "set `{}` or `{}`", - properties::MINIMUM_VOTING_POWER, - properties::MINIMUM_SHARE - )) - } - }; - let end_date = integer(properties::END_DATE)? + let pass_percent = percent(properties::PASS_PERCENT)?; + let minimum_turnout_percent = percent(properties::MINIMUM_TURNOUT_PERCENT)?; + let end_date = properties + .get_optional_integer_at_path::(properties::END_DATE) + .map_err(|error| { + format!("`{}` is not a whole number: {error}", properties::END_DATE) + })? .ok_or_else(|| format!("`{}` is required", properties::END_DATE))?; let vote_poll = YesNoVotePoll { purpose: YesNoVotePollPurpose::DecisionProposal { document_id }, - supermajority_numerator, - supermajority_denominator, - minimum_voting_power, + supermajority_numerator: pass_percent, + supermajority_denominator: 100, + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: minimum_turnout_percent, + denominator: 100, + rounding: VotingPowerRounding::Up, + }, }; vote_poll .validate_parameters(platform_version) @@ -240,65 +170,37 @@ impl DecisionProposalVotePoll { Ok(decision_proposal_vote_poll) => { ConsensusValidationResult::new_with_data(decision_proposal_vote_poll) } - Err(reason) => refused(reason), + Err(reason) => ConsensusValidationResult::new_with_error( + InvalidDecisionProposalRuleError::new(document_id, reason).into(), + ), } } } -/// The rounding a proposal's `minimumShare.rounding` names, as `VotingPowerRounding` names it in -/// JSON. -fn rounding_from_name(name: &str) -> Result { - match name { - "down" => Ok(VotingPowerRounding::Down), - "up" => Ok(VotingPowerRounding::Up), - "downPlusOne" => Ok(VotingPowerRounding::DownPlusOne), - "upMinusOne" => Ok(VotingPowerRounding::UpMinusOne), - other => Err(format!( - "`{}` is one of down, up, downPlusOne and upMinusOne, got {other}", - properties::MINIMUM_SHARE_ROUNDING - )), - } -} - #[cfg(test)] mod tests { use super::*; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; - fn proposal(minimum: (&str, Value)) -> BTreeMap { + fn proposal(pass_percent: u8, minimum_turnout_percent: u8) -> BTreeMap { BTreeMap::from([ ( "title".to_string(), Value::Text("Raise the limit?".to_string()), ), ( - properties::SUPERMAJORITY_NUMERATOR.to_string(), - Value::U8(2), + properties::PASS_PERCENT.to_string(), + Value::U8(pass_percent), ), ( - properties::SUPERMAJORITY_DENOMINATOR.to_string(), - Value::U8(3), + properties::MINIMUM_TURNOUT_PERCENT.to_string(), + Value::U8(minimum_turnout_percent), ), - (minimum.0.to_string(), minimum.1), (properties::END_DATE.to_string(), Value::U64(5_000)), ]) } - fn share(numerator: u8, denominator: u8, rounding: &str) -> Value { - Value::Map(vec![ - (Value::Text("numerator".to_string()), Value::U8(numerator)), - ( - Value::Text("denominator".to_string()), - Value::U8(denominator), - ), - ( - Value::Text("rounding".to_string()), - Value::Text(rounding.to_string()), - ), - ]) - } - fn refusal(result: ConsensusValidationResult) -> String { match result.errors.as_slice() { [ConsensusError::BasicError(BasicError::InvalidDecisionProposalRuleError(error))] => { @@ -314,7 +216,7 @@ mod tests { let document_id = Identifier::new([7; 32]); let result = DecisionProposalVotePoll::from_proposal( document_id, - &proposal((properties::MINIMUM_VOTING_POWER, Value::U32(400))), + &proposal(60, 10), platform_version, ) .expect("active at 15"); @@ -323,30 +225,54 @@ mod tests { DecisionProposalVotePoll { vote_poll: YesNoVotePoll { purpose: YesNoVotePollPurpose::DecisionProposal { document_id }, - supermajority_numerator: 2, - supermajority_denominator: 3, - minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 400 }, + supermajority_numerator: 60, + supermajority_denominator: 100, + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 10, + denominator: 100, + rounding: VotingPowerRounding::Up, + }, }, end_date: 5_000, } ); - let result = DecisionProposalVotePoll::from_proposal( - document_id, - &proposal((properties::MINIMUM_SHARE, share(1, 3, "downPlusOne"))), + } + + /// Yes must be strictly more than the percent: at 50, a tie fails and one more yes passes. + #[test] + fn should_pass_only_strictly_above_the_pass_percent() { + let platform_version = PlatformVersion::latest(); + let vote_poll = DecisionProposalVotePoll::from_proposal( + Identifier::new([7; 32]), + &proposal(50, 0), platform_version, ) - .expect("active at 15"); + .expect("active at 15") + .into_data() + .expect("a valid rule") + .vote_poll; + let passes = |yes, no| { + vote_poll + .passes(yes, no, 1_000, platform_version) + .expect("15") + }; + assert!(!passes(5, 5)); + assert!(passes(6, 5)); + // A turnout minimum of 10% of 95 rounds up to 10 + let vote_poll = DecisionProposalVotePoll::from_proposal( + Identifier::new([7; 32]), + &proposal(50, 10), + platform_version, + ) + .expect("active at 15") + .into_data() + .expect("a valid rule") + .vote_poll; assert_eq!( - result - .into_data() - .expect("a valid rule") - .vote_poll - .minimum_voting_power, - YesNoMinimumVotingPower::FractionOfTotal { - numerator: 1, - denominator: 3, - rounding: VotingPowerRounding::DownPlusOne, - } + vote_poll + .required_voting_power(95, platform_version) + .expect("15"), + 10 ); } @@ -354,59 +280,19 @@ mod tests { fn should_refuse_a_rule_outside_the_limits() { let platform_version = PlatformVersion::latest(); let document_id = Identifier::new([7; 32]); - let refused = |properties: BTreeMap| { - refusal( - DecisionProposalVotePoll::from_proposal(document_id, &properties, platform_version) - .expect("active at 15"), - ) + let read = |properties: BTreeMap| { + DecisionProposalVotePoll::from_proposal(document_id, &properties, platform_version) + .expect("active at 15") }; - - // Neither minimum, and both. - let mut neither = proposal((properties::MINIMUM_VOTING_POWER, Value::U32(1))); - neither.remove(properties::MINIMUM_VOTING_POWER); - assert!(refused(neither).contains("set `minimumVotingPower` or `minimumShare`")); - let mut both = proposal((properties::MINIMUM_VOTING_POWER, Value::U32(1))); - both.insert(properties::MINIMUM_SHARE.to_string(), share(1, 3, "down")); - assert!(refused(both).contains("not both")); - - // A threshold below a simple majority. - let mut below_majority = proposal((properties::MINIMUM_VOTING_POWER, Value::U32(1))); - below_majority.insert( - properties::SUPERMAJORITY_NUMERATOR.to_string(), - Value::U8(127), - ); - below_majority.insert( - properties::SUPERMAJORITY_DENOMINATOR.to_string(), - Value::U8(255), - ); - assert!(refused(below_majority).contains("at least 50%")); - - // A minimum above its cap: 2,000 voting power, half the network. - let max_voting_power = platform_version - .system_limits - .max_yes_no_vote_poll_minimum_voting_power; - assert!(DecisionProposalVotePoll::from_proposal( - document_id, - &proposal(( - properties::MINIMUM_VOTING_POWER, - Value::U32(max_voting_power) - )), - platform_version, - ) - .expect("active at 15") - .is_valid()); - refused(proposal(( - properties::MINIMUM_VOTING_POWER, - Value::U32(max_voting_power + 1), - ))); - assert!(DecisionProposalVotePoll::from_proposal( - document_id, - &proposal((properties::MINIMUM_SHARE, share(1, 2, "up"))), - platform_version, - ) - .expect("active at 15") - .is_valid()); - refused(proposal((properties::MINIMUM_SHARE, share(51, 100, "up")))); + // The schema holds a proposal to 50-80 and 0-50; the poll's own rules still refuse what + // no poll may run with, whatever a schema allows. + assert!(refusal(read(proposal(49, 0))).contains("at least 50%")); + assert!(refusal(read(proposal(100, 0))).contains("below its denominator")); + assert!(refusal(read(proposal(60, 51))).contains("at most 50%")); + assert!(read(proposal(50, 50)).is_valid()); + let mut no_turnout = proposal(60, 0); + no_turnout.remove(properties::MINIMUM_TURNOUT_PERCENT); + assert!(refusal(read(no_turnout)).contains("is required")); } #[test] @@ -415,7 +301,7 @@ mod tests { assert!(matches!( DecisionProposalVotePoll::from_proposal( Identifier::new([7; 32]), - &proposal((properties::MINIMUM_VOTING_POWER, Value::U32(1))), + &proposal(60, 10), platform_version, ), Err(ProtocolError::UnknownVersionError(_)) @@ -449,26 +335,87 @@ mod tests { decision_proposals_contract::ID ); assert_eq!(PROPOSAL_DOCUMENT_TYPE_NAME, proposal::NAME); + assert_eq!(properties::PASS_PERCENT, proposal::properties::PASS_PERCENT); assert_eq!( - properties::SUPERMAJORITY_NUMERATOR, - proposal::properties::SUPERMAJORITY_NUMERATOR - ); - assert_eq!( - properties::SUPERMAJORITY_DENOMINATOR, - proposal::properties::SUPERMAJORITY_DENOMINATOR - ); - assert_eq!( - properties::MINIMUM_VOTING_POWER, - proposal::properties::MINIMUM_VOTING_POWER - ); - assert_eq!( - properties::MINIMUM_SHARE, - proposal::properties::MINIMUM_SHARE + properties::MINIMUM_TURNOUT_PERCENT, + proposal::properties::MINIMUM_TURNOUT_PERCENT ); assert_eq!(properties::END_DATE, proposal::properties::END_DATE); - for name in proposal::properties::minimum_share::ROUNDINGS { - assert!(rounding_from_name(name).is_ok(), "{name} is a rounding"); + } + + /// A proposal's title, description and url freeze a day after it is created, or after a + /// fifth of the time it runs if that is shorter (which the one-week minimum makes it never + /// is today); its rule never changes. + #[cfg(feature = "decision-proposals-contract")] + #[test] + fn should_freeze_the_text_of_a_proposal_after_a_day_or_a_fifth_of_its_run() { + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, + }; + use crate::data_contract::document_type::property_constraints::DocumentSystemValues; + use crate::system_data_contracts::{load_system_data_contract, SystemDataContract}; + + const DAY_MS: u64 = 86_400_000; + let platform_version = PlatformVersion::latest(); + let contract = + load_system_data_contract(SystemDataContract::DecisionProposals, platform_version) + .expect("the contract loads at 15"); + let document_type = contract + .document_type_for_name(PROPOSAL_DOCUMENT_TYPE_NAME) + .expect("the proposal type"); + let created_at = 1_000; + let frozen = |property: &str, run: u64, edited_after: u64| { + document_type.immutable_field_conditions()[property] + .holds( + &Value::Map(vec![( + Value::Text(properties::END_DATE.to_string()), + Value::U64(created_at + run), + )]), + &DocumentSystemValues { + created_at: Some(created_at), + updated_at: Some(created_at + edited_after), + ..Default::default() + }, + ) + .expect("the condition evaluates") + }; + for property in ["title", "description", "url"] { + // Two weeks: a fifth is 2.8 days, so a day comes first + assert!( + !frozen(property, 14 * DAY_MS, DAY_MS), + "{property} at a day" + ); + assert!( + frozen(property, 14 * DAY_MS, DAY_MS + 1), + "{property} after a day" + ); + // Two days: a fifth, 9.6 hours, comes first + let fifth = 2 * DAY_MS / 5; + assert!( + !frozen(property, 2 * DAY_MS, fifth), + "{property} at a fifth" + ); + assert!( + frozen(property, 2 * DAY_MS, fifth + 1), + "{property} after a fifth" + ); + } + for property in [ + properties::PASS_PERCENT, + properties::MINIMUM_TURNOUT_PERCENT, + properties::END_DATE, + ] { + assert!( + document_type.immutable_fields().contains(property) + && !document_type + .immutable_field_conditions() + .contains_key(property), + "{property} never changes" + ); } + assert!(document_type.documents_mutable()); + assert!(document_type.documents_keep_history()); } /// A create built from a proposal document, as the SDKs build one, states the decision @@ -489,7 +436,7 @@ mod tests { .document_type_for_name(PROPOSAL_DOCUMENT_TYPE_NAME) .expect("the proposal type"); let document: Document = DocumentV0 { - properties: proposal((properties::MINIMUM_VOTING_POWER, Value::U32(1))), + properties: proposal(60, 10), ..Default::default() } .into(); diff --git a/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/invalid_decision_proposal_rule_error.rs b/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/invalid_decision_proposal_rule_error.rs index 6c025464552..541b702359c 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/invalid_decision_proposal_rule_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/invalid_decision_proposal_rule_error.rs @@ -9,8 +9,10 @@ use platform_value::Identifier; use thiserror::Error; /// A decision proposal (protocol version 15) whose rule makes no yes/no poll the system limits -/// allow (11101): a pass threshold below a simple majority or not below one, both or neither of -/// `minimumVotingPower` and `minimumShare`, or a minimum above its cap. `reason` says which. +/// allow (11101): a `passPercent` below 50 or not below 100, a `minimumTurnoutPercent` above the +/// share of the network a poll may require, or either missing. The contract's schema refuses +/// these first, so this is what remains if the schema and the poll limits ever disagree. +/// `reason` says which. #[derive( Error, Debug, diff --git a/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs b/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs index ab6d5c5a2fe..65b788f53d6 100644 --- a/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs +++ b/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs @@ -355,7 +355,9 @@ impl YesNoVotePoll { /// - `platform_version`: the platform version, whose system limits bound the window. /// /// # Returns - /// The earliest and the latest end date, both allowed. + /// The earliest and the latest end date, both allowed. The window is empty (its start past + /// its end, so it contains no date) when the opening block is so late that no end date is + /// both far enough after it and at most `i64::MAX`; no block time comes near that. pub fn end_date_window( opening_time_ms: TimestampMillis, platform_version: &PlatformVersion, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index b82be18ea49..f986cd49cb0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -734,7 +734,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "20aee245dfbc8e2e11ebc96f8611a111c2cf2d2e5fefda74f9ed200e6963d733" + "a1aba6416f1f58b9a2ae5bdad21b4aacba05a20ea4d698fb98d6aed180d2be57" ); assert_eq!( pool.platform @@ -2158,39 +2158,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "6a823c2db94612652926e6b9c1ae7feec9c2547dfd4cfc4e474318da1c16d59c", - "5824d7c4ef8a0a2647bb4279fd58b5af9f887f365a834128790540d34d0ba157", + "2fd1b754af2ac9b95cb06a74e36a2481c70ab460db1a8ddd5711033c1baab1a8", ), "valid-payment" => ( - "a1308c6fe05b0a60cfee4fa0d43f7bda5856b02f7d11f3499fd1acd866328aa3", - "5973320e1f3fac680efe3c2b0f116bf0bc0999c3ebabee08c4ee1ffaaba748d4", + "20c5acd175bdb316cd8697e1945d83e9af2f0ad0135f7e1278ad822f0905f5ee", + "e59a9cc954d4af978d3c798895b502c649583a61c51e9fc8ee52ee8a28de0902", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "e4999371a652f43c3c4b06f0147fddb1428ca80952b92b961a265fb072bd0854", + "1eaeb4e54f91a4c95b41fc2966c8d9fce2b27ee01ade95fbeab66deeb2ba3859", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "ead0ffc03bdf4ac1f230a9905e8820362d49a146707965dcede24716c84e1fb5", + "c7495c527f115ac6a78f8c709b1ac599a9ed683339f739a7e444cce767a180d7", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "6087b4932adbb3ebd731b3ca644436140cd12481b94c1f507217a5bbe5638428", + "0143d7edf2336ab89b1e83485eceeb29285d38492e16d8ae3645a1fa866eb881", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "878b679cb356092ee0ab8b634d219fe4e220323a6f63433c6fc73fcd8edfda88", + "d559cb03752fa942008a30d52efb82584b49d68cc64aebabfacf01bf72d48f48", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "8d48331809845d9483dd4df498ef831f1c55ca732e7888f4a3bae93e9d316f9c", + "7ecda0074e06c9c66c7f856680e06d2c9bd391ea8746d3ed90263580b665c115", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "f3798a1c7dc8b233ec19132a73cbd294c6d2156cf2d8e0c29d53858647dc59ff", + "94961c4ec632dca33b79cffcbf9d08981fc77142d392118c21d37a9055feaacb", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "1698e36bc7b12d83c418aebd057c358b517d973adbc0cd987ba12c89cd960ec6", + "78bafb1ae5c4a7c804421ac3afbb192eb35a4f1bc093e9a02f1130d8c00dd4c3", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests/decision_proposal_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests/decision_proposal_tests.rs index fa48dbd179c..c21ba8b8252 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests/decision_proposal_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests/decision_proposal_tests.rs @@ -6,12 +6,14 @@ use super::*; use crate::execution::validation::state_transition::state_transitions::tests::setup_identity; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::DataContract; use dpp::decision_proposal::{ decision_proposal_required_fund, DecisionProposalVotePoll, DECISION_PROPOSAL_FUND_NAME, PROPOSAL_DOCUMENT_TYPE_NAME, }; -use dpp::document::{Document, DocumentV0, DocumentV0Getters}; +use dpp::document::document_methods::DocumentMethodsV0; +use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; use dpp::fee::fee_result::FeeResult; use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; @@ -19,6 +21,7 @@ use dpp::identity::{Identity, SecurityLevel}; use dpp::platform_value::{Bytes32, Value}; use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransitionV0; +use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::batch_transition::{ BatchTransition, BatchTransitionV0, DocumentCreateTransition, }; @@ -63,8 +66,8 @@ fn decision_proposals_contract( .expect("expected the decision proposals contract at genesis") } -/// A proposal ending at `end_date` that passes on more than half of yes plus no, with at least -/// 5 voting power cast. +/// A proposal ending at `end_date` that passes on strictly more than half of yes plus no, with +/// at least half the network's voting power cast. fn proposal(title: &str, end_date: u64) -> BTreeMap { BTreeMap::from([ ("title".to_string(), Value::Text(title.to_string())), @@ -72,9 +75,8 @@ fn proposal(title: &str, end_date: u64) -> BTreeMap { "description".to_string(), Value::Text("Yes raises it, no keeps it.".to_string()), ), - ("supermajorityNumerator".to_string(), Value::U8(1)), - ("supermajorityDenominator".to_string(), Value::U8(2)), - ("minimumVotingPower".to_string(), Value::U32(5)), + ("passPercent".to_string(), Value::U8(50)), + ("minimumTurnoutPercent".to_string(), Value::U8(50)), ("endDate".to_string(), Value::U64(end_date)), ]) } @@ -209,6 +211,40 @@ fn process_refused( } } +/// A serialized edit of `proposal` by `proposer` (its owner) setting `changes`, its revision +/// bumped. +async fn edit_transition( + contract: &DataContract, + proposer: &mut Proposer, + mut proposal: Document, + changes: &[(&str, Value)], + platform_version: &PlatformVersion, +) -> Vec { + for (property, value) in changes { + proposal.set(property, value.clone()); + } + proposal.increment_revision().expect("revision increments"); + let nonce = proposer.next_nonce; + proposer.next_nonce += 1; + BatchTransition::new_document_replacement_transition_from_document( + proposal, + contract + .document_type_for_name(PROPOSAL_DOCUMENT_TYPE_NAME) + .expect("expected the proposal document type"), + &proposer.key, + nonce, + 0, + None, + &proposer.signer, + platform_version, + None, + ) + .await + .expect("expected to build the edit") + .serialize_to_bytes() + .expect("expected to serialize the edit") +} + /// The poll the proposal `document_id` holding `properties` opens. fn proposal_poll( document_id: Identifier, @@ -395,96 +431,60 @@ async fn should_refuse_a_proposal_whose_rule_is_outside_the_limits() { let contract = decision_proposals_contract(&platform, platform_version); let mut rng = StdRng::seed_from_u64(0xDEC1_5106); let mut proposer = proposer(&mut platform, 3); - let max_voting_power = platform_version - .system_limits - .max_yes_no_vote_poll_minimum_voting_power; - - let below_majority = { - let mut properties = proposal("Below a majority", END_DATE); - properties.insert("supermajorityNumerator".to_string(), Value::U8(1)); - properties.insert("supermajorityDenominator".to_string(), Value::U8(3)); - properties - }; - let unanimity = { - let mut properties = proposal("Unanimity", END_DATE); - properties.insert("supermajorityNumerator".to_string(), Value::U8(2)); - properties.insert("supermajorityDenominator".to_string(), Value::U8(2)); - properties - }; - let too_high_minimum = { - let mut properties = proposal("Too high a minimum", END_DATE); - properties.insert( - "minimumVotingPower".to_string(), - Value::U32(max_voting_power + 1), - ); - properties - }; - let both_minimums = { - let mut properties = proposal("Both minimums", END_DATE); - properties.insert( - "minimumShare".to_string(), - Value::Map(vec![ - (Value::Text("numerator".to_string()), Value::U8(1)), - (Value::Text("denominator".to_string()), Value::U8(3)), - ( - Value::Text("rounding".to_string()), - Value::Text("up".to_string()), - ), - ]), - ); - properties - }; - let no_minimum = { - let mut properties = proposal("No minimum", END_DATE); - properties.remove("minimumVotingPower"); + + // Out of range: a pass threshold below 50 or above 80 percent, a turnout above half the + // network, a rule left out. The contract's schema refuses each. + let with = |property: &str, value: Option| { + let mut properties = proposal("Out of range", END_DATE); + match value { + Some(value) => properties.insert(property.to_string(), Value::U8(value)), + None => properties.remove(property), + }; properties }; - for properties in [ - below_majority, - unanimity, - too_high_minimum, - both_minimums, - no_minimum, + for (property, value) in [ + ("passPercent", Some(49)), + ("passPercent", Some(81)), + ("passPercent", None), + ("minimumTurnoutPercent", Some(51)), + ("minimumTurnoutPercent", None), ] { - let title = properties["title"].clone(); let (transition, document_id) = proposal_transition( &contract, &mut proposer, - properties, + with(property, value), None, &mut rng, platform_version, ) .await; - let error = process_refused(&platform, transition, 0, platform_version); assert_matches!( - error, - ConsensusError::BasicError(BasicError::InvalidDecisionProposalRuleError(error)) - if error.document_id() == document_id, - "{title:?} is refused" + process_refused(&platform, transition, 0, platform_version), + ConsensusError::BasicError(_), + "{property} {value:?} is refused" ); + assert!(stored_proposal(&platform, &contract, document_id).is_none()); } assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); - // At the limits: a simple majority, the largest fixed minimum. - let at_the_limits = { - let mut properties = proposal("At the limits", END_DATE); + // At the limits: 50 and 80 percent to pass, no turnout and half the network. + for (pass_percent, minimum_turnout_percent) in [(50, 50), (80, 0)] { + let mut properties = with("passPercent", Some(pass_percent)); properties.insert( - "minimumVotingPower".to_string(), - Value::U32(max_voting_power), + "minimumTurnoutPercent".to_string(), + Value::U8(minimum_turnout_percent), ); - properties - }; - let (transition, _) = proposal_transition( - &contract, - &mut proposer, - at_the_limits, - None, - &mut rng, - platform_version, - ) - .await; - process_valid(&platform, transition, 0, platform_version); + let (transition, _) = proposal_transition( + &contract, + &mut proposer, + properties, + None, + &mut rng, + platform_version, + ) + .await; + process_valid(&platform, transition, 0, platform_version); + } } #[tokio::test] @@ -611,7 +611,7 @@ async fn should_record_the_result_of_a_proposal_voted_on_and_keep_the_proposal() let vote_poll = proposal_poll(document_id, &properties, platform_version); // Two evonodes vote yes (4 each) and a masternode votes no (1): 8 of 9 is more than half, - // and 9 reaches the minimum of 5. + // and 9 reaches half the network (these three, 9 voting power), rounded up to 5. for (seed, evonode, vote_choice) in [ (700, true, YesNoAbstainVoteChoice::Yes), (701, true, YesNoAbstainVoteChoice::Yes), @@ -678,3 +678,184 @@ async fn should_record_the_result_of_a_proposal_voted_on_and_keep_the_proposal() if finished.passed ); } + +#[tokio::test] +async fn should_let_the_proposer_edit_its_text_for_a_day_and_keep_every_version() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let contract = decision_proposals_contract(&platform, platform_version); + let mut rng = StdRng::seed_from_u64(0xDEC1_5110); + let mut proposer = proposer(&mut platform, 8); + const HOUR_MS: u64 = 3_600_000; + // Not at time 0: a document's history is read from after a time + let created_at = 1_000; + + let mut properties = proposal("Raise the limit?", END_DATE); + properties.insert( + "url".to_string(), + Value::Text("https://example.org/first".to_string()), + ); + let (transition, document_id) = proposal_transition( + &contract, + &mut proposer, + properties.clone(), + None, + &mut rng, + platform_version, + ) + .await; + process_valid(&platform, transition, created_at, platform_version); + let vote_poll = proposal_poll(document_id, &properties, platform_version); + let original = stored_proposal(&platform, &contract, document_id).expect("stored"); + + // Within the day, the rule is still fixed + let edit = edit_transition( + &contract, + &mut proposer, + original.clone(), + &[("endDate", Value::U64(END_DATE + 1))], + platform_version, + ) + .await; + assert_matches!( + process_refused(&platform, edit, created_at + HOUR_MS, platform_version), + ConsensusError::StateError(StateError::DocumentImmutablePropertyChangedError(_)), + "the end date never changes" + ); + + // but the title, the description and the url may change + let edit = edit_transition( + &contract, + &mut proposer, + original.clone(), + &[ + ("title", Value::Text("Raise the limit to 10?".to_string())), + ( + "description", + Value::Text("Yes raises it to 10, no keeps it.".to_string()), + ), + ("url", Value::Text("https://example.org/second".to_string())), + ], + platform_version, + ) + .await; + process_valid(&platform, edit, created_at + 12 * HOUR_MS, platform_version); + let edited = stored_proposal(&platform, &contract, document_id).expect("stored"); + assert_eq!( + edited.get("title"), + Some(&Value::Text("Raise the limit to 10?".to_string())) + ); + + // and no longer once the day has passed. + let edit = edit_transition( + &contract, + &mut proposer, + edited.clone(), + &[("title", Value::Text("Raise the limit to 20?".to_string()))], + platform_version, + ) + .await; + assert_matches!( + process_refused( + &platform, + edit, + created_at + 24 * HOUR_MS + 1, + platform_version + ), + ConsensusError::StateError(StateError::DocumentImmutablePropertyChangedError(_)), + "the title is frozen after a day" + ); + + // Every version is kept: a voter sees the text as it was when it voted. + let document_type = contract + .document_type_for_name(PROPOSAL_DOCUMENT_TYPE_NAME) + .expect("expected the proposal document type"); + let history = platform + .drive + .fetch_document_history( + contract.id().to_buffer(), + PROPOSAL_DOCUMENT_TYPE_NAME, + document_type, + document_id.to_buffer(), + None, + 0, + None, + None, + platform_version, + ) + .expect("expected the history"); + let titles: Vec<&Value> = history + .values() + .filter_map(|version| version.get("title")) + .collect(); + assert_eq!( + titles, + vec![ + &Value::Text("Raise the limit?".to_string()), + &Value::Text("Raise the limit to 10?".to_string()) + ] + ); + + // The poll is the one the proposal opened, its rule unchanged. + let stored_info = poll_state(&platform, &vote_poll, platform_version) + .stored_info + .expect("expected the poll to be open"); + assert_eq!(stored_info.vote_poll(), &vote_poll); +} + +#[tokio::test] +async fn should_refuse_a_proposal_whose_owner_cannot_pay_both_the_fund_and_the_fees() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let contract = decision_proposals_contract(&platform, platform_version); + let mut rng = StdRng::seed_from_u64(0xDEC1_5111); + let fund = decision_proposal_required_fund(platform_version); + + // What a proposal costs in fees, besides its fund + let mut measuring = proposer(&mut platform, 9); + let (transition, _) = proposal_transition( + &contract, + &mut measuring, + proposal("Raise the limit?", END_DATE), + None, + &mut rng, + platform_version, + ) + .await; + let fees = process_valid(&platform, transition, 0, platform_version).total_base_fee(); + + // An owner holding the fund and half those fees: the fund counts in the balance the + // transition needs, so it is refused before anything is written + let (identity, signer, key) = setup_identity(&mut platform, 10, fund + fees / 2); + let mut short = Proposer { + identity, + signer, + key, + next_nonce: 1, + }; + let properties = proposal("Lower the limit?", END_DATE); + let (transition, document_id) = proposal_transition( + &contract, + &mut short, + properties.clone(), + None, + &mut rng, + platform_version, + ) + .await; + let result = process(&platform, transition, 0, platform_version); + assert!( + !matches!(result, SuccessfulExecution { .. } | InternalError(_)), + "an owner short of the fund and the fees is refused, got {result:?}" + ); + let vote_poll = proposal_poll(document_id, &properties, platform_version); + assert!(poll_state(&platform, &vote_poll, platform_version) + .stored_info + .is_none()); + assert_eq!( + prefunded_balance(&platform, &vote_poll, platform_version), + None + ); + assert!(stored_proposal(&platform, &contract, document_id).is_none()); + assert!(identity_balance(&platform, short.identity.id(), platform_version) > fund); +} diff --git a/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs index 6ef507c1f80..c1ef864e055 100644 --- a/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs @@ -267,10 +267,19 @@ impl BatchTransitionActionV0 { .filter_map(|transition| match transition { BatchedTransitionAction::DocumentAction( DocumentTransitionAction::CreateAction(document_create_transition_action), - ) => document_create_transition_action - .prefunded_voting_balance() - .iter() - .try_fold(0u64, |acc, &(_, val)| acc.checked_add(val)), + ) => { + let contest_fund = document_create_transition_action + .prefunded_voting_balance() + .iter() + .try_fold(0u64, |acc, &(_, val)| acc.checked_add(val)); + // A decision proposal's fund (protocol version 15) leaves its owner's balance + // for its poll's as a contest's does; a create is one or the other + let decision_proposal_fund = document_create_transition_action + .decision_proposal() + .and_then(|decision_proposal| decision_proposal.stated_fund.as_ref()) + .map_or(0, |(_, fund)| *fund); + contest_fund.and_then(|fund| fund.checked_add(decision_proposal_fund)) + } _ => None, }) .fold((None, false), |(acc, _), price| match acc { diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/vote_poll.rs b/packages/rs-drive/src/util/batch/drive_op_batch/vote_poll.rs index e0280497530..dfec744a439 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/vote_poll.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/vote_poll.rs @@ -40,6 +40,13 @@ impl DriveLowLevelOperationConverter for VotePollOperationType { vote_poll, end_date, } => { + // Converted without the batch's earlier operations, which the opener would use + // to share an end date's tree with another opening or a contest in the batch. + // None can be there: a documents batch holds one transition + // (`max_transitions_in_documents_batch` is 1 at every protocol version, pinned by + // `documents_batch_is_capped_at_one_transition_at_every_protocol_version`), and + // the create opening the poll writes no votes tree besides. Raising the cap needs `apply_drive_operations` to give each + // conversion the batch's earlier operations first. let mut batch_operations = vec![]; drive.open_yes_no_vote_poll_operations( &vote_poll, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 7052106fc50..ac2b1d3b5c4 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -266,7 +266,8 @@ pub struct SystemLimits { pub max_yes_no_vote_poll_duration_ms: u64, /// The shortest a yes/no vote poll may run, in milliseconds from the block that opens it to /// its end date: one week, so masternode operators have time to see a poll before it - /// closes. Read by `YesNoVotePoll::validate_end_date` (protocol version 15). + /// closes. Read by `YesNoVotePoll::end_date_window` (protocol version 15), which the + /// decision proposal create validation and the poll opener share. pub min_yes_no_vote_poll_duration_ms: u64, /// The smallest pass threshold a yes/no vote poll may set, in percent: a poll passes when /// yes is more than `supermajority_numerator / supermajority_denominator` of yes plus no, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index ac20d2ff7a6..8586df63fb0 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -257,7 +257,10 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// moves the fund from the proposer to the poll's prefunded balance and opens the poll /// (`DriveOperation::VotePollOperation`, which estimates the opening's costs too, as /// check_tx's fee estimate converts a create no state validation has seen). Signaling -/// only: nothing acts on a result. Protocol v14 and earlier have no such contract. +/// only: nothing acts on a result. A proposal's rule never changes (`immutable`); its +/// owner may edit its title, description and url for `min(1 day, run / 5)` after creating +/// it (conditional `immutable` entries), and every version is kept (`documentsKeepHistory`). +/// Protocol v14 and earlier have no such contract. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11)