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/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/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/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/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..a9e81102521 --- /dev/null +++ b/docs/protocol/decision-proposals.md @@ -0,0 +1,117 @@ +# 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 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 | +| `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 | + +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 `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 +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 +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. The schema holds +the percentages to their ranges; the poll's own limits, which `SystemLimits` +carries, back them up: + +| Limit | Value | Where | +| --- | --- | --- | +| 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 +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/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/README.md b/packages/decision-proposals-contract/README.md new file mode 100644 index 00000000000..2bbc876c5d6 --- /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, 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 | +| `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 | + +Additional properties are rejected. + +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: + +- `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 +details. + +## Install + +```sh +npm install @dashevo/decision-proposals-contract +``` + +## License + +[MIT](LICENSE) © Dash Core Group, Inc. 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/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/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..e53d3f8d135 --- /dev/null +++ b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json @@ -0,0 +1,190 @@ +{ + "proposal": { + "type": "object", + "documentsMutable": true, + "documentsKeepHistory": true, + "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 + }, + "passPercent": { + "type": "integer", + "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 + }, + "minimumTurnoutPercent": { + "type": "integer", + "minimum": 0, + "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": 5 + } + }, + "required": [ + "$createdAt", + "$updatedAt", + "title", + "description", + "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. 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/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..a1d3f2aa5ac --- /dev/null +++ b/packages/decision-proposals-contract/src/lib.rs @@ -0,0 +1,156 @@ +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 std::collections::BTreeSet; + + 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; + + 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"); + 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") + .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 + ] + ); + // 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] + 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..10db7484bde --- /dev/null +++ b/packages/decision-proposals-contract/src/v1/mod.rs @@ -0,0 +1,41 @@ +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 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"; + + pub mod properties { + pub const TITLE: &str = "title"; + pub const DESCRIPTION: &str = "description"; + pub const URL: &str = "url"; + /// 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"; + } + + 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/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js new file mode 100644 index 00000000000..c996249466c --- /dev/null +++ b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js @@ -0,0 +1,230 @@ +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/', + passPercent: 60, + minimumTurnoutPercent: 10, + endDate: Date.now() + TWO_WEEKS_MS, + }); + + 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', () => { + expect(validate(rawProposal()).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 keep its rule fixed, its text editable for a while and every version', () => { + const { proposal } = decisionProposalsContractDocumentsSchema; + + 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', () => { + 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', 'passPercent', 'minimumTurnoutPercent', '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(); + }); + }); + + // [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 ${highest + 1}`, () => { + const raw = rawProposal(); + raw[property] = highest + 1; + + const error = expectJsonSchemaError(validate(raw)); + + expect(error.keyword).to.equal('maximum'); + }); + + 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('minimum'); + }); + } + }); + }); + }); +}); 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/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..335fb0ac957 --- /dev/null +++ b/packages/rs-dpp/src/decision_proposal/mod.rs @@ -0,0 +1,450 @@ +//! 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 { + /// 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"; +} + +/// 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 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 read = || -> Result { + 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}"))? + .ok_or_else(|| format!("`{path}` is required"))?; + u8::try_from(value).map_err(|_| format!("`{path}` is at most 255, got {value}")) + }; + 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: 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) + .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) => ConsensusValidationResult::new_with_error( + InvalidDecisionProposalRuleError::new(document_id, reason).into(), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + + fn proposal(pass_percent: u8, minimum_turnout_percent: u8) -> BTreeMap { + BTreeMap::from([ + ( + "title".to_string(), + Value::Text("Raise the limit?".to_string()), + ), + ( + properties::PASS_PERCENT.to_string(), + Value::U8(pass_percent), + ), + ( + properties::MINIMUM_TURNOUT_PERCENT.to_string(), + Value::U8(minimum_turnout_percent), + ), + (properties::END_DATE.to_string(), Value::U64(5_000)), + ]) + } + + 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(60, 10), + 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: 60, + supermajority_denominator: 100, + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 10, + denominator: 100, + rounding: VotingPowerRounding::Up, + }, + }, + end_date: 5_000, + } + ); + } + + /// 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") + .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!( + vote_poll + .required_voting_power(95, platform_version) + .expect("15"), + 10 + ); + } + + #[test] + fn should_refuse_a_rule_outside_the_limits() { + let platform_version = PlatformVersion::latest(); + let document_id = Identifier::new([7; 32]); + let read = |properties: BTreeMap| { + DecisionProposalVotePoll::from_proposal(document_id, &properties, platform_version) + .expect("active at 15") + }; + // 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] + 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(60, 10), + 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::PASS_PERCENT, proposal::properties::PASS_PERCENT); + assert_eq!( + properties::MINIMUM_TURNOUT_PERCENT, + proposal::properties::MINIMUM_TURNOUT_PERCENT + ); + assert_eq!(properties::END_DATE, proposal::properties::END_DATE); + } + + /// 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 + /// 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(60, 10), + ..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..541b702359c --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/decision_proposal/invalid_decision_proposal_rule_error.rs @@ -0,0 +1,62 @@ +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 `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, + 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/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-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..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 @@ -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,59 @@ 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. 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, + ) -> 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 +722,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 +730,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/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..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 @@ -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, @@ -261,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 4b8870fa3f1..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 @@ -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(()) } } @@ -954,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; @@ -1829,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( @@ -2745,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/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/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..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() ), - "24e77c57edbf5294e7b43df5af21fcd055697120124f3912021c2e41c954f4e0" + "a1aba6416f1f58b9a2ae5bdad21b4aacba05a20ea4d698fb98d6aed180d2be57" ); 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", + "2fd1b754af2ac9b95cb06a74e36a2481c70ab460db1a8ddd5711033c1baab1a8", ), "valid-payment" => ( - "a1308c6fe05b0a60cfee4fa0d43f7bda5856b02f7d11f3499fd1acd866328aa3", - "f6694678f1b835c476af2012fd217c67524f4f8d3ca5e4f9facbcec0b205081b", + "20c5acd175bdb316cd8697e1945d83e9af2f0ad0135f7e1278ad822f0905f5ee", + "e59a9cc954d4af978d3c798895b502c649583a61c51e9fc8ee52ee8a28de0902", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "4a1e0a2664ef5d529920510c003340cf5ecdc443d7478b08974a821076a7c8cc", + "1eaeb4e54f91a4c95b41fc2966c8d9fce2b27ee01ade95fbeab66deeb2ba3859", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "a3cbb2d69941b402cb5fc9c564da81d4716a168355fe4ce4d3ae530d99e40247", + "c7495c527f115ac6a78f8c709b1ac599a9ed683339f739a7e444cce767a180d7", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "20b47800939b417e433c695208924af0582df67c33edbf085f1fba297c9e6a06", + "0143d7edf2336ab89b1e83485eceeb29285d38492e16d8ae3645a1fa866eb881", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "5d22e1accc47d79db4b31a93e35a88b70e04412884239c4e04a05a0e2dde7f95", + "d559cb03752fa942008a30d52efb82584b49d68cc64aebabfacf01bf72d48f48", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "bc346f8d036f51e82f5f7af69a9ece71f34501e21c848f1d9df7e17c76dd847f", + "7ecda0074e06c9c66c7f856680e06d2c9bd391ea8746d3ed90263580b665c115", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "2b6f1d4e5b67d252c1aa76c466e716106107a98ec96d64eda488c37216611cd9", + "94961c4ec632dca33b79cffcbf9d08981fc77142d392118c21d37a9055feaacb", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "764422c7f8a87ad8165a7d38c795dcb57ea67d092fa751e7f0c50c06d52fbba9", + "78bafb1ae5c4a7c804421ac3afbb192eb35a4f1bc093e9a02f1130d8c00dd4c3", ), _ => unreachable!("enumerated characterization case"), }; 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() } ); 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..c21ba8b8252 --- /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,861 @@ +//! 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::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_methods::DocumentMethodsV0; +use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; +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::methods::v0::DocumentsBatchTransitionMethodsV0; +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 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())), + ( + "description".to_string(), + Value::Text("Yes raises it, no keeps it.".to_string()), + ), + ("passPercent".to_string(), Value::U8(50)), + ("minimumTurnoutPercent".to_string(), Value::U8(50)), + ("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:?}"), + } +} + +/// 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, + 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); + + // 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 (property, value) in [ + ("passPercent", Some(49)), + ("passPercent", Some(81)), + ("passPercent", None), + ("minimumTurnoutPercent", Some(51)), + ("minimumTurnoutPercent", None), + ] { + let (transition, document_id) = proposal_transition( + &contract, + &mut proposer, + with(property, value), + None, + &mut rng, + platform_version, + ) + .await; + assert_matches!( + 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: 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( + "minimumTurnoutPercent".to_string(), + Value::U8(minimum_turnout_percent), + ); + let (transition, _) = proposal_transition( + &contract, + &mut proposer, + properties, + 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 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), + (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 + ); +} + +#[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/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-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/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/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..dfec744a439 --- /dev/null +++ b/packages/rs-drive/src/util/batch/drive_op_batch/vote_poll.rs @@ -0,0 +1,65 @@ +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, + } => { + // 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, + 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/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..ac2b1d3b5c4 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,17 @@ 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::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, + /// 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..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,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..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,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..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,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..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,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..8586df63fb0 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; @@ -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,34 @@ 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. 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) 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,14 +282,14 @@ 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) - fee_version: FEE_VERSION3, + 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 { 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-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) 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-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() + } } } 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"] 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"