From a109b245cdef1a53e631418293297aa8a80893ae Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 10:24:57 +0700 Subject: [PATCH 1/7] feat(platform)!: moderation charters system data contract (moderation teams C1) The moderation charters system contract (issue #4878, item C1 of #4865), id EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88, registered as SystemDataContract::ModerationCharters = 10 with schema v1 at protocol version 14. Four document types, all immutable and undeletable: - reason: a ground for a moderation action, keyed by its owner and a three-letter code unique among the owner's reasons. - submittedCharter: a leader's proposal to moderate one contract on that contract's own terms. Its target only needs to declare elected moderation, so teams form during the target's election delay. It lists the reasons its actions may name (typed array of reason references), an optional moderatorsShare (absent is the full declared fee, 0 a team that takes no rewards) and a rewardSplit. No members, abilities or powers: the target's elected declaration is the team's whole mandate. - joinRequest: an identity's offer to serve on a proposal, one per identity per proposal, addressed to the proposal's owner, with a message encrypted to the leader's decryption key bound to submittedCharter from the writer's encryption key bound to joinRequest (keyRequirements, identityProperty, encryptedFor). - electedCharter: a proposal put to the vote with its team, the only type on the contested byTargetContract index (resolution 1, no Lock). Only the proposal's owner may file one, for the proposal's own target, once the target's election is open; its members are each the owner of a join request for the proposal (lookup through the join request's unique index) and never the leader (distinctFrom). rs-dpp reads a proposal and an elected charter out of their properties (SubmittedCharter, ElectedCharter) and checks the two rules the schema cannot express for the seating path: the reward split sums to 100 and the description fits SystemLimits::max_moderation_charter_description_length bytes (basic errors 11000 to 11002). System contracts are parsed under their published id, since boundTo and same-contract lookups compare against the id each document type keeps. The contract is registered but not written to state: no genesis or first-block creation, and the Drive cache does not serve it. The seating PR writes it. Co-Authored-By: Claude Opus 5.5 --- .codecov.yml | 1 + .../package-filters/js-packages-direct.yml | 3 + .../js-packages-no-workflows.yml | 4 + .github/package-filters/js-packages.yml | 5 + .../package-filters/rs-packages-direct.yml | 5 + .../rs-packages-no-workflows.yml | 6 + .github/package-filters/rs-packages.yml | 7 + .../package-filters/test-suite-triggers.yml | 1 + .github/workflows/tests-rs-workspace.yml | 1 + .github/workflows/tests.yml | 1 + .pnp.cjs | 21 + Cargo.lock | 12 + Cargo.toml | 1 + Dockerfile | 5 + book/src/architecture/overview.md | 2 +- docs/protocol/moderation-charters.md | 150 +++++ package.json | 2 + packages/data-contracts/Cargo.toml | 3 + packages/data-contracts/src/error.rs | 20 + packages/data-contracts/src/lib.rs | 36 +- .../moderation-charters-contract/.mocharc.yml | 2 + .../moderation-charters-contract/Cargo.toml | 15 + packages/moderation-charters-contract/LICENSE | 20 + .../moderation-charters-contract/README.md | 92 ++++ .../eslint.config.mjs | 10 + .../lib/systemIds.js | 4 + .../moderation-charters-contract/package.json | 27 + ...oderation-charters-contract-documents.json | 378 +++++++++++++ .../moderation-charters-contract/src/error.rs | 17 + .../moderation-charters-contract/src/lib.rs | 111 ++++ .../src/v1/mod.rs | 48 ++ .../test/bootstrap.js | 30 + .../unit/moderationChartersContract.spec.js | 365 ++++++++++++ packages/rs-dpp/Cargo.toml | 2 + .../config/moderation/elected.rs | 17 +- .../rs-dpp/src/data_contract/factory/mod.rs | 23 + .../src/data_contract/factory/v0/mod.rs | 30 +- .../src/errors/consensus/basic/basic_error.rs | 39 +- .../rs-dpp/src/errors/consensus/basic/mod.rs | 1 + .../consensus/basic/moderation_charter/mod.rs | 7 + ...tion_charter_description_too_long_error.rs | 54 ++ ...oderation_charter_malformed_field_error.rs | 62 +++ ...rter_reward_split_not_one_hundred_error.rs | 67 +++ packages/rs-dpp/src/errors/consensus/codes.rs | 5 + packages/rs-dpp/src/lib.rs | 1 + packages/rs-dpp/src/moderation_charter/mod.rs | 366 ++++++++++++ .../rs-dpp/src/moderation_charter/tests.rs | 206 +++++++ .../rs-dpp/src/moderation_charter/v0/mod.rs | 40 ++ packages/rs-dpp/src/system_data_contracts.rs | 519 +++++++++++++++++- .../rs-drive/src/cache/system_contracts.rs | 18 + .../dpp_validation_versions/mod.rs | 3 + .../dpp_validation_versions/v1.rs | 1 + .../dpp_validation_versions/v2.rs | 1 + .../dpp_validation_versions/v3.rs | 1 + .../dpp_validation_versions/v5.rs | 3 + .../src/version/mocks/v2_test.rs | 1 + .../system_data_contract_versions/mod.rs | 1 + .../system_data_contract_versions/v1.rs | 3 + .../system_data_contract_versions/v2.rs | 3 + .../system_data_contract_versions/v3.rs | 6 + .../src/version/system_limits/mod.rs | 5 + .../src/version/system_limits/v1.rs | 1 + .../src/version/system_limits/v2.rs | 1 + .../src/version/system_limits/v3.rs | 1 + .../src/version/system_limits/v4.rs | 4 + .../rs-platform-version/src/version/v14.rs | 38 ++ packages/rs-sdk-ffi/Cargo.toml | 1 + .../Cargo.toml | 2 + packages/rs-sdk/Cargo.toml | 1 + .../src/errors/consensus/consensus_error.rs | 13 + yarn.lock | 14 + 71 files changed, 2946 insertions(+), 20 deletions(-) create mode 100644 docs/protocol/moderation-charters.md create mode 100644 packages/moderation-charters-contract/.mocharc.yml create mode 100644 packages/moderation-charters-contract/Cargo.toml create mode 100644 packages/moderation-charters-contract/LICENSE create mode 100644 packages/moderation-charters-contract/README.md create mode 100644 packages/moderation-charters-contract/eslint.config.mjs create mode 100644 packages/moderation-charters-contract/lib/systemIds.js create mode 100644 packages/moderation-charters-contract/package.json create mode 100644 packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json create mode 100644 packages/moderation-charters-contract/src/error.rs create mode 100644 packages/moderation-charters-contract/src/lib.rs create mode 100644 packages/moderation-charters-contract/src/v1/mod.rs create mode 100644 packages/moderation-charters-contract/test/bootstrap.js create mode 100644 packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js create mode 100644 packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_malformed_field_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs create mode 100644 packages/rs-dpp/src/moderation_charter/mod.rs create mode 100644 packages/rs-dpp/src/moderation_charter/tests.rs create mode 100644 packages/rs-dpp/src/moderation_charter/v0/mod.rs diff --git a/.codecov.yml b/.codecov.yml index 9a5261f985b..ca5bab92ed9 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -54,6 +54,7 @@ ignore: - "packages/dpns-contract/src/**" - "packages/keyword-search-contract/src/**" - "packages/app-connect-contract/src/**" + - "packages/moderation-charters-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 37f0c53aec4..b170753427f 100644 --- a/.github/package-filters/js-packages-direct.yml +++ b/.github/package-filters/js-packages-direct.yml @@ -13,6 +13,9 @@ '@dashevo/app-connect-contract': - packages/app-connect-contract/** +'@dashevo/moderation-charters-contract': + - packages/moderation-charters-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 f14c729cd08..ac39c720953 100644 --- a/.github/package-filters/js-packages-no-workflows.yml +++ b/.github/package-filters/js-packages-no-workflows.yml @@ -13,6 +13,9 @@ '@dashevo/app-connect-contract': &app-connect-contract - packages/app-connect-contract/** +'@dashevo/moderation-charters-contract': &moderation-charters-contract + - packages/moderation-charters-contract/** + '@dashevo/dashpay-contract': &dashpay-contract - packages/dashpay-contract/** @@ -39,6 +42,7 @@ - *document-history-contract - *keyword-search-contract - *app-connect-contract + - *moderation-charters-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 087abed9a45..550e5633385 100644 --- a/.github/package-filters/js-packages.yml +++ b/.github/package-filters/js-packages.yml @@ -18,6 +18,10 @@ - .github/workflows/tests* - packages/app-connect-contract/** +'@dashevo/moderation-charters-contract': &moderation-charters-contract + - .github/workflows/tests* + - packages/moderation-charters-contract/** + '@dashevo/dashpay-contract': &dashpay-contract - .github/workflows/tests* - packages/dashpay-contract/** @@ -50,6 +54,7 @@ - *document-history-contract - *keyword-search-contract - *app-connect-contract + - *moderation-charters-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 e0db8b9abc6..b74139a2523 100644 --- a/.github/package-filters/rs-packages-direct.yml +++ b/.github/package-filters/rs-packages-direct.yml @@ -23,6 +23,11 @@ app-connect-contract: - packages/app-connect-contract/schema/** - packages/app-connect-contract/Cargo.toml +moderation-charters-contract: + - packages/moderation-charters-contract/src/** + - packages/moderation-charters-contract/schema/** + - packages/moderation-charters-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 b0c596ec3a8..5c59193d383 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -23,6 +23,11 @@ app-connect-contract: &app-connect-contract - packages/app-connect-contract/schema/** - packages/app-connect-contract/Cargo.toml +moderation-charters-contract: &moderation-charters-contract + - packages/moderation-charters-contract/src/** + - packages/moderation-charters-contract/schema/** + - packages/moderation-charters-contract/Cargo.toml + dashpay-contract: &dashpay-contract - packages/dashpay-contract/src/** - packages/dashpay-contract/schema/** @@ -57,6 +62,7 @@ data-contracts: &data-contracts - *keyword-search-contract - *document-history-contract - *app-connect-contract + - *moderation-charters-contract dpp: &dpp - packages/rs-dpp/** diff --git a/.github/package-filters/rs-packages.yml b/.github/package-filters/rs-packages.yml index 1174da0c998..91dd8c681ba 100644 --- a/.github/package-filters/rs-packages.yml +++ b/.github/package-filters/rs-packages.yml @@ -28,6 +28,12 @@ app-connect-contract: &app-connect-contract - packages/app-connect-contract/schema/** - packages/app-connect-contract/Cargo.toml +moderation-charters-contract: &moderation-charters-contract + - .github/workflows/tests* + - packages/moderation-charters-contract/src/** + - packages/moderation-charters-contract/schema/** + - packages/moderation-charters-contract/Cargo.toml + dashpay-contract: &dashpay-contract - .github/workflows/tests* - packages/dashpay-contract/src/** @@ -68,6 +74,7 @@ data-contracts: &data-contracts - *keyword-search-contract - *document-history-contract - *app-connect-contract + - *moderation-charters-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 1f60d0ec9f9..778c99990ba 100644 --- a/.github/package-filters/test-suite-triggers.yml +++ b/.github/package-filters/test-suite-triggers.yml @@ -31,6 +31,7 @@ run: - packages/document-history-contract/** - packages/keyword-search-contract/** - packages/app-connect-contract/** + - packages/moderation-charters-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 ac2c782301c..fb5632dc6da 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -453,6 +453,7 @@ jobs: --package wallet-utils-contract \ --package keyword-search-contract \ --package app-connect-contract \ + --package moderation-charters-contract \ --all-features \ --locked \ -E 'not test(~shield) 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 b318c0978bc..f1ad788ecfc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -326,6 +326,7 @@ jobs: - .github/workflows/tests.yml - packages/swift-sdk/** - packages/app-connect-contract/** + - packages/moderation-charters-contract/** - packages/dapi-grpc/** - packages/dashpay-contract/** - packages/data-contracts/** diff --git a/.pnp.cjs b/.pnp.cjs index 1843bea9e57..ddb8eee1876 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -74,6 +74,10 @@ const RAW_RUNTIME_STATE = "name": "@dashevo/masternode-reward-shares-contract",\ "reference": "workspace:packages/masternode-reward-shares-contract"\ },\ + {\ + "name": "@dashevo/moderation-charters-contract",\ + "reference": "workspace:packages/moderation-charters-contract"\ + },\ {\ "name": "@dashevo/platform-test-suite",\ "reference": "workspace:packages/platform-test-suite"\ @@ -128,6 +132,7 @@ const RAW_RUNTIME_STATE = ["@dashevo/grpc-common", ["workspace:packages/js-grpc-common"]],\ ["@dashevo/keyword-search-contract", ["workspace:packages/keyword-search-contract"]],\ ["@dashevo/masternode-reward-shares-contract", ["workspace:packages/masternode-reward-shares-contract"]],\ + ["@dashevo/moderation-charters-contract", ["workspace:packages/moderation-charters-contract"]],\ ["@dashevo/platform", ["workspace:."]],\ ["@dashevo/platform-test-suite", ["workspace:packages/platform-test-suite"]],\ ["@dashevo/token-history-contract", ["workspace:packages/token-history-contract"]],\ @@ -2917,6 +2922,22 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@dashevo/moderation-charters-contract", [\ + ["workspace:packages/moderation-charters-contract", {\ + "packageLocation": "./packages/moderation-charters-contract/",\ + "packageDependencies": [\ + ["@dashevo/moderation-charters-contract", "workspace:packages/moderation-charters-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/platform", [\ ["workspace:.", {\ "packageLocation": "./",\ diff --git a/Cargo.lock b/Cargo.lock index b0e4a6ff648..5bbab3cf620 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1877,6 +1877,7 @@ dependencies = [ "dpns-contract", "keyword-search-contract", "masternode-reward-shares-contract", + "moderation-charters-contract", "platform-value", "platform-version", "serde_json", @@ -4612,6 +4613,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "moderation-charters-contract" +version = "4.2.0-beta.3" +dependencies = [ + "base58", + "platform-value", + "platform-version", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "moka" version = "0.12.13" diff --git a/Cargo.toml b/Cargo.toml index 09d41d94b47..f0629de10a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ members = [ "packages/document-history-contract", "packages/keyword-search-contract", "packages/app-connect-contract", + "packages/moderation-charters-contract", "packages/rs-sdk-ffi", "packages/wasm-drive-verify", "packages/dash-platform-balance-checker", diff --git a/Dockerfile b/Dockerfile index df982026d38..6984e3ad1b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -402,6 +402,7 @@ COPY --parents \ packages/document-history-contract \ packages/keyword-search-contract \ packages/app-connect-contract \ + packages/moderation-charters-contract \ packages/data-contracts \ packages/strategy-tests \ packages/simple-signer \ @@ -525,6 +526,7 @@ COPY --parents \ packages/document-history-contract \ packages/keyword-search-contract \ packages/app-connect-contract \ + packages/moderation-charters-contract \ packages/withdrawals-contract \ packages/masternode-reward-shares-contract \ packages/dpns-contract \ @@ -708,6 +710,7 @@ COPY --parents \ packages/document-history-contract \ packages/keyword-search-contract \ packages/app-connect-contract \ + packages/moderation-charters-contract \ packages/masternode-reward-shares-contract \ packages/dpns-contract \ packages/data-contracts \ @@ -851,6 +854,7 @@ COPY --from=build-dashmate-helper /platform/packages/token-history-contract pack COPY --from=build-dashmate-helper /platform/packages/document-history-contract packages/document-history-contract 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/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 @@ -955,6 +959,7 @@ COPY --parents \ packages/document-history-contract \ packages/keyword-search-contract \ packages/app-connect-contract \ + packages/moderation-charters-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 c2e61bedba8..d001c411ec7 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`, `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`, `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/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md new file mode 100644 index 00000000000..d6bd741b148 --- /dev/null +++ b/docs/protocol/moderation-charters.md @@ -0,0 +1,150 @@ +# Moderation charters + +The moderation charters system contract holds how the moderation team of a +contract that declares elected moderation comes to be: the reasons a team may +act on, a leader's proposal, the identities that offer to join it, and the +proposal put to the vote with its team. It activates at protocol version 14. +Chains do not write it to state yet: the seating of an elected team does not +exist, and the pull request that adds it writes the contract to state at +genesis and on the upgrade to protocol version 14. + +- Contract ID: `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88` +- Owner: the all-zero system identity +- Registry entry: `SystemDataContract::ModerationCharters = 10` +- Schema version: 1 +- Document types: `reason`, `submittedCharter`, `joinRequest`, `electedCharter` + +Every type is immutable and undeletable, so each document another one refers +to stays exactly as it was when it was referred to. Additional properties are +rejected on every type. + +## The flow + +1. Anyone files `reason` documents, or reuses someone else's. +2. A leader files a `submittedCharter` for a target contract that declares + elected moderation. Proposals may be filed during the target's election + delay, so a team can form before the election opens. +3. Identities that want to serve file a `joinRequest` for the proposal, with a + message encrypted to the leader. +4. Once the target's election delay has passed, the leader files an + `electedCharter` naming the proposal and the members chosen from those who + asked to join. That create opens or joins the contest for the target. + +The seated team acts with the target contract's whole elected moderation +declaration: every document type and ability it lists. A team narrows what it +acts on only through the reasons its proposal lists, since every action names +one. There are no powers: any one member acts alone. + +## `reason` + +A ground for a moderation action. + +| Property | Type | Meaning | +| --- | --- | --- | +| `code` | string, three uppercase letters, required | Unique among the owner's reasons (`byOwnerCode`, unique on `$ownerId` and `code`); what an action shows | +| `label` | string, 1 to 64 characters, required | The reason's name, such as Spam | +| `description` | string, 1 to 1024 characters | What the reason covers and how the team applies it | + +Two owners may both file a `SPM`; a proposal says which one it means by +document id. + +## `submittedCharter` + +A leader's proposal. Its owner is the leader. The type declares +`requiresIdentityDecryptionBoundedKey`, so the leader can hold a decryption key +bound to it, the key join requests are encrypted to. + +| Property | Type | Meaning | +| --- | --- | --- | +| `targetContractId` | identifier, required, `refersTo: { "type": "contract", "contractRequirements": { "moderation": "elected" } }` | The contract the team proposes to moderate; a target that does not exist refuses the create (40120), one that does not declare elected moderation refuses it with `ReferencedContractRequirementNotMetError` (40135) | +| `description` | string, 1 to 4096 characters, required | What the team would moderate and how, for joiners and voters. Informational | +| `reasons` | typed array of at most 64 unique identifiers, required, each `refersTo` a `reason` | The moderation reasons the team's actions may name; empty is allowed, a team that can take no action; a missing reason refuses the create, naming the element (`reasons[2]`) | +| `moderatorsShare` | integer 0 to 100 | The percentage of each moderated document type's declared moderators fee the team takes. Absent is the full amount; a lower number is a discount; 0 is a team that will not moderate and takes no rewards | +| `rewardSplit` | object, required | `leader`, `equal` and `actions`, three percentages summing to 100: the leader's share, the share split equally among the other members, and the share split by each member's action count | + +Indexes: `byTargetContract` (`targetContractId`, `$createdAt`) lists the +proposals for a contract in filing order; `byOwner` (`$ownerId`) lists a +leader's proposals. + +A type's declared moderators amount is already the most a team may charge, so a +proposal can only lower the price, and the signer's fee agreement to the +declared amounts never mismatches a seated team. + +## `joinRequest` + +An identity's offer to serve on the team of a proposal. The owner is the one +offering, so the offer is consent the owner signed; no property names the +joiner. The type declares `requiresIdentityEncryptionBoundedKey`. + +| Property | Type | Meaning | +| --- | --- | --- | +| `submittedCharterId` | identifier, required, `refersTo` a `submittedCharter` with `propertyAgreement: { "recipientId": "$ownerId" }` | The proposal; `recipientId` must be its owner, the leader | +| `recipientId` | identifier, required, `refersTo: { "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } }` | The leader, and through `recipientKeyId` the key the message is encrypted to: a decryption key bound to this contract's `submittedCharter` type | +| `recipientKeyId` | integer 0 to 4294967295, required | The leader's key id | +| `senderKeyId` | integer 0 to 4294967295, required, `refersTo: { "type": "identityPublicKey", "identityProperty": "$ownerId", "keyRequirements": { "purpose": "encryption", "boundTo": "joinRequest" } }` | The owner's encryption key, bound to this contract's `joinRequest` type, the shared secret is derived from | +| `encryptedMessage` | bytes, 32 to 1040, required, `encryptedFor` recipient `recipientId`, keys `recipientKeyId` and `senderKeyId`, scheme `ecdh-secp256k1-aes256-cbc` | Why the owner wants to join, readable by the leader alone: a 16-byte IV followed by AES-256-CBC blocks under the ECDH shared key, the scheme dashpay contact requests use. Consensus checks only the shape | + +Indexes: `bySubmittedCharter` (`submittedCharterId`, `$ownerId`), unique, so +one offer per identity per proposal, and the index an elected charter's +members are looked up through; `byOwner` (`$ownerId`). The type is neither +transferable nor tradeable, which a lookup keyed on `$ownerId` requires. + +## `electedCharter` + +A proposal put to the vote with its team. Its owner is the leader. + +| Property | Type | Meaning | +| --- | --- | --- | +| `targetContractId` | identifier, required, `refersTo: { "type": "contract", "contractRequirements": { "moderation": "electionOpen" } }` | The contract contended for; it must declare elected moderation and its own `electionDelay` since its creation must have passed (40135 otherwise) | +| `submittedCharterId` | identifier, required, `refersTo` a `submittedCharter` with `propertyAgreement: { "$ownerId": "$ownerId", "targetContractId": "targetContractId" }` | The proposal the team runs on: only its owner may file this, and for the proposal's own target | +| `members` | typed array of at most 15 unique identifiers, required, elements `distinctFrom: "$ownerId"` and `refersTo` a `joinRequest` through `lookup: { "index": "bySubmittedCharter", "keys": { "submittedCharterId": "submittedCharterId", "$ownerId": "." } }` | The team besides the leader; may be empty. Each member must be the owner of a join request for this proposal, found through the join request's unique index, and none may be the leader | + +The lookup reads: for each member, the join request whose +`submittedCharterId` is this document's `submittedCharterId` and whose owner is +the member. A member with no such request refuses the create with +`ReferencedEntityNotFoundError` (40120), naming the element (`members[1]`). + +Indexes: `byTargetContract`, the contested index below, and +`bySubmittedCharter` (`submittedCharterId`), which lists the elected charters +of a proposal. It is not unique: a type with a contested unique index may carry +no other unique index, so a leader may enter one proposal more than once, each +time with its own team and its own contest fee. + +## The contest + +The `byTargetContract` index of `electedCharter` is a contested unique index +keyed by the target contract, with `"resolution": 1`: + +```json +{ + "name": "byTargetContract", + "properties": [{ "targetContractId": "asc" }], + "unique": true, + "contested": { "resolution": 1 } +} +``` + +Resolution `1` is `ContestedIndexResolution::MasternodeVoteNoLocking`: +masternodes (weight 1) and evonodes (weight 4) vote for a contender or abstain, +with no Lock choice, so the contest always ends with a winner, a tie goes to +the earliest contender, and a contest with a single contender at the end of the +join window is awarded at once. An elected charter create opens or joins that +contest for its target contract. Reading the join window, the vote window and +the fund from the target contract comes with the seating, in a later pull +request. + +## Validation beyond the schema + +Every rule above is enforced by the schema's keywords when a document is +written. Two rules of a proposal are not expressible there, and +`validate_submitted_charter` in `rs-dpp` +(`packages/rs-dpp/src/moderation_charter/`) checks them without reading state, +for the path that seats a team: + +| Rule | Error | Code | +| --- | --- | --- | +| A property is missing or of the wrong type | `ModerationCharterMalformedFieldError` | 11000 | +| The three shares of `rewardSplit` do not sum to 100 | `ModerationCharterRewardSplitNotOneHundredError` | 11001 | +| The description is over `SystemLimits::max_moderation_charter_description_length` (4096) bytes; the schema's `maxLength` counts characters | `ModerationCharterDescriptionTooLongError` | 11002 | + +`ElectedCharter` reads an elected charter's properties for the same path. diff --git a/package.json b/package.json index 497e861f4dd..f4ac1679c5c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test:token-history-contract": "ultra -r --filter \"packages/@(token-history-contract|js-dash-sdk|js-drive|js-dapi-client|wasm-dpp|wallet-lib|dapi|platform-test-suite)\" test", "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: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", @@ -79,6 +80,7 @@ "packages/document-history-contract", "packages/keyword-search-contract", "packages/app-connect-contract", + "packages/moderation-charters-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 87cc92e1318..a7562a2d2e2 100644 --- a/packages/data-contracts/Cargo.toml +++ b/packages/data-contracts/Cargo.toml @@ -19,6 +19,7 @@ all-contracts = [ "keyword-search", "document-history", "app-connect", + "moderation-charters", ] # Individual contract features @@ -31,6 +32,7 @@ token-history = ["dep:token-history-contract"] keyword-search = ["dep:keyword-search-contract"] document-history = ["dep:document-history-contract"] app-connect = ["dep:app-connect-contract"] +moderation-charters = ["dep:moderation-charters-contract"] [dependencies] thiserror = "2.0.12" @@ -46,6 +48,7 @@ token-history-contract = { path = "../token-history-contract", optional = true } 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 } [dev-dependencies] base58 = "0.2.0" diff --git a/packages/data-contracts/src/error.rs b/packages/data-contracts/src/error.rs index ce469e36239..698e7e727d1 100644 --- a/packages/data-contracts/src/error.rs +++ b/packages/data-contracts/src/error.rs @@ -183,3 +183,23 @@ impl From for Error { } } } + +#[cfg(feature = "moderation-charters")] +impl From for Error { + fn from(e: moderation_charters_contract::Error) -> Self { + match e { + moderation_charters_contract::Error::UnknownVersionMismatch { + method, + known_versions, + received, + } => Error::UnknownVersionMismatch { + method, + known_versions, + received, + }, + moderation_charters_contract::Error::InvalidSchemaJson(e) => { + Error::InvalidSchemaJson(e) + } + } + } +} diff --git a/packages/data-contracts/src/lib.rs b/packages/data-contracts/src/lib.rs index 932f139cbfb..2c416eea557 100644 --- a/packages/data-contracts/src/lib.rs +++ b/packages/data-contracts/src/lib.rs @@ -19,6 +19,9 @@ pub use keyword_search_contract; #[cfg(feature = "masternode-rewards")] pub use masternode_reward_shares_contract; +#[cfg(feature = "moderation-charters")] +pub use moderation_charters_contract; + use platform_value::Identifier; use platform_version::version::PlatformVersion; @@ -50,6 +53,10 @@ pub enum SystemDataContract { KeywordSearch = 7, DocumentHistory = 8, AppConnect = 9, + /// The charters of elected moderation teams (protocol version 14). Registered from + /// protocol version 14 on, but not yet written to state: the election a charter create + /// opens does not exist yet, and the PR that adds it writes the contract to state. + ModerationCharters = 10, } pub struct DataContractSource { @@ -66,7 +73,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; 10] = [ + pub const ALL: [SystemDataContract; 11] = [ SystemDataContract::Withdrawals, SystemDataContract::MasternodeRewards, SystemDataContract::FeatureFlags, @@ -77,6 +84,7 @@ impl SystemDataContract { SystemDataContract::KeywordSearch, SystemDataContract::DocumentHistory, SystemDataContract::AppConnect, + SystemDataContract::ModerationCharters, ]; /// A new variant must also be added to [`SystemDataContract::ALL`]; this match is where the @@ -162,6 +170,14 @@ impl SystemDataContract { 239, 150, 14, 165, 105, 114, 235, 173, 190, 248, 162, 126, 247, 218, 92, 129, 255, 75, 179, 138, 2, 150, 151, 69, 126, 36, 218, 66, 183, 155, 84, 183, ], + + #[cfg(feature = "moderation-charters")] + SystemDataContract::ModerationCharters => moderation_charters_contract::ID_BYTES, + #[cfg(not(feature = "moderation-charters"))] + SystemDataContract::ModerationCharters => [ + 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, + ], }; Identifier::new(bytes) } @@ -282,6 +298,21 @@ impl SystemDataContract { }), #[cfg(not(feature = "app-connect"))] SystemDataContract::AppConnect => Err(Error::ContractNotIncluded("app-connect")), + + #[cfg(feature = "moderation-charters")] + SystemDataContract::ModerationCharters => Ok(DataContractSource { + id_bytes: moderation_charters_contract::ID_BYTES, + owner_id_bytes: moderation_charters_contract::OWNER_ID_BYTES, + version: platform_version.system_data_contracts.moderation_charters as u32, + definitions: moderation_charters_contract::load_definitions(platform_version)?, + document_schemas: moderation_charters_contract::load_documents_schemas( + platform_version, + )?, + }), + #[cfg(not(feature = "moderation-charters"))] + SystemDataContract::ModerationCharters => { + Err(Error::ContractNotIncluded("moderation-charters")) + } } } } @@ -331,6 +362,9 @@ mod tests { SystemDataContract::AppConnect => { published("H8F9mP1BM55TE1ShsxPZHzhyinaMdY9bMmP85mkDhcJJ") } + SystemDataContract::ModerationCharters => { + published("EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88") + } }; assert_eq!( diff --git a/packages/moderation-charters-contract/.mocharc.yml b/packages/moderation-charters-contract/.mocharc.yml new file mode 100644 index 00000000000..164b941c1b6 --- /dev/null +++ b/packages/moderation-charters-contract/.mocharc.yml @@ -0,0 +1,2 @@ +require: test/bootstrap.js +recursive: true diff --git a/packages/moderation-charters-contract/Cargo.toml b/packages/moderation-charters-contract/Cargo.toml new file mode 100644 index 00000000000..c62ae3e3c57 --- /dev/null +++ b/packages/moderation-charters-contract/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "moderation-charters-contract" +description = "Moderation charters data contract schema and tools. A team's application to moderate a contract" +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/moderation-charters-contract/LICENSE b/packages/moderation-charters-contract/LICENSE new file mode 100644 index 00000000000..3be95833750 --- /dev/null +++ b/packages/moderation-charters-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/moderation-charters-contract/README.md b/packages/moderation-charters-contract/README.md new file mode 100644 index 00000000000..207124867af --- /dev/null +++ b/packages/moderation-charters-contract/README.md @@ -0,0 +1,92 @@ +# Moderation Charters Contract + +The moderation charters system contract holds the charters of the moderation +teams that masternodes elect for data contracts that declare an elected +moderation team. It activates at protocol version 14 and has the same ID on +every network: `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. + +It has four document types. All four are immutable and undeletable, so +everything a charter points at, and the charter itself, is a fixed text. + +The schema carries almost every rule through its keywords: typed arrays with +a reference per element, a reference resolved through a unique index +(`lookup`), `distinctFrom`, key requirements on key references and the +`encryptedFor` envelope. What it cannot say, the reward split summing to 100 +and the description's byte cap, is checked by `SubmittedCharter` in +`rs-dpp` when a team is seated. + +### `reason` + +A ground for a moderation action. Anyone may file one. + +| Property | Type | Meaning | +| --- | --- | --- | +| `code` | string, 3 uppercase letters, required | Unique among the owner's reasons (`byOwnerCode`); what an action shows | +| `label` | string, 1 to 64 characters, required | The reason's name | +| `description` | string, 1 to 1024 characters | What the reason covers and how the team applies it | + +### `submittedCharter` + +A leader's proposal to moderate one contract, on the contract's own terms: +the target's elected moderation declaration is the team's whole mandate, so +the proposal names no abilities. Its owner is the leader, and must hold a +decryption key bound to this type so join requests can be encrypted to it. + +| Property | Type | Meaning | +| --- | --- | --- | +| `targetContractId` | identifier, required, `refersTo` a contract with elected moderation | The contract the team proposes to moderate | +| `description` | string, 1 to 4096 characters, required | What the team would moderate and how. Informational | +| `reasons` | array of at most 64 unique reason ids, required, each `refersTo` a `reason` | The moderation reasons the team's actions may name; a team with none can take no action | +| `moderatorsShare` | integer 0 to 100 | The percentage of each moderated type's declared moderators fee the team takes; absent is the full amount, 0 a team that will not moderate and takes no rewards | +| `rewardSplit` | object, required | `leader`, `equal` and `actions` percentages summing to 100 | + +Indexes: `byTargetContract` (target, `$createdAt`) lists the proposals for a +contract in filing order; `byOwner` lists a leader's proposals. + +### `joinRequest` + +An identity's offer to serve on the team of a proposal, one per identity per +proposal (`bySubmittedCharter`, unique on the proposal and the owner), with a +message only the leader can read. The owner must hold an encryption key bound +to this type. + +| Property | Type | Meaning | +| --- | --- | --- | +| `submittedCharterId` | identifier, required, `refersTo` a `submittedCharter` | The proposal; `recipientId` must equal its owner (`propertyAgreement`) | +| `recipientId` | identifier, required, `refersTo` an identity public key through `recipientKeyId` | The leader and the decryption key the message is encrypted to | +| `recipientKeyId` | integer, required | The leader's key id | +| `senderKeyId` | integer, required | The owner's encryption key the shared secret is derived from | +| `encryptedMessage` | bytes, 32 to 1040, required | Why the owner wants to join, encrypted for the leader (ECDH on secp256k1, AES-256-CBC) | + +### `electedCharter` + +A proposal put to the vote with its team: the only type that opens or joins +the contest for a target. Only the proposal's leader may create one, for the +proposal's own target (`propertyAgreement` on `$ownerId` and +`targetContractId`). The `byTargetContract` index is a contested unique index +keyed by the target contract with resolution `1`, the vote without a Lock +choice by masternodes (weight 1) and evonodes (weight 4): a create on it +opens or joins the contest, a tie goes to the earliest applicant, and a +single applicant is seated when the join window closes. `bySubmittedCharter` +lists the elected charters of a proposal; it cannot be unique, since a type +with a contested unique index may carry no other unique index. The seated +leader and members act with the target's full mandate; there are no powers. + +| Property | Type | Meaning | +| --- | --- | --- | +| `targetContractId` | identifier, required, `refersTo` a contract whose election is open | The contract contended for; its own `electionDelay` since its creation must have passed | +| `submittedCharterId` | identifier, required, `refersTo` a `submittedCharter` | The proposal the team runs on | +| `members` | array of at most 15 unique identity ids, required, each the owner of a `joinRequest` for this proposal (`lookup`) and none the leader (`distinctFrom`) | The team besides the leader; may be empty | + +See [the protocol guide](../../docs/protocol/moderation-charters.md) for +details. + +## Install + +```sh +npm install @dashevo/moderation-charters-contract +``` + +## License + +[MIT](LICENSE) © Dash Core Group, Inc. diff --git a/packages/moderation-charters-contract/eslint.config.mjs b/packages/moderation-charters-contract/eslint.config.mjs new file mode 100644 index 00000000000..cdf50e57d0d --- /dev/null +++ b/packages/moderation-charters-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/moderation-charters-contract/lib/systemIds.js b/packages/moderation-charters-contract/lib/systemIds.js new file mode 100644 index 00000000000..38d07d8b4a6 --- /dev/null +++ b/packages/moderation-charters-contract/lib/systemIds.js @@ -0,0 +1,4 @@ +module.exports = { + ownerId: '11111111111111111111111111111111', + contractId: 'EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88', +}; diff --git a/packages/moderation-charters-contract/package.json b/packages/moderation-charters-contract/package.json new file mode 100644 index 00000000000..25fa7a39f9f --- /dev/null +++ b/packages/moderation-charters-contract/package.json @@ -0,0 +1,27 @@ +{ + "name": "@dashevo/moderation-charters-contract", + "version": "4.2.0-beta.3", + "description": "A system contract for the charters of elected moderation teams", + "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/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json new file mode 100644 index 00000000000..adff0f4da27 --- /dev/null +++ b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json @@ -0,0 +1,378 @@ +{ + "reason": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "indices": [ + { + "name": "byOwnerCode", + "properties": [ + { + "$ownerId": "asc" + }, + { + "code": "asc" + } + ], + "unique": true + } + ], + "properties": { + "code": { + "type": "string", + "minLength": 3, + "maxLength": 3, + "pattern": "^[A-Z]{3}$", + "description": "Three uppercase letters, unique among the owner's reasons; what an action shows", + "position": 0 + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "The reason's name, such as Spam", + "position": 1 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "What the reason covers and how the team applies it", + "position": 2 + } + }, + "required": [ + "$createdAt", + "code", + "label" + ], + "additionalProperties": false, + "description": "A ground for a moderation action, keyed by its owner and a three-letter code. Anyone may file one; immutable and undeletable, so a charter that lists it lists a fixed text" + }, + "submittedCharter": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "requiresIdentityDecryptionBoundedKey": 1, + "indices": [ + { + "name": "byTargetContract", + "properties": [ + { + "targetContractId": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "byOwner", + "properties": [ + { + "$ownerId": "asc" + } + ] + } + ], + "properties": { + "targetContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "contract", + "contractRequirements": { + "moderation": "elected" + } + }, + "description": "The contract the team proposes to moderate; it must declare elected moderation, and that declaration is the team's whole mandate", + "position": 0 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "What the team would moderate and how, for joiners and voters. Informational", + "position": 1 + }, + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason" + } + }, + "description": "The moderation reasons the team's actions may name. Every action names one, so a team with none can take no action", + "position": 2 + }, + "moderatorsShare": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Percentage of each moderated type's declared moderators fee the team takes. Absent means the full amount; a lower number is a discount on the team's services, and 0 says the team will not moderate and takes no rewards", + "position": 3 + }, + "rewardSplit": { + "type": "object", + "properties": { + "leader": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "The leader's share", + "position": 0 + }, + "equal": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "The share split equally among the other members", + "position": 1 + }, + "actions": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "The share split by each member's moderation action count", + "position": 2 + } + }, + "required": [ + "leader", + "equal", + "actions" + ], + "additionalProperties": false, + "description": "How the team splits each claim of the moderators pot; the three percentages sum to 100", + "position": 4 + } + }, + "required": [ + "$createdAt", + "targetContractId", + "description", + "reasons", + "rewardSplit" + ], + "additionalProperties": false, + "description": "A proposal to moderate a contract on the contract's own terms: who leads it, the reasons it may act on, what it charges and how it splits the pay. Immutable, so joiners consent to a fixed text" + }, + "joinRequest": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "requiresIdentityEncryptionBoundedKey": 1, + "indices": [ + { + "name": "bySubmittedCharter", + "properties": [ + { + "submittedCharterId": "asc" + }, + { + "$ownerId": "asc" + } + ], + "unique": true + }, + { + "name": "byOwner", + "properties": [ + { + "$ownerId": "asc" + } + ] + } + ], + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "submittedCharter", + "propertyAgreement": { + "recipientId": "$ownerId" + } + }, + "description": "The proposal the owner asks to join; recipientId must be its leader", + "position": 0 + }, + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { + "purpose": "decryption", + "boundTo": "submittedCharter" + } + }, + "description": "The leader, and through recipientKeyId the key the message is encrypted to", + "position": 1 + }, + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "The leader's decryption key the message is encrypted to", + "position": 2 + }, + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { + "purpose": "encryption", + "boundTo": "joinRequest" + } + }, + "description": "The owner's encryption key the shared secret is derived from", + "position": 3 + }, + "encryptedMessage": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 1040, + "encryptedFor": { + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }, + "description": "Why the owner wants to join, readable by the leader alone", + "position": 4 + } + }, + "required": [ + "$createdAt", + "submittedCharterId", + "recipientId", + "recipientKeyId", + "senderKeyId", + "encryptedMessage" + ], + "additionalProperties": false, + "description": "An identity's offer to serve on the team of a proposal. One per identity per proposal; immutable and permanent, so a team's consent never vanishes" + }, + "electedCharter": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "indices": [ + { + "name": "byTargetContract", + "properties": [ + { + "targetContractId": "asc" + } + ], + "unique": true, + "contested": { + "resolution": 1, + "description": "Every elected charter for a target contends for its moderation team seat" + } + }, + { + "name": "bySubmittedCharter", + "properties": [ + { + "submittedCharterId": "asc" + } + ] + } + ], + "properties": { + "targetContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "contract", + "contractRequirements": { + "moderation": "electionOpen" + } + }, + "description": "The contract contended for; it declares elected moderation and its own election delay has passed", + "position": 0 + }, + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "submittedCharter", + "propertyAgreement": { + "$ownerId": "$ownerId", + "targetContractId": "targetContractId" + } + }, + "description": "The proposal this team runs on; only its leader may open the contest, and for the same target", + "position": 1 + }, + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "submittedCharterId", + "$ownerId": "." + } + } + } + }, + "description": "The team besides the leader: only identities that asked to join this proposal", + "position": 2 + } + }, + "required": [ + "$createdAt", + "targetContractId", + "submittedCharterId", + "members" + ], + "additionalProperties": false, + "description": "A proposal put to the vote with its team. Creating one opens or joins the contest for the target; the leader and every member act with the target's full mandate" + } +} diff --git a/packages/moderation-charters-contract/src/error.rs b/packages/moderation-charters-contract/src/error.rs new file mode 100644 index 00000000000..d01bbcc91cf --- /dev/null +++ b/packages/moderation-charters-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/moderation-charters-contract/src/lib.rs b/packages/moderation-charters-contract/src/lib.rs new file mode 100644 index 00000000000..e6d4c4be87a --- /dev/null +++ b/packages/moderation-charters-contract/src/lib.rs @@ -0,0 +1,111 @@ +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] = [ + 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, +]; + +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 14 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.moderation_charters { + 1 => Ok(None), + version => Err(Error::UnknownVersionMismatch { + method: "moderation_charters_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.moderation_charters { + 1 => v1::load_documents_schemas(), + version => Err(Error::UnknownVersionMismatch { + method: "moderation_charters_contract::load_documents_schemas".to_string(), + known_versions: vec![1], + received: version, + }), + } +} + +#[cfg(test)] +mod tests { + use base58::FromBase58; + + use super::*; + + #[test] + /// Ensure that the ID constant matches the expected value + /// and that it can be encoded to base58 correctly. + fn should_match_the_published_system_contract_id() { + assert_eq!( + ID, + Identifier(IdentifierBytes32(ID_BYTES)), + "ID should match the expected value" + ); + + let base58_decoded = "EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88" + .from_base58() + .unwrap(); + assert_eq!( + base58_decoded, ID_BYTES, + "ID should match the base58 decoded value" + ); + } + + #[test] + fn should_load_the_schema_at_the_latest_platform_version() { + let schema = load_documents_schemas(PlatformVersion::latest()).expect("schema loads"); + let charter = schema + .get(v1::document_types::charter::NAME) + .expect("the charter document type is declared"); + let properties = charter + .get("properties") + .and_then(Value::as_object) + .expect("the charter has properties"); + for property in [ + v1::document_types::charter::properties::TARGET_CONTRACT_ID, + v1::document_types::charter::properties::DESCRIPTION, + v1::document_types::charter::properties::ABILITIES, + v1::document_types::charter::properties::MEMBERS, + v1::document_types::charter::properties::REASON_CODES, + v1::document_types::charter::properties::MODERATORS_SHARE, + v1::document_types::charter::properties::SPLIT, + ] { + assert!( + properties.contains_key(property), + "the schema declares {property}" + ); + } + assert_eq!( + charter["indices"][0]["name"], + v1::document_types::charter::indexes::BY_TARGET_CONTRACT + ); + } + + #[test] + fn should_refuse_to_load_below_the_activation_version() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + 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/moderation-charters-contract/src/v1/mod.rs b/packages/moderation-charters-contract/src/v1/mod.rs new file mode 100644 index 00000000000..392d3e18efd --- /dev/null +++ b/packages/moderation-charters-contract/src/v1/mod.rs @@ -0,0 +1,48 @@ +use crate::Error; +use serde_json::Value; + +pub mod document_types { + /// A team's application to moderate a contract, and once elected the terms it moderates + /// under. Immutable and undeletable. + pub mod charter { + pub const NAME: &str = "charter"; + + pub mod properties { + pub const TARGET_CONTRACT_ID: &str = "targetContractId"; + pub const DESCRIPTION: &str = "description"; + pub const ABILITIES: &str = "abilities"; + pub const MEMBERS: &str = "members"; + pub const REASON_CODES: &str = "reasonCodes"; + pub const MODERATORS_SHARE: &str = "moderatorsShare"; + pub const SPLIT: &str = "split"; + + /// The keys of the `abilities` object, each holding the power the ability needs. + pub mod abilities { + pub const DELETE_DOCUMENTS: &str = "deleteDocuments"; + pub const BAN: &str = "ban"; + pub const SUSPEND: &str = "suspend"; + pub const WARN: &str = "warn"; + } + + /// The keys of the `split` object, three percentages summing to 100. + pub mod split { + pub const LEADER: &str = "leader"; + pub const EQUAL: &str = "equal"; + pub const ACTIONS: &str = "actions"; + } + } + + pub mod indexes { + /// The contested unique index keyed by the target contract: a create on it is + /// the team's application, and opens or joins the election. + pub const BY_TARGET_CONTRACT: &str = "byTargetContract"; + } + } +} + +pub fn load_documents_schemas() -> Result { + serde_json::from_str(include_str!( + "../../schema/v1/moderation-charters-contract-documents.json" + )) + .map_err(Error::InvalidSchemaJson) +} diff --git a/packages/moderation-charters-contract/test/bootstrap.js b/packages/moderation-charters-contract/test/bootstrap.js new file mode 100644 index 00000000000..7af04f464d7 --- /dev/null +++ b/packages/moderation-charters-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/moderation-charters-contract/test/unit/moderationChartersContract.spec.js b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js new file mode 100644 index 00000000000..d4dc938bbe2 --- /dev/null +++ b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js @@ -0,0 +1,365 @@ +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 moderationChartersContractDocumentsSchema = require('../../schema/v1/moderation-charters-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 randomIds = (count) => Array.from({ length: count }, () => crypto.randomBytes(32)); + +describe('Moderation Charters 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), + moderationChartersContractDocumentsSchema, + ); + }); + + const validate = (type, raw) => { + const document = dpp.document.create(dataContract, identityId, type, raw); + return document.validate(dpp.protocolVersion); + }; + + const expectRequired = (type, rawFactory, properties) => { + properties.forEach((property) => { + it(`should require ${property}`, async () => { + const raw = await rawFactory(); + delete raw[property]; + + const error = expectJsonSchemaError(validate(type, raw)); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal(property); + }); + }); + }; + + const expectNoAdditionalProperties = (type, rawFactory, extra) => { + it(`should not have additional properties such as ${extra}`, async () => { + const raw = await rawFactory(); + raw[extra] = 42; + + const error = expectJsonSchemaError(validate(type, raw)); + + expect(error.keyword).to.equal('additionalProperties'); + expect(error.params.additionalProperties).to.deep.equal([extra]); + }); + }; + + it('should have a valid contract definition', async () => { + expect(() => dpp.dataContract.create( + identityId, + BigInt(1), + moderationChartersContractDocumentsSchema, + )) + .to + .not + .throw(); + }); + + it('should have four document types', () => { + expect(Object.keys(moderationChartersContractDocumentsSchema).sort()).to.deep.equal([ + 'electedCharter', + 'joinRequest', + 'reason', + 'submittedCharter', + ]); + }); + + describe('reason', () => { + const rawReason = async () => ({ + code: 'SPM', + label: 'Spam', + description: 'Unsolicited promotion, repeated or automated.', + }); + + it('should be valid', async () => { + expect(validate('reason', await rawReason()).isValid()).to.be.true(); + }); + + it('should not need a description', async () => { + const raw = await rawReason(); + delete raw.description; + + expect(validate('reason', raw).isValid()).to.be.true(); + }); + + expectRequired('reason', rawReason, ['code', 'label']); + expectNoAdditionalProperties('reason', rawReason, 'severity'); + + ['spm', 'SP', 'SPAM', 'SP1'].forEach((code) => { + it(`should refuse the code ${code}`, async () => { + const raw = await rawReason(); + raw.code = code; + + expect(validate('reason', raw).isValid()).to.be.false(); + }); + }); + + it('should refuse a label over 64 characters', async () => { + const raw = await rawReason(); + raw.label = 'a'.repeat(65); + + const error = expectJsonSchemaError(validate('reason', raw)); + + expect(error.keyword).to.equal('maxLength'); + }); + }); + + describe('submittedCharter', () => { + const rawProposal = async () => ({ + targetContractId: await generateRandomIdentifier(), + description: 'We remove spam and doxing within a day and warn before we act.', + reasons: randomIds(2), + moderatorsShare: 60, + rewardSplit: { + leader: 10, + equal: 40, + actions: 50, + }, + }); + + it('should be valid', async () => { + expect(validate('submittedCharter', await rawProposal()).isValid()).to.be.true(); + }); + + expectRequired('submittedCharter', rawProposal, ['targetContractId', 'description', 'reasons', 'rewardSplit']); + expectNoAdditionalProperties('submittedCharter', rawProposal, 'members'); + expectNoAdditionalProperties('submittedCharter', rawProposal, 'abilities'); + + describe('description', () => { + it('should refuse 4097 characters', async () => { + const raw = await rawProposal(); + raw.description = 'a'.repeat(4097); + + const error = expectJsonSchemaError(validate('submittedCharter', raw)); + + expect(error.keyword).to.equal('maxLength'); + }); + + it('should count characters, not bytes; consensus caps the bytes at 4096', async () => { + // 2049 two-byte characters: within the schema's 4096 characters, over the + // 4096 bytes `SystemLimits::max_moderation_charter_description_length` + // enforces in rs-dpp (ModerationCharterDescriptionTooLongError, 11002). + const raw = await rawProposal(); + raw.description = 'é'.repeat(2049); + + expect(validate('submittedCharter', raw).isValid()).to.be.true(); + }); + }); + + describe('reasons', () => { + it('should accept none, a team that can take no action', async () => { + const raw = await rawProposal(); + raw.reasons = []; + + expect(validate('submittedCharter', raw).isValid()).to.be.true(); + }); + + it('should accept sixty-four', async () => { + const raw = await rawProposal(); + raw.reasons = randomIds(64); + + expect(validate('submittedCharter', raw).isValid()).to.be.true(); + }); + + it('should refuse sixty-five', async () => { + const raw = await rawProposal(); + raw.reasons = randomIds(65); + + const error = expectJsonSchemaError(validate('submittedCharter', raw)); + + expect(error.keyword).to.equal('maxItems'); + }); + + it('should refuse a repeated reason', async () => { + const raw = await rawProposal(); + const [reason] = randomIds(1); + raw.reasons = [reason, Buffer.from(reason)]; + + const error = expectJsonSchemaError(validate('submittedCharter', raw)); + + expect(error.keyword).to.equal('uniqueItems'); + }); + }); + + describe('moderatorsShare', () => { + it('should be optional, the full declared fee', async () => { + const raw = await rawProposal(); + delete raw.moderatorsShare; + + expect(validate('submittedCharter', raw).isValid()).to.be.true(); + }); + + it('should accept 0, a team that takes no rewards', async () => { + const raw = await rawProposal(); + raw.moderatorsShare = 0; + + expect(validate('submittedCharter', raw).isValid()).to.be.true(); + }); + + it('should refuse a share over 100', async () => { + const raw = await rawProposal(); + raw.moderatorsShare = 101; + + const error = expectJsonSchemaError(validate('submittedCharter', raw)); + + expect(error.keyword).to.equal('maximum'); + }); + }); + + describe('rewardSplit', () => { + ['leader', 'equal', 'actions'].forEach((share) => { + it(`should require ${share}`, async () => { + const raw = await rawProposal(); + delete raw.rewardSplit[share]; + + const error = expectJsonSchemaError(validate('submittedCharter', raw)); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal(share); + }); + }); + + it('should refuse a share over 100', async () => { + const raw = await rawProposal(); + raw.rewardSplit.leader = 101; + + const error = expectJsonSchemaError(validate('submittedCharter', raw)); + + expect(error.keyword).to.equal('maximum'); + }); + + it('should refuse an unknown share', async () => { + const raw = await rawProposal(); + raw.rewardSplit.bonus = 0; + + const error = expectJsonSchemaError(validate('submittedCharter', raw)); + + expect(error.keyword).to.equal('additionalProperties'); + }); + }); + }); + + describe('joinRequest', () => { + const rawJoinRequest = async () => ({ + submittedCharterId: await generateRandomIdentifier(), + recipientId: await generateRandomIdentifier(), + recipientKeyId: 3, + senderKeyId: 2, + // A 16-byte IV and two AES blocks. + encryptedMessage: crypto.randomBytes(48), + }); + + it('should be valid', async () => { + expect(validate('joinRequest', await rawJoinRequest()).isValid()).to.be.true(); + }); + + expectRequired('joinRequest', rawJoinRequest, ['submittedCharterId', 'recipientId', 'recipientKeyId', 'senderKeyId', 'encryptedMessage']); + expectNoAdditionalProperties('joinRequest', rawJoinRequest, 'message'); + + it('should refuse a message shorter than an IV and a block', async () => { + const raw = await rawJoinRequest(); + raw.encryptedMessage = crypto.randomBytes(31); + + const error = expectJsonSchemaError(validate('joinRequest', raw)); + + expect(error.keyword).to.equal('minItems'); + }); + + it('should refuse a message over 1040 bytes', async () => { + const raw = await rawJoinRequest(); + raw.encryptedMessage = crypto.randomBytes(1041); + + const error = expectJsonSchemaError(validate('joinRequest', raw)); + + expect(error.keyword).to.equal('maxItems'); + }); + + it('should refuse a key id over u32', async () => { + const raw = await rawJoinRequest(); + raw.senderKeyId = 4294967296; + + expect(validate('joinRequest', raw).isValid()).to.be.false(); + }); + }); + + describe('electedCharter', () => { + const rawElectedCharter = async () => ({ + targetContractId: await generateRandomIdentifier(), + submittedCharterId: await generateRandomIdentifier(), + members: randomIds(2), + }); + + it('should be valid', async () => { + expect(validate('electedCharter', await rawElectedCharter()).isValid()).to.be.true(); + }); + + expectRequired('electedCharter', rawElectedCharter, ['targetContractId', 'submittedCharterId', 'members']); + expectNoAdditionalProperties('electedCharter', rawElectedCharter, 'leaderPower'); + + it('should accept a leader running alone', async () => { + const raw = await rawElectedCharter(); + raw.members = []; + + expect(validate('electedCharter', raw).isValid()).to.be.true(); + }); + + it('should accept fifteen members', async () => { + const raw = await rawElectedCharter(); + raw.members = randomIds(15); + + expect(validate('electedCharter', raw).isValid()).to.be.true(); + }); + + it('should refuse sixteen members', async () => { + const raw = await rawElectedCharter(); + raw.members = randomIds(16); + + const error = expectJsonSchemaError(validate('electedCharter', raw)); + + expect(error.keyword).to.equal('maxItems'); + }); + + it('should refuse a repeated member', async () => { + const raw = await rawElectedCharter(); + const [member] = randomIds(1); + raw.members = [member, Buffer.from(member)]; + + const error = expectJsonSchemaError(validate('electedCharter', raw)); + + expect(error.keyword).to.equal('uniqueItems'); + }); + }); +}); diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index d98e26f03b6..dcda3d37fc9 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -269,6 +269,7 @@ all-system_contracts = [ "keywords-contract", "document-history-contract", "app-connect-contract", + "moderation-charters-contract", ] # Individual data contract features @@ -284,6 +285,7 @@ token-history-contract = ["data-contracts", "data-contracts/token-history"] 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"] 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/config/moderation/elected.rs b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs index 7afb9aa7a8d..65f4d343b96 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs @@ -2,9 +2,9 @@ //! masternodes and evonodes instead of by the contract owner (protocol version 14). //! //! The declaration is fixed at the contract's creation and never changes: the election -//! parameters, the document types the team moderates with the abilities a charter may claim -//! on each, who moderates until the first team is seated, and whether the owner is protected -//! from the team. A charter does not price the moderators part of a document action: the +//! parameters, the document types the team moderates with the abilities it holds on each, +//! who moderates until the first team is seated, and whether the owner is protected from the +//! team. A charter does not price the moderators part of a document action: the //! type's own `actionFees.moderators` amount is the most a team may charge, and a charter //! charges a share of it. No election exists yet: until one does, the contract is in its //! **interim**, @@ -38,7 +38,7 @@ pub mod property_names { pub const CHALLENGE_COOL_DOWN: &str = "challengeCoolDown"; /// The election delay, in seconds after the contract's creation pub const ELECTION_DELAY: &str = "electionDelay"; - /// The moderated document types, each with the abilities a charter may claim on it + /// The moderated document types, each with the abilities the seated team holds on it pub const MODERATED_DOCUMENT_TYPES: &str = "moderatedDocumentTypes"; /// The interim moderators pub const INTERIM: &str = "interim"; @@ -51,8 +51,9 @@ pub mod property_names { } /// What a moderation team may do to a contract's users and content. The contract declares -/// which of these a charter may claim on each moderated document type; a seated team has -/// what its charter claims. +/// which of these a seated team holds on each moderated document type. A team holds every +/// ability the declaration gives it; its charter narrows what it may act on only through the +/// moderation reasons it lists, since every action names one. /// /// Append-only: the discriminant is stored in every declaration. #[derive( @@ -323,7 +324,7 @@ pub struct ElectedModerators { /// case the election may be called at once. A reference declaring /// `contractRequirements: { "moderation": "electionOpen" }` is what reads it. pub election_delay: Option, - /// The document types the team moderates, each with the abilities a charter may claim + /// The document types the team moderates, each with the abilities the seated team holds /// on it: non-empty, each type a document type of the contract, each ability set /// non-empty and backed by the contract (`Ban`, `Suspend` and `Warn` by the list the /// contract keeps, `DeleteDocuments` by the type being flagged @@ -369,7 +370,7 @@ impl ElectedModerators { .contains_key(document_type_name) } - /// Whether a charter may claim the ability on the document type + /// Whether the seated team holds the ability on the document type pub fn allows(&self, document_type_name: &str, ability: ModerationAbility) -> bool { self.moderated_document_types .get(document_type_name) diff --git a/packages/rs-dpp/src/data_contract/factory/mod.rs b/packages/rs-dpp/src/data_contract/factory/mod.rs index 86bf8b5e346..ea67d9b7c95 100644 --- a/packages/rs-dpp/src/data_contract/factory/mod.rs +++ b/packages/rs-dpp/src/data_contract/factory/mod.rs @@ -88,6 +88,29 @@ impl DataContractFactory { } } + /// Create a DataContract under a given id instead of the one derived from the owner and + /// the nonce, for the system contracts whose ids are published constants. + pub fn create_with_id( + &self, + data_contract_id: Identifier, + owner_id: Identifier, + identity_nonce: IdentityNonce, + documents: Value, + config: Option, + definitions: Option, + ) -> Result { + match self { + DataContractFactory::V0(v0) => v0.create_with_id( + data_contract_id, + owner_id, + identity_nonce, + documents, + config, + definitions, + ), + } + } + #[cfg(feature = "value-conversion")] /// Create a DataContract from a plain object pub fn create_from_object( diff --git a/packages/rs-dpp/src/data_contract/factory/v0/mod.rs b/packages/rs-dpp/src/data_contract/factory/v0/mod.rs index 4ac13e279bc..a924f91c448 100644 --- a/packages/rs-dpp/src/data_contract/factory/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/factory/v0/mod.rs @@ -67,11 +67,37 @@ impl DataContractFactoryV0 { config: Option, definitions: Option, ) -> Result { - let platform_version = PlatformVersion::get(self.protocol_version)?; - let data_contract_id = DataContract::generate_data_contract_id_v0(owner_id.to_buffer(), identity_nonce); + self.create_with_id( + data_contract_id, + owner_id, + identity_nonce, + documents, + config, + definitions, + ) + } + + /// Create Data Contract under a given id instead of the one derived from the owner and + /// the nonce. + /// + /// For the system contracts, whose ids are published constants: the document types keep + /// the id of the contract they belong to, and the checks that compare against it (a key + /// reference's `boundTo`, which requires the key bound to this contract, and a lookup into + /// a document type of the same contract) have to see the real one. + pub fn create_with_id( + &self, + data_contract_id: Identifier, + owner_id: Identifier, + identity_nonce: IdentityNonce, + documents: Value, + config: Option, + definitions: Option, + ) -> Result { + let platform_version = PlatformVersion::get(self.protocol_version)?; + let defs = definitions .map(|defs| defs.into_btree_string_map()) .transpose() 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 75960594ef7..888aca00d81 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -90,6 +90,10 @@ use crate::consensus::basic::identity::{ WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError, }; use crate::consensus::basic::invalid_identifier_error::InvalidIdentifierError; +use crate::consensus::basic::moderation_charter::{ + ModerationCharterDescriptionTooLongError, ModerationCharterMalformedFieldError, + ModerationCharterRewardSplitNotOneHundredError, +}; use crate::consensus::basic::state_transition::{ FeeStrategyDuplicateError, FeeStrategyEmptyError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, InputOutputBalanceMismatchError, @@ -812,6 +816,16 @@ pub enum BasicError { // The shape of an `encryptedFor` property's ciphertext (protocol version 14). #[error(transparent)] InvalidEncryptedPropertyShapeError(InvalidEncryptedPropertyShapeError), + + // Moderation charters (protocol version 14). + #[error(transparent)] + ModerationCharterMalformedFieldError(ModerationCharterMalformedFieldError), + + #[error(transparent)] + ModerationCharterRewardSplitNotOneHundredError(ModerationCharterRewardSplitNotOneHundredError), + + #[error(transparent)] + ModerationCharterDescriptionTooLongError(ModerationCharterDescriptionTooLongError), } impl From for ConsensusError { @@ -908,8 +922,7 @@ mod tests { )), 194 ); - // The shape of an `encryptedFor` property's ciphertext (protocol version 14): the - // tail of the enum. + // The shape of an `encryptedFor` property's ciphertext (protocol version 14). assert_eq!( discriminant_of(BasicError::InvalidEncryptedPropertyShapeError( InvalidEncryptedPropertyShapeError::new( @@ -922,5 +935,27 @@ mod tests { )), 195 ); + // Moderation charters (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(BasicError::ModerationCharterMalformedFieldError( + ModerationCharterMalformedFieldError::new( + "rewardSplit".to_string(), + "reason".to_string() + ) + )), + 196 + ); + assert_eq!( + discriminant_of(BasicError::ModerationCharterRewardSplitNotOneHundredError( + ModerationCharterRewardSplitNotOneHundredError::new(10, 40, 40) + )), + 197 + ); + assert_eq!( + discriminant_of(BasicError::ModerationCharterDescriptionTooLongError( + ModerationCharterDescriptionTooLongError::new(4097, 4096) + )), + 198 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/mod.rs index b3448e58430..6b5b07859b8 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/mod.rs @@ -19,6 +19,7 @@ pub mod group; pub mod invalid_identifier_error; pub mod json_schema_compilation_error; pub mod json_schema_error; +pub mod moderation_charter; pub mod overflow_error; pub mod state_transition; pub mod unsupported_feature_error; diff --git a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs new file mode 100644 index 00000000000..24207bc0246 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs @@ -0,0 +1,7 @@ +mod moderation_charter_description_too_long_error; +mod moderation_charter_malformed_field_error; +mod moderation_charter_reward_split_not_one_hundred_error; + +pub use moderation_charter_description_too_long_error::*; +pub use moderation_charter_malformed_field_error::*; +pub use moderation_charter_reward_split_not_one_hundred_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs new file mode 100644 index 00000000000..7a9ceefb983 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs @@ -0,0 +1,54 @@ +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 thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("The moderation charter's description is {length} bytes long, the maximum is {max_length}")] +#[platform_serialize(unversioned)] +pub struct ModerationCharterDescriptionTooLongError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + length: u64, + max_length: u16, +} + +impl ModerationCharterDescriptionTooLongError { + pub fn new(length: u64, max_length: u16) -> Self { + Self { length, max_length } + } + + /// The length of the description, in bytes of UTF-8 + pub fn length(&self) -> u64 { + self.length + } + + pub fn max_length(&self) -> u16 { + self.max_length + } +} + +impl From for ConsensusError { + fn from(err: ModerationCharterDescriptionTooLongError) -> Self { + Self::BasicError(BasicError::ModerationCharterDescriptionTooLongError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_malformed_field_error.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_malformed_field_error.rs new file mode 100644 index 00000000000..f32614dfd87 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_malformed_field_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 thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("The {field} of the moderation charter is malformed: {reason}")] +#[platform_serialize(unversioned)] +pub struct ModerationCharterMalformedFieldError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + field: String, + reason: String, +} + +impl ModerationCharterMalformedFieldError { + pub fn new(field: String, reason: String) -> Self { + Self { field, reason } + } + + /// The error for the charter property `field`, with `reason` said any way. + pub fn for_field(field: &str, reason: impl Into) -> Self { + Self { + field: field.to_string(), + reason: reason.into(), + } + } + + /// The charter property that is malformed + pub fn field(&self) -> &str { + &self.field + } + + pub fn reason(&self) -> &str { + &self.reason + } +} + +impl From for ConsensusError { + fn from(err: ModerationCharterMalformedFieldError) -> Self { + Self::BasicError(BasicError::ModerationCharterMalformedFieldError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs new file mode 100644 index 00000000000..757f8fcf071 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs @@ -0,0 +1,67 @@ +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 thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "The moderation charter's reward split of {leader}% to the leader, {equal}% equally and {actions}% by action count sums to {}%, it must sum to 100%", + *leader as u16 + *equal as u16 + *actions as u16 +)] +#[platform_serialize(unversioned)] +pub struct ModerationCharterRewardSplitNotOneHundredError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + leader: u8, + equal: u8, + actions: u8, +} + +impl ModerationCharterRewardSplitNotOneHundredError { + pub fn new(leader: u8, equal: u8, actions: u8) -> Self { + Self { + leader, + equal, + actions, + } + } + + pub fn leader(&self) -> u8 { + self.leader + } + + pub fn equal(&self) -> u8 { + self.equal + } + + pub fn actions(&self) -> u8 { + self.actions + } +} + +impl From for ConsensusError { + fn from(err: ModerationCharterRewardSplitNotOneHundredError) -> Self { + Self::BasicError(BasicError::ModerationCharterRewardSplitNotOneHundredError( + err, + )) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 3e04ecfe6b9..4fb0b499aa3 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -272,6 +272,11 @@ impl ErrorWithCode for BasicError { Self::DocumentActionFeesWithoutModerationError(_) => 10902, Self::ContractModerationReasonTooLongError(_) => 10903, Self::InvalidContractModerationReasonDocumentsError(_) => 10904, + + // Moderation Team Errors: 11000-11099 + Self::ModerationCharterMalformedFieldError(_) => 11000, + Self::ModerationCharterRewardSplitNotOneHundredError(_) => 11001, + Self::ModerationCharterDescriptionTooLongError(_) => 11002, } } } diff --git a/packages/rs-dpp/src/lib.rs b/packages/rs-dpp/src/lib.rs index 675e5b00c55..23e18eb3c67 100644 --- a/packages/rs-dpp/src/lib.rs +++ b/packages/rs-dpp/src/lib.rs @@ -80,6 +80,7 @@ pub mod core_types; pub mod address_funds; pub mod contract_group; pub mod group; +pub mod moderation_charter; pub mod shielded; pub mod withdrawal; diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs new file mode 100644 index 00000000000..881a6d41b89 --- /dev/null +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -0,0 +1,366 @@ +//! Moderation charters. +//! +//! A data contract may declare that its moderation team is elected by the masternodes +//! (decentralized moderation teams, protocol version 14). The moderation charters system +//! contract holds how a team comes to be: +//! +//! - a `reason` is a ground for a moderation action, keyed by its owner and a three-letter code; +//! - a `submittedCharter` is a leader's proposal to moderate one contract on that contract's own +//! terms: the reasons its actions may name, the share of the moderators fee it takes and how +//! it splits the pay; +//! - a `joinRequest` is an identity's offer to serve on the team of a proposal, with a message +//! only the leader can read; +//! - an `electedCharter` is a proposal put to the vote with its team, chosen from the identities +//! that asked to join it. Creating one opens or joins the contest for the target contract. +//! +//! The schema carries almost every rule through its keywords (references, lookups, key +//! requirements, `distinctFrom`). What it cannot say is here: [`SubmittedCharter`] and +//! [`ElectedCharter`] read the documents' properties, and [`validate_submitted_charter`] adds +//! the proposal's two pure-data rules, which the path that seats a team runs. Nothing here reads +//! state. + +mod v0; + +use crate::consensus::basic::moderation_charter::ModerationCharterMalformedFieldError; +use crate::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; +use crate::ProtocolError; +use platform_value::{Identifier, IdentifierBytes32, Value, ValueMap}; +use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; + +/// The id of the moderation charters system contract, `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. +/// +/// 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 MODERATION_CHARTERS_CONTRACT_ID: Identifier = Identifier(IdentifierBytes32([ + 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, +])); + +/// The name of the reason document type. +pub const REASON_DOCUMENT_TYPE_NAME: &str = "reason"; +/// The name of the proposal document type. +pub const SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME: &str = "submittedCharter"; +/// The name of the join request document type. +pub const JOIN_REQUEST_DOCUMENT_TYPE_NAME: &str = "joinRequest"; +/// The name of the elected charter document type, the one on the contested index. +pub const ELECTED_CHARTER_DOCUMENT_TYPE_NAME: &str = "electedCharter"; + +/// The moderators share a proposal takes when it declares none: the full declared fee. +pub const FULL_MODERATORS_SHARE: u8 = 100; + +/// The properties of the charter document types. +pub mod property_names { + pub const TARGET_CONTRACT_ID: &str = "targetContractId"; + pub const DESCRIPTION: &str = "description"; + pub const REASONS: &str = "reasons"; + pub const MODERATORS_SHARE: &str = "moderatorsShare"; + pub const REWARD_SPLIT: &str = "rewardSplit"; + pub const REWARD_SPLIT_LEADER: &str = "leader"; + pub const REWARD_SPLIT_EQUAL: &str = "equal"; + pub const REWARD_SPLIT_ACTIONS: &str = "actions"; + pub const SUBMITTED_CHARTER_ID: &str = "submittedCharterId"; + pub const MEMBERS: &str = "members"; +} + +/// How a team splits every claim of the moderators pot: three percentages summing to 100. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ModerationCharterRewardSplit { + /// The share of the leader. + pub leader: u8, + /// The share split equally between the members other than the leader. + pub equal: u8, + /// The share split between the members by the moderation actions each signed since the + /// last claim. + pub actions: u8, +} + +impl ModerationCharterRewardSplit { + /// The three shares summed, as declared. + pub fn total(&self) -> u16 { + self.leader as u16 + self.equal as u16 + self.actions as u16 + } +} + +/// A proposal to moderate a contract, as read out of a `submittedCharter` document. Its owner +/// is the leader. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubmittedCharter { + /// The contract the team proposes to moderate. Its elected moderation declaration is the + /// team's whole mandate. + pub target_contract_id: Identifier, + /// What the team would moderate and how, for joiners and voters. Informational. + pub description: String, + /// The ids of the `reason` documents the team's actions may name, in declared order and + /// never repeated. A team with none can take no action. + pub reasons: Vec, + /// The percentage, 0 to 100, of each moderated type's declared moderators fee the team + /// takes. `None` is the full amount; 0 is a team that will not moderate and takes no + /// rewards. See [`SubmittedCharter::moderators_share_or_full`]. + pub moderators_share: Option, + /// How the team splits every claim of the moderators pot. + pub reward_split: ModerationCharterRewardSplit, +} + +/// A proposal put to the vote with its team, as read out of an `electedCharter` document. Its +/// owner is the leader, the owner of the proposal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ElectedCharter { + /// The contract contended for, the proposal's target. + pub target_contract_id: Identifier, + /// The `submittedCharter` document the team runs on. + pub submitted_charter_id: Identifier, + /// The team besides the leader, in declared order and never repeated: each filed a + /// `joinRequest` for the proposal, which the schema's lookup reference checks. + pub members: Vec, +} + +fn malformed(field: &str, reason: impl Into) -> ModerationCharterMalformedFieldError { + ModerationCharterMalformedFieldError::for_field(field, reason) +} + +fn get<'a>( + properties: &'a BTreeMap, + field: &'static str, +) -> Result<&'a Value, ModerationCharterMalformedFieldError> { + properties + .get(field) + .ok_or_else(|| malformed(field, "missing")) +} + +fn identifier_list( + properties: &BTreeMap, + field: &'static str, +) -> Result, ModerationCharterMalformedFieldError> { + get(properties, field)? + .as_array() + .ok_or_else(|| malformed(field, "not a list"))? + .iter() + .map(|element| { + element + .to_identifier() + .map_err(|e| malformed(field, e.to_string())) + }) + .collect() +} + +fn identifier_list_value(identifiers: &[Identifier]) -> Value { + Value::Array( + identifiers + .iter() + .map(|id| Value::Identifier(id.to_buffer())) + .collect(), + ) +} + +impl SubmittedCharter { + /// The share of each moderated type's declared moderators fee the team takes, with an + /// absent share read as the full amount. + pub fn moderators_share_or_full(&self) -> u8 { + self.moderators_share.unwrap_or(FULL_MODERATORS_SHARE) + } + + /// Reads a proposal out of the properties of a `submittedCharter` document. + /// + /// The result carries a consensus error, never a proposal, when a property is missing or + /// of the wrong type. The proposal's own rules are checked by + /// [`SubmittedCharter::validate`]; [`validate_submitted_charter`] does both. + pub fn from_document_properties( + properties: &BTreeMap, + ) -> ConsensusValidationResult { + match Self::try_from_document_properties(properties) { + Ok(charter) => ConsensusValidationResult::new_with_data(charter), + Err(error) => ConsensusValidationResult::new_with_error(error.into()), + } + } + + fn try_from_document_properties( + properties: &BTreeMap, + ) -> Result { + let target_contract_id = get(properties, property_names::TARGET_CONTRACT_ID)? + .to_identifier() + .map_err(|e| malformed(property_names::TARGET_CONTRACT_ID, e.to_string()))?; + + let description = get(properties, property_names::DESCRIPTION)? + .to_str() + .map_err(|e| malformed(property_names::DESCRIPTION, e.to_string()))? + .to_string(); + + let reasons = identifier_list(properties, property_names::REASONS)?; + + let moderators_share = properties + .get(property_names::MODERATORS_SHARE) + .map(|value| { + value + .to_integer::() + .map_err(|e| malformed(property_names::MODERATORS_SHARE, e.to_string())) + }) + .transpose()?; + + let reward_split = get(properties, property_names::REWARD_SPLIT)?; + let share = |name: &str| { + reward_split + .get_integer::(name) + .map_err(|e| malformed(property_names::REWARD_SPLIT, format!("{name}: {e}"))) + }; + let reward_split = ModerationCharterRewardSplit { + leader: share(property_names::REWARD_SPLIT_LEADER)?, + equal: share(property_names::REWARD_SPLIT_EQUAL)?, + actions: share(property_names::REWARD_SPLIT_ACTIONS)?, + }; + + Ok(Self { + target_contract_id, + description, + reasons, + moderators_share, + reward_split, + }) + } + + /// The properties of the `submittedCharter` document that carries this proposal, the way + /// [`SubmittedCharter::from_document_properties`] reads them back. An absent share stays + /// absent. + pub fn to_document_properties(&self) -> BTreeMap { + let mut properties = BTreeMap::from([ + ( + property_names::TARGET_CONTRACT_ID.to_string(), + Value::Identifier(self.target_contract_id.to_buffer()), + ), + ( + property_names::DESCRIPTION.to_string(), + Value::Text(self.description.clone()), + ), + ( + property_names::REASONS.to_string(), + identifier_list_value(&self.reasons), + ), + ( + property_names::REWARD_SPLIT.to_string(), + Value::Map(ValueMap::from([ + ( + Value::Text(property_names::REWARD_SPLIT_LEADER.to_string()), + Value::U8(self.reward_split.leader), + ), + ( + Value::Text(property_names::REWARD_SPLIT_EQUAL.to_string()), + Value::U8(self.reward_split.equal), + ), + ( + Value::Text(property_names::REWARD_SPLIT_ACTIONS.to_string()), + Value::U8(self.reward_split.actions), + ), + ])), + ), + ]); + if let Some(share) = self.moderators_share { + properties.insert( + property_names::MODERATORS_SHARE.to_string(), + Value::U8(share), + ); + } + properties + } + + /// Checks the proposal's own rules, the two the schema cannot express: the reward split + /// sums to 100, and the description fits + /// `SystemLimits::max_moderation_charter_description_length` bytes (the schema's + /// `maxLength` counts characters). + pub fn validate( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .validation + .data_contract + .validate_moderation_charter + { + Some(0) => Ok(self.validate_v0(platform_version)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "SubmittedCharter::validate".to_string(), + known_versions: vec![0], + received: version, + }), + None => Err(ProtocolError::NotSupported(format!( + "moderation charters do not exist at protocol version {}", + platform_version.protocol_version + ))), + } + } +} + +impl ElectedCharter { + /// Reads an elected charter out of the properties of an `electedCharter` document. The + /// result carries a consensus error, never a charter, when a property is missing or of the + /// wrong type. + pub fn from_document_properties( + properties: &BTreeMap, + ) -> ConsensusValidationResult { + match Self::try_from_document_properties(properties) { + Ok(charter) => ConsensusValidationResult::new_with_data(charter), + Err(error) => ConsensusValidationResult::new_with_error(error.into()), + } + } + + fn try_from_document_properties( + properties: &BTreeMap, + ) -> Result { + let target_contract_id = get(properties, property_names::TARGET_CONTRACT_ID)? + .to_identifier() + .map_err(|e| malformed(property_names::TARGET_CONTRACT_ID, e.to_string()))?; + let submitted_charter_id = get(properties, property_names::SUBMITTED_CHARTER_ID)? + .to_identifier() + .map_err(|e| malformed(property_names::SUBMITTED_CHARTER_ID, e.to_string()))?; + let members = identifier_list(properties, property_names::MEMBERS)?; + Ok(Self { + target_contract_id, + submitted_charter_id, + members, + }) + } + + /// The properties of the `electedCharter` document that carries this charter, the way + /// [`ElectedCharter::from_document_properties`] reads them back. + pub fn to_document_properties(&self) -> BTreeMap { + BTreeMap::from([ + ( + property_names::TARGET_CONTRACT_ID.to_string(), + Value::Identifier(self.target_contract_id.to_buffer()), + ), + ( + property_names::SUBMITTED_CHARTER_ID.to_string(), + Value::Identifier(self.submitted_charter_id.to_buffer()), + ), + ( + property_names::MEMBERS.to_string(), + identifier_list_value(&self.members), + ), + ]) + } +} + +/// Reads a proposal out of the properties of a `submittedCharter` document and checks its own +/// rules. The result carries the proposal when it passes, and the first error it fails on +/// otherwise. +pub fn validate_submitted_charter( + properties: &BTreeMap, + platform_version: &PlatformVersion, +) -> Result, ProtocolError> { + let result = SubmittedCharter::from_document_properties(properties); + if !result.is_valid_with_data() { + return Ok(ConsensusValidationResult::new_with_errors(result.errors)); + } + let charter = result.into_data()?; + let validation = charter.validate(platform_version)?; + if validation.is_valid() { + Ok(ConsensusValidationResult::new_with_data(charter)) + } else { + Ok(ConsensusValidationResult::new_with_errors( + validation.errors, + )) + } +} + +#[cfg(test)] +mod tests; diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs new file mode 100644 index 00000000000..42244bf720f --- /dev/null +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -0,0 +1,206 @@ +use super::{ + property_names, validate_submitted_charter, ElectedCharter, ModerationCharterRewardSplit, + SubmittedCharter, FULL_MODERATORS_SHARE, +}; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use platform_value::{Identifier, Value}; +use platform_version::version::PlatformVersion; + +fn proposal() -> SubmittedCharter { + SubmittedCharter { + target_contract_id: Identifier::from([9u8; 32]), + description: "We remove spam and doxing within a day and warn before we act.".to_string(), + reasons: vec![Identifier::from([3u8; 32]), Identifier::from([4u8; 32])], + moderators_share: Some(60), + reward_split: ModerationCharterRewardSplit { + leader: 10, + equal: 40, + actions: 50, + }, + } +} + +fn first_basic_error( + result: &crate::validation::ConsensusValidationResult, +) -> &BasicError { + match result.errors.first() { + Some(ConsensusError::BasicError(error)) => error, + other => panic!("expected a basic error, got {other:?}"), + } +} + +#[test] +fn should_round_trip_a_proposal_through_its_document_properties() { + let proposal = proposal(); + let read = SubmittedCharter::from_document_properties(&proposal.to_document_properties()); + assert!(read.is_valid_with_data()); + assert_eq!(read.into_data().expect("data"), proposal); +} + +#[test] +fn should_keep_an_absent_moderators_share_absent_and_read_it_as_the_full_amount() { + let proposal = SubmittedCharter { + moderators_share: None, + ..proposal() + }; + let properties = proposal.to_document_properties(); + assert!(!properties.contains_key(property_names::MODERATORS_SHARE)); + let read = SubmittedCharter::from_document_properties(&properties) + .into_data() + .expect("data"); + assert_eq!(read.moderators_share, None); + assert_eq!(read.moderators_share_or_full(), FULL_MODERATORS_SHARE); +} + +#[test] +fn should_read_a_zero_share_as_zero() { + let proposal = SubmittedCharter { + moderators_share: Some(0), + ..proposal() + }; + assert_eq!(proposal.moderators_share_or_full(), 0); +} + +#[test] +fn should_accept_a_proposal_without_reasons() { + let proposal = SubmittedCharter { + reasons: vec![], + ..proposal() + }; + let result = validate_submitted_charter( + &proposal.to_document_properties(), + PlatformVersion::latest(), + ) + .expect("validation executes"); + assert!(result.is_valid_with_data()); +} + +#[test] +fn should_accept_a_valid_proposal() { + let result = validate_submitted_charter( + &proposal().to_document_properties(), + PlatformVersion::latest(), + ) + .expect("validation executes"); + assert!(result.is_valid_with_data(), "{:?}", result.errors); +} + +#[test] +fn should_refuse_a_reward_split_that_does_not_sum_to_one_hundred() { + for (leader, equal, actions) in [(10, 40, 40), (50, 50, 1), (0, 0, 0)] { + let proposal = SubmittedCharter { + reward_split: ModerationCharterRewardSplit { + leader, + equal, + actions, + }, + ..proposal() + }; + let result = validate_submitted_charter( + &proposal.to_document_properties(), + PlatformVersion::latest(), + ) + .expect("validation executes"); + assert!(matches!( + first_basic_error(&result), + BasicError::ModerationCharterRewardSplitNotOneHundredError(e) + if (e.leader(), e.equal(), e.actions()) == (leader, equal, actions) + )); + } +} + +#[test] +fn should_refuse_a_description_over_the_byte_limit() { + let platform_version = PlatformVersion::latest(); + let limit = platform_version + .system_limits + .max_moderation_charter_description_length as usize; + + let at_limit = SubmittedCharter { + description: "a".repeat(limit), + ..proposal() + }; + assert!( + validate_submitted_charter(&at_limit.to_document_properties(), platform_version) + .expect("validation executes") + .is_valid_with_data() + ); + + // Fewer characters than the schema's maxLength, more bytes than the consensus cap. + let over = SubmittedCharter { + description: "é".repeat(limit / 2 + 1), + ..proposal() + }; + let result = validate_submitted_charter(&over.to_document_properties(), platform_version) + .expect("validation executes"); + assert!(matches!( + first_basic_error(&result), + BasicError::ModerationCharterDescriptionTooLongError(e) + if e.length() == (limit + 2) as u64 + )); +} + +#[test] +fn should_refuse_a_missing_or_mistyped_property() { + for field in [ + property_names::TARGET_CONTRACT_ID, + property_names::DESCRIPTION, + property_names::REASONS, + property_names::REWARD_SPLIT, + ] { + let mut properties = proposal().to_document_properties(); + properties.remove(field); + let result = SubmittedCharter::from_document_properties(&properties); + assert!( + matches!( + result.errors.first(), + Some(ConsensusError::BasicError( + BasicError::ModerationCharterMalformedFieldError(e) + )) if e.field() == field + ), + "{field}" + ); + } + + let mut properties = proposal().to_document_properties(); + properties.insert( + property_names::REASONS.to_string(), + Value::Array(vec![Value::Text("not an id".to_string())]), + ); + assert!(matches!( + SubmittedCharter::from_document_properties(&properties).errors.first(), + Some(ConsensusError::BasicError( + BasicError::ModerationCharterMalformedFieldError(e) + )) if e.field() == property_names::REASONS + )); +} + +#[test] +fn should_refuse_to_validate_below_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("version 13"); + assert!(proposal().validate(platform_version).is_err()); +} + +#[test] +fn should_round_trip_an_elected_charter_through_its_document_properties() { + let charter = ElectedCharter { + target_contract_id: Identifier::from([9u8; 32]), + submitted_charter_id: Identifier::from([7u8; 32]), + members: vec![Identifier::from([2u8; 32]), Identifier::from([5u8; 32])], + }; + let read = ElectedCharter::from_document_properties(&charter.to_document_properties()); + assert!(read.is_valid_with_data()); + assert_eq!(read.into_data().expect("data"), charter); + + let alone = ElectedCharter { + members: vec![], + ..charter + }; + assert_eq!( + ElectedCharter::from_document_properties(&alone.to_document_properties()) + .into_data() + .expect("data"), + alone + ); +} diff --git a/packages/rs-dpp/src/moderation_charter/v0/mod.rs b/packages/rs-dpp/src/moderation_charter/v0/mod.rs new file mode 100644 index 00000000000..e81ce193c13 --- /dev/null +++ b/packages/rs-dpp/src/moderation_charter/v0/mod.rs @@ -0,0 +1,40 @@ +use crate::consensus::basic::moderation_charter::{ + ModerationCharterDescriptionTooLongError, ModerationCharterRewardSplitNotOneHundredError, +}; +use crate::moderation_charter::SubmittedCharter; +use crate::validation::SimpleConsensusValidationResult; +use platform_version::version::PlatformVersion; + +impl SubmittedCharter { + #[inline(always)] + pub(super) fn validate_v0( + &self, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + if self.reward_split.total() != 100 { + return SimpleConsensusValidationResult::new_with_error( + ModerationCharterRewardSplitNotOneHundredError::new( + self.reward_split.leader, + self.reward_split.equal, + self.reward_split.actions, + ) + .into(), + ); + } + + let max_description_length = platform_version + .system_limits + .max_moderation_charter_description_length; + if self.description.len() > max_description_length as usize { + return SimpleConsensusValidationResult::new_with_error( + ModerationCharterDescriptionTooLongError::new( + self.description.len() as u64, + max_description_length, + ) + .into(), + ); + } + + SimpleConsensusValidationResult::new() + } +} diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index 66b865d0012..046a77390cc 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -74,10 +74,22 @@ impl ConfigurationForSystemContract for SystemDataContract { config.set_sized_integer_types_enabled(true); Ok(config) } + SystemDataContract::ModerationCharters => { + let mut config = DataContractConfig::default_for_version(platform_version)?; + config.set_sized_integer_types_enabled(true); + Ok(config) + } } } } +/// Builds a system contract from its source, with full validation, under its published id. +/// +/// The contract is parsed under the published id rather than one derived from the owner and +/// a nonce and renamed afterwards: the document types remember the contract id they belong +/// to, and the checks that compare against it read that one. The moderation charters +/// contract's key references require keys bound to its own document types (`boundTo`), which +/// would name the wrong contract under any other id. fn create_data_contract( factory: &DataContractFactory, system_contract: SystemDataContract, @@ -93,18 +105,15 @@ fn create_data_contract( .source(platform_version) .map_err(|e| ProtocolError::Generic(e.to_string()))?; - let id = Identifier::from(id_bytes); - let owner_id = Identifier::from(owner_id_bytes); - - let mut data_contract = factory.create( - owner_id, + let mut data_contract = factory.create_with_id( + Identifier::from(id_bytes), + Identifier::from(owner_id_bytes), 0, document_schemas.into(), Some(system_contract.configuration_in_platform_version(platform_version)?), definitions.map(|def| def.into()), )?; - data_contract.data_contract_mut().set_id(id); data_contract.data_contract_mut().set_version(version); Ok(data_contract.data_contract_owned()) @@ -296,3 +305,501 @@ mod app_connect_tests { ); } } + +#[cfg(all(test, feature = "moderation-charters-contract", feature = "validation"))] +mod moderation_charters_tests { + use super::*; + use crate::consensus::ConsensusError; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; + use crate::data_contract::document_type::random_document::CreateRandomDocument; + use crate::data_contract::document_type::{ + ContestedIndexResolution, ContractReferenceModeration, DistinctFrom, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, EncryptedForRecipient, + EncryptionScheme, KeyReferenceIdentityProperty, LookupKeySource, PropertyReference, + }; + use crate::data_contract::validate_document::DataContractDocumentValidationMethodsV0; + use crate::document::{Document, DocumentV0Getters, DocumentV0Setters}; + use crate::identity::Purpose; + use crate::moderation_charter::{ + property_names, validate_submitted_charter, ElectedCharter, ModerationCharterRewardSplit, + SubmittedCharter, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, JOIN_REQUEST_DOCUMENT_TYPE_NAME, + MODERATION_CHARTERS_CONTRACT_ID, REASON_DOCUMENT_TYPE_NAME, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + }; + use platform_value::{Identifier, Value}; + + fn contract() -> DataContract { + load_system_data_contract( + SystemDataContract::ModerationCharters, + PlatformVersion::latest(), + ) + .expect("the moderation charters contract loads") + } + + fn document_type<'a>(contract: &'a DataContract, name: &str) -> &'a DocumentType { + contract + .document_types() + .get(name) + .unwrap_or_else(|| panic!("the {name} type")) + } + + fn reference<'a>( + contract: &'a DataContract, + type_name: &str, + property: &str, + ) -> PropertyReference<'a> { + document_type(contract, type_name) + .flattened_properties() + .get(property) + .unwrap_or_else(|| panic!("{type_name}.{property}")) + .property_type + .reference() + .unwrap_or_else(|| panic!("{type_name}.{property} declares a reference")) + } + + fn proposal() -> SubmittedCharter { + SubmittedCharter { + target_contract_id: Identifier::from([9u8; 32]), + description: "We remove spam and doxing within a day.".to_string(), + reasons: vec![Identifier::from([3u8; 32]), Identifier::from([4u8; 32])], + moderators_share: None, + reward_split: ModerationCharterRewardSplit { + leader: 10, + equal: 40, + actions: 50, + }, + } + } + + fn document_with( + contract: &DataContract, + type_name: &str, + properties: std::collections::BTreeMap, + ) -> Document { + let mut document = document_type(contract, type_name) + .random_document(Some(42), PlatformVersion::latest()) + .expect("a random document"); + document.set_properties(properties); + document + } + + fn schema_validation( + contract: &DataContract, + type_name: &str, + document: &Document, + ) -> Vec { + contract + .validate_document(type_name, document, PlatformVersion::latest()) + .expect("validation executes") + .errors + } + + #[test] + fn should_spell_the_same_id_in_the_crate_and_in_dpp() { + assert_eq!( + moderation_charters_contract::ID, + MODERATION_CHARTERS_CONTRACT_ID + ); + assert_eq!(contract().id(), MODERATION_CHARTERS_CONTRACT_ID); + } + + /// The document types remember the contract id they were parsed under, and the key bound + /// and lookup checks compare against it, so the contract has to be built under its + /// published id rather than renamed afterwards. + #[test] + fn should_parse_every_document_type_under_the_published_id() { + let contract = contract(); + let mut names: Vec<&str> = contract + .document_types() + .keys() + .map(String::as_str) + .collect(); + names.sort(); + assert_eq!( + names, + vec![ + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + JOIN_REQUEST_DOCUMENT_TYPE_NAME, + REASON_DOCUMENT_TYPE_NAME, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + ] + ); + for name in names { + let document_type = document_type(&contract, name); + assert_eq!( + document_type.data_contract_id(), + MODERATION_CHARTERS_CONTRACT_ID + ); + assert!(!document_type.documents_mutable(), "{name} is immutable"); + assert!( + !document_type.documents_can_be_deleted(), + "{name} is undeletable" + ); + } + } + + #[test] + fn should_declare_only_the_elected_charter_as_a_no_locking_contest() { + let contract = contract(); + for name in [ + REASON_DOCUMENT_TYPE_NAME, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + JOIN_REQUEST_DOCUMENT_TYPE_NAME, + ] { + assert!( + document_type(&contract, name) + .find_contested_index() + .is_none(), + "{name} is not contested" + ); + } + let index = document_type(&contract, ELECTED_CHARTER_DOCUMENT_TYPE_NAME) + .find_contested_index() + .expect("the elected charter type has a contested index"); + assert_eq!(index.name, "byTargetContract"); + assert!(index.unique); + assert_eq!( + index + .properties + .iter() + .map(|p| p.name.as_str()) + .collect::>(), + vec![property_names::TARGET_CONTRACT_ID] + ); + assert_eq!( + index + .contested_index + .as_ref() + .expect("contested") + .resolution, + ContestedIndexResolution::MasternodeVoteNoLocking + ); + } + + #[test] + fn should_let_teams_form_before_the_election_opens() { + let contract = contract(); + let moderation = + |type_name| match reference(&contract, type_name, property_names::TARGET_CONTRACT_ID) { + PropertyReference::Value(DocumentPropertyReferenceTarget::Contract { + contract_requirements, + }) => contract_requirements.moderation, + other => panic!("{type_name}.targetContractId: {other:?}"), + }; + assert_eq!( + moderation(SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME), + Some(ContractReferenceModeration::Elected) + ); + assert_eq!( + moderation(ELECTED_CHARTER_DOCUMENT_TYPE_NAME), + Some(ContractReferenceModeration::ElectionOpen) + ); + } + + #[test] + fn should_list_reasons_as_references_to_reason_documents() { + let contract = contract(); + match reference( + &contract, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names::REASONS, + ) { + PropertyReference::Elements { + target: + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name, + .. + }, + max_items, + } => { + assert_eq!(document_type_name, REASON_DOCUMENT_TYPE_NAME); + assert_eq!(max_items, 64); + } + other => panic!("reasons: {other:?}"), + } + } + + #[test] + fn should_address_a_join_request_to_the_leader_under_bound_keys() { + let contract = contract(); + match reference( + &contract, + JOIN_REQUEST_DOCUMENT_TYPE_NAME, + "submittedCharterId", + ) { + PropertyReference::Value(DocumentPropertyReferenceTarget::PermanentDocument { + document_type_name, + property_agreement, + .. + }) => { + assert_eq!(document_type_name, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME); + assert_eq!( + property_agreement.get("recipientId").map(String::as_str), + Some("$ownerId"), + "the recipient is the proposal's owner" + ); + } + other => panic!("submittedCharterId: {other:?}"), + } + match reference(&contract, JOIN_REQUEST_DOCUMENT_TYPE_NAME, "recipientId") { + PropertyReference::Value(DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, + key_requirements, + }) => { + assert_eq!(key_id_property, "recipientKeyId"); + assert_eq!(key_requirements.purpose, Some(Purpose::DECRYPTION)); + assert_eq!( + key_requirements.bound_to.as_deref(), + Some(SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME) + ); + } + other => panic!("recipientId: {other:?}"), + } + match reference(&contract, JOIN_REQUEST_DOCUMENT_TYPE_NAME, "senderKeyId") { + PropertyReference::KeyId(key_reference) => { + assert_eq!( + key_reference.identity_property, + KeyReferenceIdentityProperty::OwnerId + ); + assert_eq!( + key_reference.key_requirements.purpose, + Some(Purpose::ENCRYPTION) + ); + assert_eq!( + key_reference.key_requirements.bound_to.as_deref(), + Some(JOIN_REQUEST_DOCUMENT_TYPE_NAME) + ); + } + other => panic!("senderKeyId: {other:?}"), + } + let encrypted_for = document_type(&contract, JOIN_REQUEST_DOCUMENT_TYPE_NAME) + .flattened_properties() + .get("encryptedMessage") + .and_then(|property| property.encrypted_for.clone()) + .expect("the message declares its envelope"); + assert_eq!( + encrypted_for.recipient, + EncryptedForRecipient::Property("recipientId".to_string()) + ); + assert_eq!(encrypted_for.recipient_key, "recipientKeyId"); + assert_eq!(encrypted_for.sender_key, "senderKeyId"); + assert_eq!( + encrypted_for.scheme, + EncryptionScheme::EcdhSecp256k1Aes256Cbc + ); + } + + #[test] + fn should_open_the_contest_only_from_the_leaders_own_proposal() { + let contract = contract(); + match reference( + &contract, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names::SUBMITTED_CHARTER_ID, + ) { + PropertyReference::Value(DocumentPropertyReferenceTarget::PermanentDocument { + document_type_name, + property_agreement, + .. + }) => { + assert_eq!(document_type_name, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME); + assert_eq!( + property_agreement.get("$ownerId").map(String::as_str), + Some("$ownerId") + ); + assert_eq!( + property_agreement + .get(property_names::TARGET_CONTRACT_ID) + .map(String::as_str), + Some(property_names::TARGET_CONTRACT_ID) + ); + } + other => panic!("submittedCharterId: {other:?}"), + } + } + + #[test] + fn should_choose_members_only_from_the_proposals_join_requests() { + let contract = contract(); + let members = document_type(&contract, ELECTED_CHARTER_DOCUMENT_TYPE_NAME) + .flattened_properties() + .get(property_names::MEMBERS) + .expect("members") + .clone(); + match members.property_type.reference() { + Some(PropertyReference::Elements { + target: + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + document_type_name, + lookup, + .. + }, + max_items, + }) => { + assert_eq!(document_type_name, JOIN_REQUEST_DOCUMENT_TYPE_NAME); + assert_eq!(max_items, 15); + assert_eq!(lookup.index, "bySubmittedCharter"); + assert_eq!( + lookup.keys.get(property_names::SUBMITTED_CHARTER_ID), + Some(&LookupKeySource::Property( + property_names::SUBMITTED_CHARTER_ID.to_string() + )) + ); + assert_eq!( + lookup.keys.get("$ownerId"), + Some(&LookupKeySource::ReferenceValue) + ); + } + other => panic!("members: {other:?}"), + } + let DocumentPropertyType::TypedArray(typed_array) = &members.property_type else { + panic!("members is a typed array"); + }; + assert!(typed_array.unique_items); + assert_eq!(typed_array.min_items.unwrap_or_default(), 0); + assert_eq!( + members.distinct_from, + Some(DistinctFrom::OwnerId), + "the leader cannot list themself" + ); + let join_request = document_type(&contract, JOIN_REQUEST_DOCUMENT_TYPE_NAME); + let index = join_request + .indexes() + .get("bySubmittedCharter") + .expect("the join request's unique index"); + assert!(index.unique); + assert!( + !join_request.documents_transferable().is_transferable(), + "a lookup may key on $ownerId only on a type that cannot change hands" + ); + } + + #[test] + fn should_round_trip_a_proposal_through_the_system_contract() { + let contract = contract(); + let proposal = proposal(); + let document = document_with( + &contract, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + proposal.to_document_properties(), + ); + assert_eq!( + schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document), + vec![], + "the encoded proposal passes the schema" + ); + let read = validate_submitted_charter(document.properties(), PlatformVersion::latest()) + .expect("validation executes") + .into_data() + .expect("the proposal is valid"); + assert_eq!(read, proposal); + } + + #[test] + fn should_round_trip_an_elected_charter_through_the_system_contract() { + let contract = contract(); + let charter = ElectedCharter { + target_contract_id: Identifier::from([9u8; 32]), + submitted_charter_id: Identifier::from([7u8; 32]), + members: vec![Identifier::from([2u8; 32]), Identifier::from([5u8; 32])], + }; + let document = document_with( + &contract, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + charter.to_document_properties(), + ); + assert_eq!( + schema_validation(&contract, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, &document), + vec![], + "the encoded elected charter passes the schema" + ); + let read = ElectedCharter::from_document_properties(document.properties()) + .into_data() + .expect("the elected charter reads back"); + assert_eq!(read, charter); + } + + #[test] + fn should_refuse_through_the_schema_what_it_can_express() { + let contract = contract(); + let too_many = + |count: u8| Value::Array((0..count).map(|i| Value::Identifier([i; 32])).collect()); + for (type_name, property, value, expected_keyword) in [ + ( + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names::DESCRIPTION, + Value::Text("a".repeat(4097)), + "maxLength", + ), + ( + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names::DESCRIPTION, + Value::Text(String::new()), + "minLength", + ), + ( + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names::REASONS, + too_many(65), + "maxItems", + ), + ( + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names::REASONS, + Value::Array(vec![Value::Identifier([3; 32]), Value::Identifier([3; 32])]), + "uniqueItems", + ), + ( + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names::MODERATORS_SHARE, + Value::U8(101), + "maximum", + ), + ( + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names::MEMBERS, + too_many(16), + "maxItems", + ), + ] { + let properties = if type_name == SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME { + proposal().to_document_properties() + } else { + ElectedCharter { + target_contract_id: Identifier::from([9u8; 32]), + submitted_charter_id: Identifier::from([7u8; 32]), + members: vec![], + } + .to_document_properties() + }; + let mut document = document_with(&contract, type_name, properties); + document.set(property, value); + let errors = schema_validation(&contract, type_name, &document); + let message = format!("{errors:?}"); + assert!( + !errors.is_empty() && message.contains(expected_keyword), + "{type_name}.{property}: expected a {expected_keyword} error, got {message}" + ); + } + for property in [ + property_names::TARGET_CONTRACT_ID, + property_names::DESCRIPTION, + property_names::REASONS, + property_names::REWARD_SPLIT, + ] { + let mut document = document_with( + &contract, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + proposal().to_document_properties(), + ); + document.properties_mut().remove(property); + assert!( + !schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document) + .is_empty(), + "{property} is required" + ); + } + } +} diff --git a/packages/rs-drive/src/cache/system_contracts.rs b/packages/rs-drive/src/cache/system_contracts.rs index 8c562a5bce3..f7a58efd703 100644 --- a/packages/rs-drive/src/cache/system_contracts.rs +++ b/packages/rs-drive/src/cache/system_contracts.rs @@ -211,6 +211,11 @@ impl SystemDataContracts { // 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), + // Registered but not yet written to state at any protocol version: the election a + // charter create opens does not exist yet. The PR that adds it writes the contract + // to state on the upgrade to protocol version 14 and gives it an activation version + // here; until then a lookup falls through to grovedb and reports it absent. + SystemDataContract::ModerationCharters => return Ok(None), }; if activated_at_protocol_version > platform_version.protocol_version { @@ -410,6 +415,19 @@ mod tests { .is_some()); } + #[test] + fn should_not_serve_moderation_charters_until_it_is_written_to_state() { + let contracts = SystemDataContracts::new(); + + assert!(contracts + .find_by_id( + SystemDataContract::ModerationCharters.id(), + PlatformVersion::latest() + ) + .expect("expected the lookup to succeed") + .is_none()); + } + #[test] fn should_serve_app_connect_only_from_its_activation_version() { let contracts = SystemDataContracts::new(); diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs index 52c521e4309..75c4ae4182c 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs @@ -45,6 +45,9 @@ pub struct DataContractValidationVersions { /// version 14: version 1 distribution rules and once-per-identity claims are rejected as /// unsupported, matching older software that can not decode them. pub validate_once_per_identity_distribution: OptionalFeatureVersion, + /// `ModerationCharter::validate`, the pure-data rules of a moderation charter. `None` below + /// protocol version 14, where the moderation charters system contract does not exist. + pub validate_moderation_charter: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs index 622b080100a..8cf7f3672b0 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs @@ -21,6 +21,7 @@ pub const DPP_VALIDATION_VERSIONS_V1: DPPValidationVersions = DPPValidationVersi validate_token_config_groups_exist: 0, validate_localizations: 0, validate_once_per_identity_distribution: None, + validate_moderation_charter: None, }, document_type: DocumentTypeValidationVersions { validate_update: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs index 4d23c704c0d..01c2a794c6f 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs @@ -21,6 +21,7 @@ pub const DPP_VALIDATION_VERSIONS_V2: DPPValidationVersions = DPPValidationVersi validate_token_config_groups_exist: 0, validate_localizations: 0, validate_once_per_identity_distribution: None, + validate_moderation_charter: None, }, document_type: DocumentTypeValidationVersions { validate_update: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs index a1053ad10b9..931bda8f923 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs @@ -22,6 +22,7 @@ pub const DPP_VALIDATION_VERSIONS_V3: DPPValidationVersions = DPPValidationVersi validate_token_config_groups_exist: 0, validate_localizations: 0, validate_once_per_identity_distribution: None, + validate_moderation_charter: None, }, document_type: DocumentTypeValidationVersions { validate_update: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs index 90ca0d7155b..823fa9a7237 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs @@ -23,6 +23,9 @@ pub const DPP_VALIDATION_VERSIONS_V5: DPPValidationVersions = DPPValidationVersi data_contract: DataContractValidationVersions { validate_config_update: 2, validate_once_per_identity_distribution: Some(0), + // Moderation charters: the charter system contract and the pure-data rules of a + // charter exist from this protocol version on. + validate_moderation_charter: Some(0), ..DPP_VALIDATION_VERSIONS_V4.data_contract }, document_type: DocumentTypeValidationVersions { 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 1105d50cd62..08e6031b480 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -597,6 +597,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, max_contract_moderation_challenge_cool_down_seconds: 94_608_000, contract_document_restore_window_ms: 604_800_000, + max_moderation_charter_description_length: 4096, max_token_redemption_cycles: 128, max_shielded_transition_actions: 16, max_time_range_overlap_factor: None, 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 62b8a6ea477..a8214a67d46 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 @@ -15,4 +15,5 @@ pub struct SystemDataContractVersions { pub keyword_search: FeatureVersion, pub document_history: FeatureVersion, pub app_connect: FeatureVersion, + pub moderation_charters: 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 b81a0bfb104..c800f384b2c 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 @@ -13,4 +13,7 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V1: SystemDataContractVersions = // The app-connect contract does not exist before protocol version 14: no // schema generation is selected here, so loading it is refused. app_connect: 0, + // The moderation charters contract does not exist before protocol version 14 + // either: no schema generation is selected here. + moderation_charters: 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 c4a4f8e8035..8dc349d7ad1 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 @@ -18,4 +18,7 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V2: SystemDataContractVersions = // The app-connect contract does not exist before protocol version 14: no // schema generation is selected here, so loading it is refused. app_connect: 0, + // The moderation charters contract does not exist before protocol version 14 + // either: no schema generation is selected here. + moderation_charters: 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 57f8f319595..5bdeac3b0b4 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 @@ -16,6 +16,11 @@ use crate::version::system_data_contract_versions::SystemDataContractVersions; // inserted by `transition_to_version_14` on chains upgrading from 13. The // earlier tables carry 0, which no schema generation answers to, so the table // itself refuses to load the contract before 14. +// +// The moderation charters contract (moderation_charters: 1) is registered at +// PROTOCOL_VERSION_14 too, but is not yet written to state at genesis or on +// upgrade: the election that a charter create opens does not exist yet, and the +// PR that adds it writes the contract to state. pub const SYSTEM_DATA_CONTRACT_VERSIONS_V3: SystemDataContractVersions = SystemDataContractVersions { withdrawals: 2, @@ -27,4 +32,5 @@ pub const SYSTEM_DATA_CONTRACT_VERSIONS_V3: SystemDataContractVersions = keyword_search: 1, document_history: 1, app_connect: 1, + moderation_charters: 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 3bd698ed411..bc7eb8b52fa 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -162,6 +162,11 @@ pub struct SystemLimits { /// action): a week. Read by the `ContractUserModeration` state validation v0 (protocol /// version 14) and never reached before. pub contract_document_restore_window_ms: u64, + /// Maximum length, in bytes of UTF-8, of a moderation charter's description. Read by + /// `SubmittedCharter::validate` v0 (protocol version 14) and never reached before; the + /// charter schema pins the same number as the description's `maxLength`, which the JSON + /// schema validator counts in characters, so the byte cap is this check's. + pub max_moderation_charter_description_length: u16, // This the max redemption cycles we can process if we don't use a constant distribution // For a constant perpetual distribution this is very cheap since it's just a multiplication // For other distributions we much calculate at each cycle the rewards, so we don't want to 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 09ae4b30517..0d49bc4421a 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -58,6 +58,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days + max_moderation_charter_description_length: 4096, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is 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 a0a40ae65be..bbd3678e66c 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -39,6 +39,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days + max_moderation_charter_description_length: 4096, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is 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 b14cac1f1d1..9bcbe4a9ace 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -41,6 +41,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days + max_moderation_charter_description_length: 4096, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is 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 d9269889647..153e5533f12 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -58,6 +58,9 @@ use crate::version::system_limits::SystemLimits; /// `maxItems` per typed array whose elements declare one (`max_references_per_document`, /// backfilled into the earlier tables, whose parsers never read it). Each reference is a /// billed state read when the document is written. +/// * Moderation charters (protocol version 14): a charter's description is at most 4096 +/// bytes (`max_moderation_charter_description_length`, joined this table in place while +/// protocol version 14 was unreleased). pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB @@ -93,6 +96,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days + max_moderation_charter_description_length: 4096, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 8f9acd35690..06f47ddab53 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -759,6 +759,43 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// by-id joins refuse a lookup reference as a join property, and /// preallocated indexes are never bound through one. /// +/// 33. **The moderation charters system contract** +/// (`SystemDataContract::ModerationCharters`, schema v1, the first piece of +/// decentralized moderation teams) carries four document types, all +/// immutable and undeletable. A `reason` is a ground for a moderation +/// action, keyed by its owner and a three-letter `code` unique among the +/// owner's reasons. A `submittedCharter` is a leader's proposal to +/// moderate one contract on that contract's own terms: its +/// `targetContractId` refers to a contract declaring elected moderation +/// (item 24, `moderation: "elected"`, so teams form during the contract's +/// election delay), its `reasons` are a typed array (item 25) of +/// references to reasons (item 31), and it carries an optional +/// `moderatorsShare` and a `rewardSplit`. A `joinRequest` is an identity's +/// offer to serve on a proposal, one per identity per proposal, whose +/// `recipientId` must be the proposal's owner (`propertyAgreement`) and +/// name a decryption key bound to `submittedCharter` (item 29), whose +/// `senderKeyId` is an encryption key of the writer bound to `joinRequest` +/// (item 30) and whose `encryptedMessage` declares its envelope (item 27). +/// An `electedCharter` is a proposal put to the vote with its team: only +/// the proposal's owner may create one, for the proposal's own target +/// (`propertyAgreement`), its `targetContractId` requires +/// `moderation: "electionOpen"`, and its `members` are identities each of +/// which filed a join request for that proposal (item 32, a lookup through +/// the join request's unique index) and none of which is the leader +/// (item 26). Its `byTargetContract` index is a contested unique index +/// with `"resolution": 1`, the masternode vote without a Lock choice of +/// item 23, so an elected charter create opens or joins the contest for +/// its target. `SYSTEM_DATA_CONTRACT_VERSIONS_V3` registers it +/// (`moderation_charters: 1`), and +/// `DPP_VALIDATION_VERSIONS_V5.validate_moderation_charter = Some(0)` turns +/// on the pure-data rules the seating path will run on a proposal: its +/// reward split sums to 100 and its description fits +/// `SystemLimits::max_moderation_charter_description_length` bytes (basic +/// errors 11000 to 11002). Nothing writes the contract to state yet, at +/// genesis or on upgrade, and the Drive cache does not serve it: the +/// seating of an elected team comes in a later pull request, which writes +/// the contract to state. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's @@ -766,6 +803,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// chains born at this version; `transition_to_version_14` inserts it on upgrade. /// The Drive and trusted SDK caches serve it only from protocol version 14. /// +/// /// * `ShieldFromIdentity` (state transition type 21) activates: /// `SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION = 14` gates it in /// `is_allowed`, and `DRIVE_ABCI_VALIDATION_VERSIONS_V10` is the first diff --git a/packages/rs-sdk-ffi/Cargo.toml b/packages/rs-sdk-ffi/Cargo.toml index a95d87e5d5f..72cd4e85d68 100644 --- a/packages/rs-sdk-ffi/Cargo.toml +++ b/packages/rs-sdk-ffi/Cargo.toml @@ -25,6 +25,7 @@ rs-sdk-trusted-context-provider = { path = "../rs-sdk-trusted-context-provider", "keywords-contract", "document-history-contract", "app-connect-contract", + "moderation-charters-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 69d488e11b9..f93f42e7a75 100644 --- a/packages/rs-sdk-trusted-context-provider/Cargo.toml +++ b/packages/rs-sdk-trusted-context-provider/Cargo.toml @@ -43,6 +43,7 @@ all-system-contracts = [ "keywords-contract", "document-history-contract", "app-connect-contract", + "moderation-charters-contract", ] # Individual contract features - these enable specific contracts in DPP @@ -54,6 +55,7 @@ token-history-contract = ["dpp/token-history-contract"] 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"] [target.'cfg(not(target_os = "android"))'.dependencies] reqwest = { version = "0.12", features = ["json"] } diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 0a75a9f36c6..f0585dbc021 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -144,6 +144,7 @@ token-history-contract = ["dpp/token-history-contract"] 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"] 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 88453f8962f..13de304dc1b 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -95,6 +95,10 @@ use dpp::consensus::basic::contract_moderation::{ DocumentActionFeesWithoutModerationError, InvalidContractModerationConfigError, }; +use dpp::consensus::basic::moderation_charter::{ + ModerationCharterDescriptionTooLongError, ModerationCharterMalformedFieldError, + ModerationCharterRewardSplitNotOneHundredError, +}; use dpp::consensus::state::contract_moderation::{ ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, ContractFeesNothingToClaimError, ContractModeratedDocumentTypeNotYetUsableError, @@ -1272,6 +1276,15 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::ContractModerationReasonTooLongError(e) => { generic_consensus_error!(ContractModerationReasonTooLongError, e).into() } + BasicError::ModerationCharterMalformedFieldError(e) => { + generic_consensus_error!(ModerationCharterMalformedFieldError, e).into() + } + BasicError::ModerationCharterRewardSplitNotOneHundredError(e) => { + generic_consensus_error!(ModerationCharterRewardSplitNotOneHundredError, e).into() + } + BasicError::ModerationCharterDescriptionTooLongError(e) => { + generic_consensus_error!(ModerationCharterDescriptionTooLongError, e).into() + } BasicError::InvalidContractModerationReasonDocumentsError(e) => { generic_consensus_error!(InvalidContractModerationReasonDocumentsError, e).into() } diff --git a/yarn.lock b/yarn.lock index 9bf457de492..59a947649b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1869,6 +1869,20 @@ __metadata: languageName: unknown linkType: soft +"@dashevo/moderation-charters-contract@workspace:packages/moderation-charters-contract": + version: 0.0.0-use.local + resolution: "@dashevo/moderation-charters-contract@workspace:packages/moderation-charters-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/platform-test-suite@workspace:packages/platform-test-suite": version: 0.0.0-use.local resolution: "@dashevo/platform-test-suite@workspace:packages/platform-test-suite" From d844959af49e971f05439633f379d57801bc0bf1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 10:35:56 +0700 Subject: [PATCH 2/7] refactor(contract): short descriptions in the moderation charters schema The contract is served with every fetch, so a description is dropped where the property name says it and cut to a few words elsewhere. The rules stay in the keywords, the README and the protocol guide. Co-Authored-By: Claude Opus 5.5 --- ...oderation-charters-contract-documents.json | 39 +++++++------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json index adff0f4da27..9e42648ff86 100644 --- a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json +++ b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json @@ -23,21 +23,19 @@ "minLength": 3, "maxLength": 3, "pattern": "^[A-Z]{3}$", - "description": "Three uppercase letters, unique among the owner's reasons; what an action shows", + "description": "Three uppercase letters, unique per owner", "position": 0 }, "label": { "type": "string", "minLength": 1, "maxLength": 64, - "description": "The reason's name, such as Spam", "position": 1 }, "description": { "type": "string", "minLength": 1, "maxLength": 1024, - "description": "What the reason covers and how the team applies it", "position": 2 } }, @@ -47,7 +45,7 @@ "label" ], "additionalProperties": false, - "description": "A ground for a moderation action, keyed by its owner and a three-letter code. Anyone may file one; immutable and undeletable, so a charter that lists it lists a fixed text" + "description": "A ground for a moderation action" }, "submittedCharter": { "type": "object", @@ -88,14 +86,13 @@ "moderation": "elected" } }, - "description": "The contract the team proposes to moderate; it must declare elected moderation, and that declaration is the team's whole mandate", + "description": "The contract to moderate", "position": 0 }, "description": { "type": "string", "minLength": 1, "maxLength": 4096, - "description": "What the team would moderate and how, for joiners and voters. Informational", "position": 1 }, "reasons": { @@ -114,14 +111,14 @@ "documentType": "reason" } }, - "description": "The moderation reasons the team's actions may name. Every action names one, so a team with none can take no action", + "description": "Reasons the team may act on; none means it cannot act", "position": 2 }, "moderatorsShare": { "type": "integer", "minimum": 0, "maximum": 100, - "description": "Percentage of each moderated type's declared moderators fee the team takes. Absent means the full amount; a lower number is a discount on the team's services, and 0 says the team will not moderate and takes no rewards", + "description": "Percent of the declared moderators fee taken; absent is 100, 0 takes no rewards", "position": 3 }, "rewardSplit": { @@ -131,21 +128,20 @@ "type": "integer", "minimum": 0, "maximum": 100, - "description": "The leader's share", "position": 0 }, "equal": { "type": "integer", "minimum": 0, "maximum": 100, - "description": "The share split equally among the other members", + "description": "Split equally among the other members", "position": 1 }, "actions": { "type": "integer", "minimum": 0, "maximum": 100, - "description": "The share split by each member's moderation action count", + "description": "Split by action count", "position": 2 } }, @@ -155,7 +151,7 @@ "actions" ], "additionalProperties": false, - "description": "How the team splits each claim of the moderators pot; the three percentages sum to 100", + "description": "Percentages summing to 100", "position": 4 } }, @@ -167,7 +163,7 @@ "rewardSplit" ], "additionalProperties": false, - "description": "A proposal to moderate a contract on the contract's own terms: who leads it, the reasons it may act on, what it charges and how it splits the pay. Immutable, so joiners consent to a fixed text" + "description": "A proposal to moderate a contract" }, "joinRequest": { "type": "object", @@ -210,7 +206,6 @@ "recipientId": "$ownerId" } }, - "description": "The proposal the owner asks to join; recipientId must be its leader", "position": 0 }, "recipientId": { @@ -227,14 +222,13 @@ "boundTo": "submittedCharter" } }, - "description": "The leader, and through recipientKeyId the key the message is encrypted to", + "description": "The proposal's leader", "position": 1 }, "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295, - "description": "The leader's decryption key the message is encrypted to", "position": 2 }, "senderKeyId": { @@ -249,7 +243,6 @@ "boundTo": "joinRequest" } }, - "description": "The owner's encryption key the shared secret is derived from", "position": 3 }, "encryptedMessage": { @@ -263,7 +256,7 @@ "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" }, - "description": "Why the owner wants to join, readable by the leader alone", + "description": "Readable by the leader only", "position": 4 } }, @@ -276,7 +269,7 @@ "encryptedMessage" ], "additionalProperties": false, - "description": "An identity's offer to serve on the team of a proposal. One per identity per proposal; immutable and permanent, so a team's consent never vanishes" + "description": "An offer to join a proposal's team" }, "electedCharter": { "type": "object", @@ -293,7 +286,7 @@ "unique": true, "contested": { "resolution": 1, - "description": "Every elected charter for a target contends for its moderation team seat" + "description": "Contest for the moderation seat" } }, { @@ -318,7 +311,6 @@ "moderation": "electionOpen" } }, - "description": "The contract contended for; it declares elected moderation and its own election delay has passed", "position": 0 }, "submittedCharterId": { @@ -335,7 +327,6 @@ "targetContractId": "targetContractId" } }, - "description": "The proposal this team runs on; only its leader may open the contest, and for the same target", "position": 1 }, "members": { @@ -362,7 +353,7 @@ } } }, - "description": "The team besides the leader: only identities that asked to join this proposal", + "description": "Team besides the leader, each with a join request", "position": 2 } }, @@ -373,6 +364,6 @@ "members" ], "additionalProperties": false, - "description": "A proposal put to the vote with its team. Creating one opens or joins the contest for the target; the leader and every member act with the target's full mandate" + "description": "A proposal put to the vote with its team" } } From 0fc36e18a646f0aa4ea8662a320287a7d2605c85 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 10:52:29 +0700 Subject: [PATCH 3/7] docs(contract): a proposal has at most one contender at a time The README and the protocol guide said a leader could enter one proposal more than once because the by-proposal index is not unique. The contest rules already forbid it: an identity may be a contestant once per contest, every entry of a proposal lands in its target's contest, and only the proposal's owner may enter. Co-Authored-By: Claude Fable 5.1 --- docs/protocol/moderation-charters.md | 7 +++++-- packages/moderation-charters-contract/README.md | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md index d6bd741b148..925cef5df45 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -107,8 +107,11 @@ the member. A member with no such request refuses the create with Indexes: `byTargetContract`, the contested index below, and `bySubmittedCharter` (`submittedCharterId`), which lists the elected charters of a proposal. It is not unique: a type with a contested unique index may carry -no other unique index, so a leader may enter one proposal more than once, each -time with its own team and its own contest fee. +no other unique index. None is needed: an identity may be a contestant once +per contest (`DocumentContestIdentityAlreadyContestantError`), every entry of a +proposal lands in its target's contest, and only the proposal's owner may +enter, so a proposal has at most one contender at a time. Contenders live in +the contest until it is awarded, so this index lists seated charters only. ## The contest diff --git a/packages/moderation-charters-contract/README.md b/packages/moderation-charters-contract/README.md index 207124867af..45e41e04c91 100644 --- a/packages/moderation-charters-contract/README.md +++ b/packages/moderation-charters-contract/README.md @@ -68,8 +68,10 @@ keyed by the target contract with resolution `1`, the vote without a Lock choice by masternodes (weight 1) and evonodes (weight 4): a create on it opens or joins the contest, a tie goes to the earliest applicant, and a single applicant is seated when the join window closes. `bySubmittedCharter` -lists the elected charters of a proposal; it cannot be unique, since a type -with a contested unique index may carry no other unique index. The seated +lists the elected charters of a proposal. It is not unique: a type with a +contested unique index may carry no other unique index, and none is needed, +since an identity may contend once per contest and only the proposal's owner +may enter it, so a proposal has at most one contender at a time. The seated leader and members act with the target's full mandate; there are no powers. | Property | Type | Meaning | From 4b5ebd8304808ede4b536dfdf5839fd3d95a6338 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 12:14:25 +0700 Subject: [PATCH 4/7] feat(platform)!: a seated team changes after the election: additions, removals, resignations The elected charter is on a contested index and so immutable; its team changes through three new document types in the moderation charters contract, each once per member and charter: - addedModerator: the leader adds an identity that asked to join the proposal (the same join request lookup as members), up to the target contract's maxAddedModerators; - removedModerator: the leader removes a member, no resignation needed; - resignationRequest: a member leaves on its own, effective when filed. The team that acts is the leader plus the elected members and the additions, less the removals and the resignations (ElectedCharter::active_members). Removals and resignations are final. The elected moderation declaration gains maxAddedModerators (0 when left out, absent from the wire form then, at most SystemLimits::max_contract_moderation_added_moderators = 15, frozen with the rest of the declaration). It counts additions ever filed, so a removal frees no slot; the seating PR, which first lets the charter contract's documents be written, enforces it. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/contract-moderation.md | 4 +- docs/protocol/moderation-charters.md | 36 +++- .../moderation-charters-contract/README.md | 17 +- ...oderation-charters-contract-documents.json | 167 ++++++++++++++++++ .../unit/moderationChartersContract.spec.js | 47 ++++- .../config/methods/validate_update/v2/mod.rs | 4 +- .../config/moderation/elected.rs | 55 ++++++ .../data_contract/config/moderation/mod.rs | 20 ++- .../document_type/property/mod.rs | 2 + packages/rs-dpp/src/moderation_charter/mod.rs | 42 ++++- .../rs-dpp/src/moderation_charter/tests.rs | 36 ++++ packages/rs-dpp/src/system_data_contracts.rs | 119 ++++++++++++- .../batch/tests/document/creation.rs | 1 + .../v0/contract_moderation_gate/mod.rs | 1 + .../contract_user_moderation/tests.rs | 1 + .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 4 + .../src/version/system_limits/v1.rs | 1 + .../src/version/system_limits/v2.rs | 1 + .../src/version/system_limits/v3.rs | 1 + .../src/version/system_limits/v4.rs | 1 + .../rs-platform-version/src/version/v14.rs | 22 ++- packages/wasm-dpp2/src/data_contract/model.rs | 5 + 23 files changed, 570 insertions(+), 18 deletions(-) diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index d90df67f139..7fd3f288e82 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -283,8 +283,8 @@ pub struct ElectedModerators { The declaration lives in `packages/rs-dpp/src/data_contract/config/moderation/elected.rs`. On the wire it is the third `$type` of the moderators, flat: `{"$type": "elected", "challengeCoolDown": 1209600, "moderatedDocumentTypes": {"post": ["ban", "deleteDocuments"]}, "interim": {"$type": "notYetUsable"}}`, with `joinWindow`, `voteWindow`, `electionDelay` and `ownerProtected` optional. Its parts: -- **The election parameters** are fixed once set (`SystemLimits`: `min_contract_moderation_election_window_seconds` and `max_contract_moderation_election_window_seconds` bound both windows, `min_contract_moderation_challenge_cool_down_seconds` and `max_contract_moderation_challenge_cool_down_seconds` the cool-down). The join window is how long applicants may join an election once the first one applied, the vote window how long masternodes then vote, and the cool-down how long a seated team is safe from a challenge after a seat change. Nothing reads them yet. The **election delay** is the one parameter the contract sets freely: how many seconds after its creation the first charter may be filed against it, the notice the contract gives before its first election can be called. It is optional and unbounded; left out, the election may be called at once. Because the declaration is made at the contract's creation and never changes, the creation is the declaration's own time. The charter contract's `targetContractId` reads it through the `moderation: "electionOpen"` requirement below. -- **The moderated set** is the document types the team moderates, each with the abilities a charter may claim on it: non-empty, each type a document type of the contract, each ability set non-empty and backed by the contract (`ban` needs the banlist, `suspend` the suspension list, `warn` the warning list, `deleteDocuments` the type itself flagged `canBeDeletedByModerators`, so deletions reach only flagged types, within their window). The charter of a team will say how those types are moderated, never which. The lists stay contract-wide: an ability on a type is what a team may do over the documents of that type. The set also bounds the interim block. A charter does not price the moderators part of an action: a type's own `actionFees.moderators` amount is the most a team may charge, a charter charges a share of it (the charter contract's business, not the declaration's), and the owner part stays what the type declares, immutable as before. +- **The election parameters** are fixed once set (`SystemLimits`: `min_contract_moderation_election_window_seconds` and `max_contract_moderation_election_window_seconds` bound both windows, `min_contract_moderation_challenge_cool_down_seconds` and `max_contract_moderation_challenge_cool_down_seconds` the cool-down). The join window is how long applicants may join an election once the first one applied, the vote window how long masternodes then vote, and the cool-down how long a seated team is safe from a challenge after a seat change. Nothing reads them yet. The **election delay** is the one parameter the contract sets freely: how many seconds after its creation the first charter may be filed against it, the notice the contract gives before its first election can be called. It is optional and unbounded; left out, the election may be called at once. Because the declaration is made at the contract's creation and never changes, the creation is the declaration's own time. The charter contract's `targetContractId` reads it through the `moderation: "electionOpen"` requirement below. **`maxAddedModerators`** says how many members the leader of a seated team may add after the election, each one an identity that asked to join the team's proposal: additions ever filed, so a removal or a resignation frees no slot. It is 0 when left out, a team then being exactly what was elected, and at most `SystemLimits::max_contract_moderation_added_moderators` (15). +- **The moderated set** is the document types the team moderates, each with the abilities the seated team holds on it: non-empty, each type a document type of the contract, each ability set non-empty and backed by the contract (`ban` needs the banlist, `suspend` the suspension list, `warn` the warning list, `deleteDocuments` the type itself flagged `canBeDeletedByModerators`, so deletions reach only flagged types, within their window). The charter of a team will say how those types are moderated, never which. The lists stay contract-wide: an ability on a type is what a team may do over the documents of that type. The set also bounds the interim block. A charter does not price the moderators part of an action: a type's own `actionFees.moderators` amount is the most a team may charge, a charter charges a share of it (the charter contract's business, not the declaration's), and the owner part stays what the type declares, immutable as before. - **The interim** says who moderates until a team is seated. `ContractOwner` and `AppointedModerators(set)` are the merged kinds, with their authority, their limit and their existence check (41110 at create): they moderate, they are protected, and they are the team that claims the moderators pot. `NotYetUsable` names nobody: nobody moderates, nobody claims the pot (it accumulates for the team to come, `ContractFeeClaimNotAllowedError` for everyone), and the moderated document types can not be used. A contract that never attracts a team keeps those types unusable for good; the other types work as on an unmoderated contract. `NoModeration` names nobody too, with the moderated types usable meanwhile: nobody moderates and nobody claims the pot, and every type works as on an unmoderated contract until a team is seated. - **The owner flag** says whether the contract owner is protected from the team once one is seated, as the owner and the moderators of the merged kinds are (41102 on a ban, a suspension or a deletion of its documents). Not protected by default. During the interim the owner is protected whenever it moderates, flag or not: `ContractModerationConfig::protects` is what the moderation transition checks, and it is `may_moderate` or the flag for the owner. diff --git a/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md index 925cef5df45..c0c86bbcfe3 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -12,7 +12,8 @@ genesis and on the upgrade to protocol version 14. - Owner: the all-zero system identity - Registry entry: `SystemDataContract::ModerationCharters = 10` - Schema version: 1 -- Document types: `reason`, `submittedCharter`, `joinRequest`, `electedCharter` +- Document types: `reason`, `submittedCharter`, `joinRequest`, `electedCharter`, + `addedModerator`, `removedModerator`, `resignationRequest` Every type is immutable and undeletable, so each document another one refers to stays exactly as it was when it was referred to. Additional properties are @@ -30,10 +31,19 @@ rejected on every type. `electedCharter` naming the proposal and the members chosen from those who asked to join. That create opens or joins the contest for the target. +5. After the election the leader may add members from the same join requests, + up to the target's `maxAddedModerators`, and remove members, and a member + may resign. + The seated team acts with the target contract's whole elected moderation declaration: every document type and ability it lists. A team narrows what it acts on only through the reasons its proposal lists, since every action names -one. There are no powers: any one member acts alone. +one. There are no powers: any one member acts alone. The team that acts is: + +``` +leader + (electedCharter.members + addedModerator.memberId) + - removedModerator.memberId - resignationRequest.$ownerId +``` ## `reason` @@ -113,6 +123,28 @@ proposal lands in its target's contest, and only the proposal's owner may enter, so a proposal has at most one contender at a time. Contenders live in the contest until it is awarded, so this index lists seated charters only. +## After the election + +All three types refer to an `electedCharter`. A reference finds a document in the +type's own storage, and only a winner is ever written there (contenders live in +the contest), so these documents can only name a seated charter. Each is +unique on the charter and the member, so it is written at most once per member, +and a removal or a resignation is final. + +| Type | Properties | Rules | +| --- | --- | --- | +| `addedModerator` | `electedCharterId`, `submittedCharterId`, `memberId` | `electedCharterId` carries `propertyAgreement: { "$ownerId": "$ownerId", "submittedCharterId": "submittedCharterId" }`: only the leader adds, and `submittedCharterId` is the charter's proposal. `memberId` refers to a `joinRequest` through the same `lookup` as `members` and is `distinctFrom: "$ownerId"`: an addition needs the member's consent, disclosed on the proposal | +| `removedModerator` | `electedCharterId`, `memberId` | Only the leader removes (`propertyAgreement: { "$ownerId": "$ownerId" }`); no resignation is needed; `memberId` is `distinctFrom: "$ownerId"` | +| `resignationRequest` | `electedCharterId` | The owner is the member leaving; it takes effect when filed, whatever the leader does. A resignation by someone not on the team changes nothing, and neither does the leader's own: leader succession is not a resignation | + +**The cap on additions.** The target contract's elected declaration carries +`maxAddedModerators`: how many members a seated team's leader may add, 0 when +left out and at most `SystemLimits::max_contract_moderation_added_moderators` +(15). It counts additions ever filed against a charter, so a removal or a +resignation frees no slot. The schema cannot count documents, so the seating +pull request, which first lets this contract's documents be written, refuses an +addition over the cap. + ## The contest The `byTargetContract` index of `electedCharter` is a contested unique index diff --git a/packages/moderation-charters-contract/README.md b/packages/moderation-charters-contract/README.md index 45e41e04c91..79f3297badc 100644 --- a/packages/moderation-charters-contract/README.md +++ b/packages/moderation-charters-contract/README.md @@ -5,7 +5,7 @@ teams that masternodes elect for data contracts that declare an elected moderation team. It activates at protocol version 14 and has the same ID on every network: `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. -It has four document types. All four are immutable and undeletable, so +It has seven document types. All are immutable and undeletable, so everything a charter points at, and the charter itself, is a fixed text. The schema carries almost every rule through its keywords: typed arrays with @@ -80,6 +80,21 @@ leader and members act with the target's full mandate; there are no powers. | `submittedCharterId` | identifier, required, `refersTo` a `submittedCharter` | The proposal the team runs on | | `members` | array of at most 15 unique identity ids, required, each the owner of a `joinRequest` for this proposal (`lookup`) and none the leader (`distinctFrom`) | The team besides the leader; may be empty | +### After the election + +Once an elected charter is seated, its team can change without a new vote: + +| Type | Written by | Properties | Rules | +| --- | --- | --- | --- | +| `addedModerator` | the leader | `electedCharterId`, `submittedCharterId`, `memberId` | `memberId` owns a `joinRequest` for the charter's proposal (`lookup`) and is not the leader; at most the target's `maxAddedModerators` additions per charter, checked when a team is seated | +| `removedModerator` | the leader | `electedCharterId`, `memberId` | Needs no resignation; `memberId` is not the leader | +| `resignationRequest` | the member leaving | `electedCharterId` | Takes effect when filed; a leader's resignation changes nothing | + +Each is written once per member and charter (unique indexes), and a removal or +a resignation is final. The team that acts is the leader plus the elected +members and the additions, less the removals and the resignations +(`ElectedCharter::active_members` in `rs-dpp`). + See [the protocol guide](../../docs/protocol/moderation-charters.md) for details. diff --git a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json index 9e42648ff86..d921801d7ac 100644 --- a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json +++ b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json @@ -365,5 +365,172 @@ ], "additionalProperties": false, "description": "A proposal put to the vote with its team" + }, + "addedModerator": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "indices": [ + { + "name": "byElectedCharterMember", + "properties": [ + { + "electedCharterId": "asc" + }, + { + "memberId": "asc" + } + ], + "unique": true + } + ], + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter", + "propertyAgreement": { + "$ownerId": "$ownerId", + "submittedCharterId": "submittedCharterId" + } + }, + "description": "The seated charter; only its leader may add", + "position": 0 + }, + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1 + }, + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "submittedCharterId", + "$ownerId": "." + } + } + }, + "description": "An identity that asked to join the proposal", + "position": 2 + } + }, + "required": [ + "$createdAt", + "electedCharterId", + "submittedCharterId", + "memberId" + ], + "additionalProperties": false, + "description": "A member the leader adds after the election, up to the target's maxAddedModerators" + }, + "removedModerator": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "indices": [ + { + "name": "byElectedCharterMember", + "properties": [ + { + "electedCharterId": "asc" + }, + { + "memberId": "asc" + } + ], + "unique": true + } + ], + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter", + "propertyAgreement": { + "$ownerId": "$ownerId" + } + }, + "description": "The seated charter; only its leader may remove", + "position": 0 + }, + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "position": 1 + } + }, + "required": [ + "$createdAt", + "electedCharterId", + "memberId" + ], + "additionalProperties": false, + "description": "A member the leader removes; final" + }, + "resignationRequest": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "indices": [ + { + "name": "byElectedCharterOwner", + "properties": [ + { + "electedCharterId": "asc" + }, + { + "$ownerId": "asc" + } + ], + "unique": true + } + ], + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + }, + "position": 0 + } + }, + "required": [ + "$createdAt", + "electedCharterId" + ], + "additionalProperties": false, + "description": "A member leaving the team; takes effect when filed" } } diff --git a/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js index d4dc938bbe2..24ee77b6644 100644 --- a/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js +++ b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js @@ -88,11 +88,14 @@ describe('Moderation Charters Contract', () => { .throw(); }); - it('should have four document types', () => { + it('should have seven document types', () => { expect(Object.keys(moderationChartersContractDocumentsSchema).sort()).to.deep.equal([ + 'addedModerator', 'electedCharter', 'joinRequest', 'reason', + 'removedModerator', + 'resignationRequest', 'submittedCharter', ]); }); @@ -362,4 +365,46 @@ describe('Moderation Charters Contract', () => { expect(error.keyword).to.equal('uniqueItems'); }); }); + + describe('addedModerator', () => { + const rawAddition = async () => ({ + electedCharterId: await generateRandomIdentifier(), + submittedCharterId: await generateRandomIdentifier(), + memberId: await generateRandomIdentifier(), + }); + + it('should be valid', async () => { + expect(validate('addedModerator', await rawAddition()).isValid()).to.be.true(); + }); + + expectRequired('addedModerator', rawAddition, ['electedCharterId', 'submittedCharterId', 'memberId']); + expectNoAdditionalProperties('addedModerator', rawAddition, 'power'); + }); + + describe('removedModerator', () => { + const rawRemoval = async () => ({ + electedCharterId: await generateRandomIdentifier(), + memberId: await generateRandomIdentifier(), + }); + + it('should be valid without a resignation', async () => { + expect(validate('removedModerator', await rawRemoval()).isValid()).to.be.true(); + }); + + expectRequired('removedModerator', rawRemoval, ['electedCharterId', 'memberId']); + expectNoAdditionalProperties('removedModerator', rawRemoval, 'resignationRequestId'); + }); + + describe('resignationRequest', () => { + const rawResignation = async () => ({ + electedCharterId: await generateRandomIdentifier(), + }); + + it('should be valid', async () => { + expect(validate('resignationRequest', await rawResignation()).isValid()).to.be.true(); + }); + + expectRequired('resignationRequest', rawResignation, ['electedCharterId']); + expectNoAdditionalProperties('resignationRequest', rawResignation, 'memberId'); + }); }); diff --git a/packages/rs-dpp/src/data_contract/config/methods/validate_update/v2/mod.rs b/packages/rs-dpp/src/data_contract/config/methods/validate_update/v2/mod.rs index befbeb0636b..a84f8dd3084 100644 --- a/packages/rs-dpp/src/data_contract/config/methods/validate_update/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/config/methods/validate_update/v2/mod.rs @@ -216,6 +216,7 @@ mod tests { )]), interim: InterimModerators::ContractOwner, election_delay: None, + max_added_moderators: 0, owner_protected: false, }; modify(&mut declaration); @@ -239,11 +240,12 @@ mod tests { assert!(kept.is_valid(), "{:?}", kept.errors); type Change = (&'static str, fn(&mut ElectedModerators)); - let changes: [Change; 8] = [ + let changes: [Change; 9] = [ ("join window", |d| d.join_window += 1), ("vote window", |d| d.vote_window += 1), ("challenge cool-down", |d| d.challenge_cool_down += 1), ("election delay", |d| d.election_delay = Some(1)), + ("added moderators", |d| d.max_added_moderators = 1), ("moderated set", |d| { d.moderated_document_types .insert("like".to_string(), BTreeSet::from([ModerationAbility::Ban])); diff --git a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs index 65f4d343b96..35189046061 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs @@ -38,6 +38,8 @@ pub mod property_names { pub const CHALLENGE_COOL_DOWN: &str = "challengeCoolDown"; /// The election delay, in seconds after the contract's creation pub const ELECTION_DELAY: &str = "electionDelay"; + /// How many members a seated team's leader may add after the election + pub const MAX_ADDED_MODERATORS: &str = "maxAddedModerators"; /// The moderated document types, each with the abilities the seated team holds on it pub const MODERATED_DOCUMENT_TYPES: &str = "moderatedDocumentTypes"; /// The interim moderators @@ -324,6 +326,13 @@ pub struct ElectedModerators { /// case the election may be called at once. A reference declaring /// `contractRequirements: { "moderation": "electionOpen" }` is what reads it. pub election_delay: Option, + /// How many members the leader of a seated team may add after the election, each one + /// an identity that asked to join the team's proposal: the additions ever filed against + /// a seated charter, so a removal or a resignation frees no slot. 0 when the declaration + /// leaves it out, a team then being exactly what was elected; at most + /// `SystemLimits::max_contract_moderation_added_moderators`. The moderation charters + /// contract's `addedModerator` documents are what it counts. + pub max_added_moderators: u16, /// The document types the team moderates, each with the abilities the seated team holds /// on it: non-empty, each type a document type of the contract, each ability set /// non-empty and backed by the contract (`Ban`, `Suspend` and `Warn` by the list the @@ -417,6 +426,13 @@ impl ElectedModerators { { return Some(reason); } + let max_added = limits.max_contract_moderation_added_moderators; + if self.max_added_moderators > max_added { + return Some(format!( + "the {} members a leader may add exceed the limit of {max_added}", + self.max_added_moderators + )); + } if self.moderated_document_types.is_empty() { return Some("the moderated document type set is empty".to_string()); @@ -477,6 +493,13 @@ impl fmt::Display for ElectedModerators { ", its first election open {delay} seconds after the contract's creation" )?; } + if self.max_added_moderators > 0 { + write!( + f, + ", its leader free to add {} members after the election", + self.max_added_moderators + )?; + } Ok(()) } } @@ -523,6 +546,7 @@ mod tests { )]), interim: InterimModerators::ContractOwner, election_delay: None, + max_added_moderators: 0, owner_protected: false, } } @@ -747,18 +771,37 @@ mod tests { assert!(!protected.protects(&owner, &user)); } + #[test] + fn should_bound_the_members_a_leader_may_add() { + let max = PlatformVersion::latest() + .system_limits + .max_contract_moderation_added_moderators; + assert_eq!(max, 15); + let with = |added: u16| { + let mut declaration = elected(); + declaration.max_added_moderators = added; + config(declaration) + }; + assert_eq!(refusal(&with(0)), None); + assert_eq!(refusal(&with(max)), None); + let over = refusal(&with(max + 1)).expect("refused over the limit"); + assert!(over.contains("members a leader may add"), "{over}"); + } + #[test] fn should_round_trip_through_json_and_platform_value() { let mut declaration = elected(); declaration.interim = InterimModerators::AppointedModerators(set(&[1, 2])); declaration.owner_protected = true; declaration.election_delay = Some(86_400); + declaration.max_added_moderators = 3; let moderators = ContractModerators::Elected(Box::new(declaration)); let json = serde_json::to_value(&moderators).expect("serialize"); assert_eq!(json["$type"], "elected"); assert_eq!(json["joinWindow"], 604_800); assert_eq!(json["electionDelay"], 86_400); + assert_eq!(json["maxAddedModerators"], 3); assert_eq!( json["moderatedDocumentTypes"], serde_json::json!({ "post": ["ban", "suspend"] }) @@ -804,6 +847,11 @@ mod tests { json.get("electionDelay").is_none(), "a declaration without a delay serializes none: {json}" ); + assert_eq!(elected.max_added_moderators, 0); + assert!( + json.get("maxAddedModerators").is_none(), + "a declaration letting no member be added serializes none: {json}" + ); assert_eq!(elected.vote_window, DEFAULT_ELECTION_WINDOW_SECONDS); assert!(!elected.owner_protected); assert_eq!(elected.interim, InterimModerators::NotYetUsable); @@ -921,6 +969,13 @@ mod tests { first election open 86400 seconds after the contract's creation" ); declaration.election_delay = None; + declaration.max_added_moderators = 2; + assert_eq!( + declaration.to_string(), + "an elected moderation team, in its interim moderated by the contract owner, its \ + leader free to add 2 members after the election" + ); + declaration.max_added_moderators = 0; declaration.interim = InterimModerators::NotYetUsable; assert_eq!( declaration.to_string(), diff --git a/packages/rs-dpp/src/data_contract/config/moderation/mod.rs b/packages/rs-dpp/src/data_contract/config/moderation/mod.rs index 30262d0941e..14d1c2678a6 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/mod.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/mod.rs @@ -171,7 +171,9 @@ impl Serialize for ContractModerators { m.end() } ContractModerators::Elected(elected) => { - let entries = 7 + usize::from(elected.election_delay.is_some()); + let entries = 7 + + usize::from(elected.election_delay.is_some()) + + usize::from(elected.max_added_moderators > 0); let mut m = serializer.serialize_map(Some(entries))?; m.serialize_entry("$type", "elected")?; m.serialize_entry(elected_names::JOIN_WINDOW, &elected.join_window)?; @@ -185,6 +187,13 @@ impl Serialize for ContractModerators { if let Some(delay) = elected.election_delay { m.serialize_entry(elected_names::ELECTION_DELAY, &delay)?; } + // Absent when no member may be added, for the same reason + if elected.max_added_moderators > 0 { + m.serialize_entry( + elected_names::MAX_ADDED_MODERATORS, + &elected.max_added_moderators, + )?; + } m.serialize_entry( elected_names::MODERATED_DOCUMENT_TYPES, &elected.moderated_document_types, @@ -209,6 +218,7 @@ impl<'de> Deserialize<'de> for ContractModerators { elected_names::VOTE_WINDOW, elected_names::CHALLENGE_COOL_DOWN, elected_names::ELECTION_DELAY, + elected_names::MAX_ADDED_MODERATORS, elected_names::MODERATED_DOCUMENT_TYPES, elected_names::INTERIM, elected_names::OWNER_PROTECTED, @@ -221,6 +231,7 @@ impl<'de> Deserialize<'de> for ContractModerators { vote_window: Option, challenge_cool_down: Option, election_delay: Option, + max_added_moderators: Option, moderated_document_types: Option>>, interim: Option, owner_protected: Option, @@ -232,6 +243,7 @@ impl<'de> Deserialize<'de> for ContractModerators { || self.vote_window.is_some() || self.challenge_cool_down.is_some() || self.election_delay.is_some() + || self.max_added_moderators.is_some() || self.moderated_document_types.is_some() || self.interim.is_some() || self.owner_protected.is_some() @@ -296,6 +308,11 @@ impl<'de> Deserialize<'de> for ContractModerators { elected_names::ELECTION_DELAY, &mut elected.election_delay, )?, + elected_names::MAX_ADDED_MODERATORS => read_once( + &mut map, + elected_names::MAX_ADDED_MODERATORS, + &mut elected.max_added_moderators, + )?, elected_names::MODERATED_DOCUMENT_TYPES => read_once( &mut map, elected_names::MODERATED_DOCUMENT_TYPES, @@ -354,6 +371,7 @@ impl<'de> Deserialize<'de> for ContractModerators { .challenge_cool_down .ok_or_else(required(elected_names::CHALLENGE_COOL_DOWN))?, election_delay: elected.election_delay, + max_added_moderators: elected.max_added_moderators.unwrap_or(0), moderated_document_types: elected .moderated_document_types .ok_or_else(required(elected_names::MODERATED_DOCUMENT_TYPES))?, diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 3477d4cfe66..da259124219 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -9276,6 +9276,7 @@ mod tests { vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, challenge_cool_down: 1_209_600, election_delay: None, + max_added_moderators: 0, moderated_document_types: BTreeMap::from([( "profile".to_string(), BTreeSet::from([ModerationAbility::Ban]), @@ -9359,6 +9360,7 @@ mod tests { vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, challenge_cool_down: 1_209_600, election_delay, + max_added_moderators: 0, moderated_document_types: BTreeMap::from([( "profile".to_string(), BTreeSet::from([ModerationAbility::Ban]), diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs index 881a6d41b89..006cdf77b3e 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -11,7 +11,13 @@ //! - a `joinRequest` is an identity's offer to serve on the team of a proposal, with a message //! only the leader can read; //! - an `electedCharter` is a proposal put to the vote with its team, chosen from the identities -//! that asked to join it. Creating one opens or joins the contest for the target contract. +//! that asked to join it. Creating one opens or joins the contest for the target contract; +//! - once a charter is seated, its leader may add members from the same join requests, up to +//! the target's `maxAddedModerators` (`addedModerator`), and remove members +//! (`removedModerator`), and a member may leave on its own (`resignationRequest`). +//! +//! The team that acts is the leader plus [`ElectedCharter::active_members`]: the elected +//! members and the additions, less the removals and the resignations. //! //! The schema carries almost every rule through its keywords (references, lookups, key //! requirements, `distinctFrom`). What it cannot say is here: [`SubmittedCharter`] and @@ -26,7 +32,7 @@ use crate::validation::{ConsensusValidationResult, SimpleConsensusValidationResu use crate::ProtocolError; use platform_value::{Identifier, IdentifierBytes32, Value, ValueMap}; use platform_version::version::PlatformVersion; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; /// The id of the moderation charters system contract, `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. /// @@ -45,6 +51,12 @@ pub const SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME: &str = "submittedCharter"; pub const JOIN_REQUEST_DOCUMENT_TYPE_NAME: &str = "joinRequest"; /// The name of the elected charter document type, the one on the contested index. pub const ELECTED_CHARTER_DOCUMENT_TYPE_NAME: &str = "electedCharter"; +/// The name of the document type of a member the leader adds after the election. +pub const ADDED_MODERATOR_DOCUMENT_TYPE_NAME: &str = "addedModerator"; +/// The name of the document type of a member the leader removes. +pub const REMOVED_MODERATOR_DOCUMENT_TYPE_NAME: &str = "removedModerator"; +/// The name of the document type of a member leaving the team on its own. +pub const RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME: &str = "resignationRequest"; /// The moderators share a proposal takes when it declares none: the full declared fee. pub const FULL_MODERATORS_SHARE: u8 = 100; @@ -61,6 +73,8 @@ pub mod property_names { pub const REWARD_SPLIT_ACTIONS: &str = "actions"; pub const SUBMITTED_CHARTER_ID: &str = "submittedCharterId"; pub const MEMBERS: &str = "members"; + pub const ELECTED_CHARTER_ID: &str = "electedCharterId"; + pub const MEMBER_ID: &str = "memberId"; } /// How a team splits every claim of the moderators pot: three percentages summing to 100. @@ -291,6 +305,30 @@ impl SubmittedCharter { } impl ElectedCharter { + /// The members a seated team acts with besides its leader, `leader_id`: the elected + /// members and those the leader added after the election, less those the leader removed + /// and those who resigned. `added`, `removed` and `resigned` are the `memberId`s of the + /// charter's `addedModerator` and `removedModerator` documents and the owners of its + /// `resignationRequest` documents. A removal and a resignation are final, so the order + /// the documents were filed in does not matter. The leader is never among the result: + /// neither list may name it, and its own resignation does not remove it (leader + /// succession is not a resignation). + pub fn active_members<'a>( + &self, + leader_id: Identifier, + added: impl IntoIterator, + removed: impl IntoIterator, + resigned: impl IntoIterator, + ) -> BTreeSet { + let mut active: BTreeSet = self.members.iter().copied().collect(); + active.extend(added.into_iter().copied()); + for gone in removed.into_iter().chain(resigned) { + active.remove(gone); + } + active.remove(&leader_id); + active + } + /// Reads an elected charter out of the properties of an `electedCharter` document. The /// result carries a consensus error, never a charter, when a property is missing or of the /// wrong type. diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs index 42244bf720f..3fd92011e68 100644 --- a/packages/rs-dpp/src/moderation_charter/tests.rs +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -204,3 +204,39 @@ fn should_round_trip_an_elected_charter_through_its_document_properties() { alone ); } + +#[test] +fn should_combine_the_elected_members_the_additions_the_removals_and_the_resignations() { + let id = |byte: u8| Identifier::from([byte; 32]); + let leader = id(1); + let charter = ElectedCharter { + target_contract_id: id(9), + submitted_charter_id: id(7), + members: vec![id(2), id(3), id(4)], + }; + + // Nothing filed since the election: the elected team + assert_eq!( + charter.active_members(leader, &[], &[], &[]), + [id(2), id(3), id(4)].into() + ); + + // An addition joins, a removal and a resignation leave, whether the member was elected + // or added + assert_eq!( + charter.active_members(leader, &[id(5), id(6)], &[id(2), id(6)], &[id(3)]), + [id(4), id(5)].into() + ); + + // A removal is final: an addition of a removed member does not bring it back + assert_eq!( + charter.active_members(leader, &[id(2)], &[id(2)], &[]), + [id(3), id(4)].into() + ); + + // The leader is never among the members and its resignation changes nothing + assert_eq!( + charter.active_members(leader, &[leader], &[], &[leader]), + [id(2), id(3), id(4)].into() + ); +} diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index 046a77390cc..2f93f27e3b9 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -323,9 +323,10 @@ mod moderation_charters_tests { use crate::identity::Purpose; use crate::moderation_charter::{ property_names, validate_submitted_charter, ElectedCharter, ModerationCharterRewardSplit, - SubmittedCharter, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, JOIN_REQUEST_DOCUMENT_TYPE_NAME, - MODERATION_CHARTERS_CONTRACT_ID, REASON_DOCUMENT_TYPE_NAME, - SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + SubmittedCharter, ADDED_MODERATOR_DOCUMENT_TYPE_NAME, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + JOIN_REQUEST_DOCUMENT_TYPE_NAME, MODERATION_CHARTERS_CONTRACT_ID, + REASON_DOCUMENT_TYPE_NAME, REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, }; use platform_value::{Identifier, Value}; @@ -419,9 +420,12 @@ mod moderation_charters_tests { assert_eq!( names, vec![ + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, JOIN_REQUEST_DOCUMENT_TYPE_NAME, REASON_DOCUMENT_TYPE_NAME, + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, ] ); @@ -446,6 +450,9 @@ mod moderation_charters_tests { REASON_DOCUMENT_TYPE_NAME, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, JOIN_REQUEST_DOCUMENT_TYPE_NAME, + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, ] { assert!( document_type(&contract, name) @@ -802,4 +809,110 @@ mod moderation_charters_tests { ); } } + + /// After the election the leader adds members from the same join requests and removes + /// members, and a member leaves on its own: each change is written once per member, and + /// only by the one entitled to it. + #[test] + fn should_let_only_the_leader_change_the_team_and_only_a_member_resign() { + let contract = contract(); + let charter_agreement = + |type_name| match reference(&contract, type_name, property_names::ELECTED_CHARTER_ID) { + PropertyReference::Value(DocumentPropertyReferenceTarget::PermanentDocument { + document_type_name, + property_agreement, + .. + }) => { + assert_eq!(document_type_name, ELECTED_CHARTER_DOCUMENT_TYPE_NAME); + property_agreement.clone() + } + other => panic!("{type_name}.electedCharterId: {other:?}"), + }; + + let added = charter_agreement(ADDED_MODERATOR_DOCUMENT_TYPE_NAME); + assert_eq!(added.get("$ownerId").map(String::as_str), Some("$ownerId")); + assert_eq!( + added + .get(property_names::SUBMITTED_CHARTER_ID) + .map(String::as_str), + Some(property_names::SUBMITTED_CHARTER_ID) + ); + let removed = charter_agreement(REMOVED_MODERATOR_DOCUMENT_TYPE_NAME); + assert_eq!( + removed.get("$ownerId").map(String::as_str), + Some("$ownerId") + ); + assert!( + charter_agreement(RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME).is_empty(), + "anyone may resign; only a member's resignation changes the team" + ); + + match reference( + &contract, + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + property_names::MEMBER_ID, + ) { + PropertyReference::Value( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + document_type_name, + lookup, + .. + }, + ) => { + assert_eq!(document_type_name, JOIN_REQUEST_DOCUMENT_TYPE_NAME); + assert_eq!(lookup.index, "bySubmittedCharter"); + assert_eq!( + lookup.keys.get("$ownerId"), + Some(&LookupKeySource::ReferenceValue) + ); + } + other => panic!("addedModerator.memberId: {other:?}"), + } + for type_name in [ + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + ] { + let member = document_type(&contract, type_name) + .flattened_properties() + .get(property_names::MEMBER_ID) + .expect("memberId"); + assert_eq!( + member.distinct_from, + Some(DistinctFrom::OwnerId), + "{type_name}: the leader is not a member" + ); + } + + for (type_name, index_name, member_property) in [ + ( + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + "byElectedCharterMember", + property_names::MEMBER_ID, + ), + ( + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + "byElectedCharterMember", + property_names::MEMBER_ID, + ), + ( + RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, + "byElectedCharterOwner", + "$ownerId", + ), + ] { + let index = document_type(&contract, type_name) + .indexes() + .get(index_name) + .unwrap_or_else(|| panic!("{type_name}.{index_name}")); + assert!(index.unique, "{type_name}: once per member and charter"); + assert_eq!( + index + .properties + .iter() + .map(|p| p.name.as_str()) + .collect::>(), + vec![property_names::ELECTED_CHARTER_ID, member_property] + ); + } + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index b733147c676..b7bac857a5c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -5705,6 +5705,7 @@ mod creation_tests { vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, challenge_cool_down: 1_209_600, election_delay, + max_added_moderators: 0, moderated_document_types: BTreeMap::from([( "message".to_string(), BTreeSet::from([ModerationAbility::Ban]), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs index 7a93976d9cc..01bc8fad7a7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs @@ -444,6 +444,7 @@ mod tests { )]), interim: InterimModerators::NotYetUsable, election_delay: None, + max_added_moderators: 0, owner_protected: false, })), }, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs index 407323573e4..c143e41fb79 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs @@ -3074,6 +3074,7 @@ fn elected(interim: InterimModerators, moderated: &[&str]) -> ContractModeration .collect(), interim, election_delay: None, + max_added_moderators: 0, owner_protected: false, })), } 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 08e6031b480..e2ddb79eeea 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -598,6 +598,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_contract_moderation_challenge_cool_down_seconds: 94_608_000, contract_document_restore_window_ms: 604_800_000, max_moderation_charter_description_length: 4096, + max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, max_shielded_transition_actions: 16, max_time_range_overlap_factor: None, 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 bc7eb8b52fa..07a6bbb8984 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -167,6 +167,10 @@ pub struct SystemLimits { /// charter schema pins the same number as the description's `maxLength`, which the JSON /// schema validator counts in characters, so the byte cap is this check's. pub max_moderation_charter_description_length: u16, + /// Most members an elected moderation declaration may let a seated team's leader add + /// after the election (`maxAddedModerators`). Read by the declaration's validation + /// (protocol version 14) and never reached before. + pub max_contract_moderation_added_moderators: u16, // This the max redemption cycles we can process if we don't use a constant distribution // For a constant perpetual distribution this is very cheap since it's just a multiplication // For other distributions we much calculate at each cycle the rewards, so we don't want to 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 0d49bc4421a..da563fcce7c 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -59,6 +59,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days max_moderation_charter_description_length: 4096, + max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is 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 bbd3678e66c..70eda962f51 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -40,6 +40,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days max_moderation_charter_description_length: 4096, + max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is 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 9bcbe4a9ace..9239b6888d4 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -42,6 +42,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days max_moderation_charter_description_length: 4096, + max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is 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 153e5533f12..1478d248c22 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -97,6 +97,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days max_moderation_charter_description_length: 4096, + max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on // top of the 408 B serialized action), so a transition's on-wire size is diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 06f47ddab53..c4e3312c7d4 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -505,9 +505,12 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// years), all in seconds and bounded by `SYSTEM_LIMITS_V4`; an optional, /// unbounded election delay in seconds after the contract's creation /// before the first charter may be filed (`electionDelay`, read by the -/// `moderation: "electionOpen"` reference requirement of item 24); the document -/// types the team moderates, each with the abilities a charter may claim on -/// it; who moderates until the first team is seated (the owner, an +/// `moderation: "electionOpen"` reference requirement of item 24); how many +/// members a seated team's leader may add after the election +/// (`maxAddedModerators`, 0 when left out, at most +/// `SYSTEM_LIMITS_V4.max_contract_moderation_added_moderators`, 15); the +/// document types the team moderates, each with the abilities the seated +/// team holds on it; who moderates until the first team is seated (the owner, an /// appointed set, or nobody, with the moderated types not yet usable or /// used unmoderated meanwhile); and whether the owner is protected from the /// team. `validate_moderation_config` v0 checks @@ -761,7 +764,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// /// 33. **The moderation charters system contract** /// (`SystemDataContract::ModerationCharters`, schema v1, the first piece of -/// decentralized moderation teams) carries four document types, all +/// decentralized moderation teams) carries seven document types, all /// immutable and undeletable. A `reason` is a ground for a moderation /// action, keyed by its owner and a three-letter `code` unique among the /// owner's reasons. A `submittedCharter` is a leader's proposal to @@ -782,7 +785,16 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `moderation: "electionOpen"`, and its `members` are identities each of /// which filed a join request for that proposal (item 32, a lookup through /// the join request's unique index) and none of which is the leader -/// (item 26). Its `byTargetContract` index is a contested unique index +/// (item 26). Once a charter is seated, its leader adds members from the +/// same join requests (`addedModerator`, the same lookup) and removes +/// members (`removedModerator`), and a member leaves on its own +/// (`resignationRequest`): each once per member and charter (unique +/// indexes), removals and resignations final, so the team that acts is the +/// leader plus the elected members and the additions less the removals and +/// the resignations (`ElectedCharter::active_members`). The cap on +/// additions, the target's `maxAddedModerators`, is checked by the seating +/// pull request, which first lets these documents be written. +/// Its `byTargetContract` index is a contested unique index /// with `"resolution": 1`, the masternode vote without a Lock choice of /// item 23, so an elected charter create opens or joins the contest for /// its target. `SYSTEM_DATA_CONTRACT_VERSIONS_V3` registers it diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index b84fbeb5938..dd64b5f2cad 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -158,6 +158,11 @@ export type ContractModerators = * reference requiring `moderation: 'electionOpen'` reads it. */ electionDelay?: number; + /** + * How many members a seated team's leader may add after the election, each one an + * identity that asked to join the team's proposal; 0 when left out, at most 15. + */ + maxAddedModerators?: number; /** * The moderated document types of the contract, each with the non-empty abilities a * charter may claim on it: `ban`, `suspend` and `warn` need the list the contract From b307c98672dd1617910d1ecc5b7ddc8ae8e56270 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 12:15:10 +0700 Subject: [PATCH 5/7] docs(contract): level-two headings for the charter README's sections Co-Authored-By: Claude Opus 5.5 --- packages/moderation-charters-contract/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/moderation-charters-contract/README.md b/packages/moderation-charters-contract/README.md index 79f3297badc..13a1059c781 100644 --- a/packages/moderation-charters-contract/README.md +++ b/packages/moderation-charters-contract/README.md @@ -15,7 +15,7 @@ a reference per element, a reference resolved through a unique index and the description's byte cap, is checked by `SubmittedCharter` in `rs-dpp` when a team is seated. -### `reason` +## `reason` A ground for a moderation action. Anyone may file one. @@ -25,7 +25,7 @@ A ground for a moderation action. Anyone may file one. | `label` | string, 1 to 64 characters, required | The reason's name | | `description` | string, 1 to 1024 characters | What the reason covers and how the team applies it | -### `submittedCharter` +## `submittedCharter` A leader's proposal to moderate one contract, on the contract's own terms: the target's elected moderation declaration is the team's whole mandate, so @@ -43,7 +43,7 @@ decryption key bound to this type so join requests can be encrypted to it. Indexes: `byTargetContract` (target, `$createdAt`) lists the proposals for a contract in filing order; `byOwner` lists a leader's proposals. -### `joinRequest` +## `joinRequest` An identity's offer to serve on the team of a proposal, one per identity per proposal (`bySubmittedCharter`, unique on the proposal and the owner), with a @@ -58,7 +58,7 @@ to this type. | `senderKeyId` | integer, required | The owner's encryption key the shared secret is derived from | | `encryptedMessage` | bytes, 32 to 1040, required | Why the owner wants to join, encrypted for the leader (ECDH on secp256k1, AES-256-CBC) | -### `electedCharter` +## `electedCharter` A proposal put to the vote with its team: the only type that opens or joins the contest for a target. Only the proposal's leader may create one, for the @@ -80,7 +80,7 @@ leader and members act with the target's full mandate; there are no powers. | `submittedCharterId` | identifier, required, `refersTo` a `submittedCharter` | The proposal the team runs on | | `members` | array of at most 15 unique identity ids, required, each the owner of a `joinRequest` for this proposal (`lookup`) and none the leader (`distinctFrom`) | The team besides the leader; may be empty | -### After the election +## After the election Once an elected charter is seated, its team can change without a new vote: From f8c71d3aa5e12aefb102d7c397c1133ec8a5b0ea Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 18:47:36 +0700 Subject: [PATCH 6/7] feat(platform)!: write the moderation charters contract to state at protocol version 14 A chain born at protocol version 14 registers the contract at genesis (create_genesis_state v1, inside the existing >= 14 branch that registers app-connect; v0 replays mainnet and testnet and is untouched), and an older chain inserts it in transition_to_version_14. The Drive system contract cache and the trusted context provider serve it from MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION (14). Seating a winning team is not part of this change: until it lands an awarded elected charter is stored but seats no team, and nothing counts additions against maxAddedModerators. The genesis contracts tree gains a sibling, so three pinned protocol version 14 fees move, as they did for app-connect: document delete 1770160 -> 1817440, document replace 1498860 -> 1546140, DPNS domain create 6049360 -> 5761600. Co-Authored-By: Claude Opus 5.5 --- docs/protocol/moderation-charters.md | 18 ++-- packages/data-contracts/src/lib.rs | 4 +- .../moderation-charters-contract/README.md | 5 +- .../create_genesis_state/v1/mod.rs | 52 ++++++++++ .../v0/mod.rs | 99 ++++++++++++++++++- .../batch/tests/document/deletion.rs | 7 +- .../batch/tests/document/dpns.rs | 6 +- .../batch/tests/document/replacement.rs | 7 +- .../rs-drive/src/cache/system_contracts.rs | 40 ++++++-- .../feature_initial_protocol_versions.rs | 5 + .../rs-platform-version/src/version/v14.rs | 14 +-- .../src/provider.rs | 59 ++++++++++- 12 files changed, 281 insertions(+), 35 deletions(-) diff --git a/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md index c0c86bbcfe3..e058b5485e1 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -3,10 +3,15 @@ The moderation charters system contract holds how the moderation team of a contract that declares elected moderation comes to be: the reasons a team may act on, a leader's proposal, the identities that offer to join it, and the -proposal put to the vote with its team. It activates at protocol version 14. -Chains do not write it to state yet: the seating of an elected team does not -exist, and the pull request that adds it writes the contract to state at -genesis and on the upgrade to protocol version 14. +proposal put to the vote with its team. It activates at protocol version 14: +a chain born at 14 registers it at genesis (`create_genesis_state` v1, behind +the same version branch as the app-connect contract), an older chain inserts it +on the upgrade to 14 (`transition_to_version_14`), and the Drive system contract +cache and the trusted context provider serve it from 14 on. + +Seating does not exist yet. Until it does, an awarded elected charter is stored +but seats no team, and nothing counts additions against `maxAddedModerators`; +both come with the seating pull request. - Contract ID: `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88` - Owner: the all-zero system identity @@ -141,9 +146,8 @@ and a removal or a resignation is final. `maxAddedModerators`: how many members a seated team's leader may add, 0 when left out and at most `SystemLimits::max_contract_moderation_added_moderators` (15). It counts additions ever filed against a charter, so a removal or a -resignation frees no slot. The schema cannot count documents, so the seating -pull request, which first lets this contract's documents be written, refuses an -addition over the cap. +resignation frees no slot. The schema cannot count documents, so a consensus +rule refuses an addition over the cap; it comes with the seating pull request. ## The contest diff --git a/packages/data-contracts/src/lib.rs b/packages/data-contracts/src/lib.rs index 2c416eea557..850e0a93c50 100644 --- a/packages/data-contracts/src/lib.rs +++ b/packages/data-contracts/src/lib.rs @@ -54,8 +54,8 @@ pub enum SystemDataContract { DocumentHistory = 8, AppConnect = 9, /// The charters of elected moderation teams (protocol version 14). Registered from - /// protocol version 14 on, but not yet written to state: the election a charter create - /// opens does not exist yet, and the PR that adds it writes the contract to state. + /// protocol version 14 on: registered at genesis by chains born at 14 and inserted by the + /// upgrade to 14. ModerationCharters = 10, } diff --git a/packages/moderation-charters-contract/README.md b/packages/moderation-charters-contract/README.md index 13a1059c781..9f2d857e1ce 100644 --- a/packages/moderation-charters-contract/README.md +++ b/packages/moderation-charters-contract/README.md @@ -2,7 +2,8 @@ The moderation charters system contract holds the charters of the moderation teams that masternodes elect for data contracts that declare an elected -moderation team. It activates at protocol version 14 and has the same ID on +moderation team. It activates at protocol version 14, registered at genesis by +chains born at 14 and inserted by the upgrade to 14, and has the same ID on every network: `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. It has seven document types. All are immutable and undeletable, so @@ -86,7 +87,7 @@ Once an elected charter is seated, its team can change without a new vote: | Type | Written by | Properties | Rules | | --- | --- | --- | --- | -| `addedModerator` | the leader | `electedCharterId`, `submittedCharterId`, `memberId` | `memberId` owns a `joinRequest` for the charter's proposal (`lookup`) and is not the leader; at most the target's `maxAddedModerators` additions per charter, checked when a team is seated | +| `addedModerator` | the leader | `electedCharterId`, `submittedCharterId`, `memberId` | `memberId` owns a `joinRequest` for the charter's proposal (`lookup`) and is not the leader; at most the target's `maxAddedModerators` additions per charter, a consensus rule that comes with seating | | `removedModerator` | the leader | `electedCharterId`, `memberId` | Needs no resignation; `memberId` is not the leader | | `resignationRequest` | the member leaving | `electedCharterId` | Takes effect when filed; a leader's resignation changes nothing | 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 2af05278cba..127133b0a4a 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 @@ -81,6 +81,11 @@ impl Platform { SystemDataContract::AppConnect, system_data_contracts.load_app_connect(platform_version)?, ); + // The moderation charters contract activates with the same version and branch. + system_data_contract_types.insert( + SystemDataContract::ModerationCharters, + system_data_contracts.load_moderation_charters(platform_version)?, + ); } for data_contract in system_data_contract_types.values() { @@ -130,6 +135,7 @@ mod tests { use crate::test::helpers::setup::TestPlatformBuilder; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contracts::SystemDataContract; + use dpp::prelude::Identifier; use drive::config::DriveConfig; use platform_version::version::{PlatformVersion, INITIAL_PROTOCOL_VERSION}; @@ -209,5 +215,51 @@ mod tests { } } } + + /// The moderation charters contract joins the genesis state at protocol version 14, + /// behind the same branch as the app-connect contract. + #[test] + pub fn should_register_the_moderation_charters_contract_only_from_protocol_version_14() { + let moderation_charters_id = SystemDataContract::ModerationCharters.id(); + + for (platform_version, expected) in [ + (PlatformVersion::get(13).expect("protocol 13"), 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( + moderation_charters_id.to_buffer(), + None, + None, + None, + platform_version, + ) + .value + .expect("expected to query the moderation charters contract"); + + assert_eq!( + stored.is_some(), + expected, + "moderation charters contract presence in a genesis state born at protocol version {initial_protocol_version}" + ); + + if let Some(stored) = stored { + assert_eq!(stored.contract.id(), moderation_charters_id); + assert_eq!(stored.contract.owner_id(), Identifier::from([0u8; 32])); + assert!(stored + .contract + .document_type_for_name("electedCharter") + .is_ok()); + assert_eq!(stored.contract.document_types().len(), 7); + } + } + } } } 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 5820f88c0c3..494d97af704 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 @@ -698,7 +698,8 @@ impl Platform { /// `profile` document type (DIP-33), the withdrawals contract whose v2 /// schema admits the terminal FAILED value of the `status` property, and /// register the app-connect contract that carries the wallet-to-app login - /// handshake. + /// handshake and the moderation charters contract that elected moderation + /// teams apply through. fn transition_to_version_14( &self, block_info: &BlockInfo, @@ -746,6 +747,21 @@ impl Platform { platform_version, )?; + // Moderation charters contract: the reasons, proposals, join requests and elected + // charters of elected moderation teams, one system contract id on every network from + // this version. Fresh chains register it at genesis (`create_genesis_state` v1, behind + // the same version branch as app-connect). + let moderation_charters_contract = + load_system_data_contract(SystemDataContract::ModerationCharters, platform_version)?; + + self.drive.insert_contract( + &moderation_charters_contract, + *block_info, + true, + Some(transaction), + platform_version, + )?; + // Total credits history under the withdrawals tree: the daily withdrawal limit becomes // a share of the total credits Platform held a day ago, recorded here every block. self.drive.grove_insert_if_not_exists( @@ -1235,6 +1251,87 @@ mod tests { assert!(profile.iter().any(|p| p == "shieldedAddress")); } + #[test] + fn should_insert_moderation_charters_on_transition_to_version_14() { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + + // A chain born at protocol version 13 has no moderation charters contract: it is + // neither in that genesis state nor active for the system contract cache. + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_version_13 = PlatformVersion::get(13).expect("expected platform version 13"); + let platform_version = PlatformVersion::latest(); + let moderation_charters_id = SystemDataContract::ModerationCharters.id(); + + let transaction = platform.drive.grove.start_transaction(); + + assert!( + platform + .drive + .fetch_contract( + moderation_charters_id.to_buffer(), + None, + None, + Some(&transaction), + platform_version_13, + ) + .value + .expect("expected to query the moderation charters contract") + .is_none(), + "the moderation charters contract must not exist before transition_to_version_14" + ); + + 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_14(&block_info, &transaction, platform_version) + .expect("expected the transition to succeed"); + + let stored = platform + .drive + .fetch_contract( + moderation_charters_id.to_buffer(), + None, + None, + Some(&transaction), + platform_version, + ) + .value + .expect("expected to fetch the moderation charters contract") + .expect("the moderation charters contract must exist after transition_to_version_14"); + + assert_eq!(stored.contract.id(), moderation_charters_id); + assert_eq!(stored.contract.owner_id(), Identifier::from([0u8; 32])); + assert_eq!(stored.contract.document_types().len(), 7); + assert_eq!( + platform + .drive + .fetch_contract_version( + moderation_charters_id.to_buffer(), + Some(&transaction), + platform_version + ) + .expect("expected to read the version item"), + Some(stored.contract.version()), + "the moderation charters contract has its version item after the transition" + ); + assert!(platform + .drive + .cache + .system_data_contracts + .find_by_id(moderation_charters_id, platform_version) + .expect("expected the post-activation lookup to succeed") + .is_some()); + } + #[test] fn should_insert_app_connect_on_transition_to_version_14() { use dpp::data_contract::accessors::v0::DataContractV0Getters; 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 1ba8d5de0ed..981acfa8853 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 @@ -15,9 +15,10 @@ mod deletion_tests { // Protocol version 14 adds +740 per document write (the contract's version // item is one more node to rehash) and the larger DashPay v2 schema // increases byte-billed contract-tree reads. - // The app-connect contract adds one sibling to the genesis contracts tree, - // increasing the bytes billed when reading that tree (protocol 14 only). - 1770160, + // 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, ) .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 94bdf6fe6b2..219d00da376 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 @@ -34,8 +34,10 @@ mod dpns_tests { // when the seed reorders its ids). +10_000 per create: the nonce // derived id is billed both SHA-256 passes, 4 blocks instead of 2. // The app-connect contract adds one sibling to the genesis contracts tree, - // increasing the bytes billed when reading that tree (protocol 14 only). - 6_049_360, + // 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, ) .await; } 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 13c73623208..3dbd0e71470 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 @@ -517,9 +517,10 @@ mod replacement_tests { // Protocol version 14 adds +740 per document write (the contract's version // item is one more node to rehash) and the larger DashPay v2 schema // increases byte-billed contract-tree reads. - // The app-connect contract adds one sibling to the genesis contracts tree, - // increasing the bytes billed when reading that tree (protocol 14 only). - 1498860, + // 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, ) .await; } diff --git a/packages/rs-drive/src/cache/system_contracts.rs b/packages/rs-drive/src/cache/system_contracts.rs index f7a58efd703..ada5710ee55 100644 --- a/packages/rs-drive/src/cache/system_contracts.rs +++ b/packages/rs-drive/src/cache/system_contracts.rs @@ -3,7 +3,10 @@ use arc_swap::ArcSwap; use dpp::data_contract::DataContract; 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; +use platform_version::version::feature_initial_protocol_versions::{ + APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION, + MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION, +}; use platform_version::version::{PlatformVersion, ProtocolVersion}; use std::collections::BTreeMap; use std::sync::Arc; @@ -168,6 +171,14 @@ impl SystemDataContracts { self.load(SystemDataContract::AppConnect, platform_version) } + /// Returns the moderation charters contract materialized for `platform_version`. + pub fn load_moderation_charters( + &self, + platform_version: &PlatformVersion, + ) -> Result, Error> { + self.load(SystemDataContract::ModerationCharters, platform_version) + } + /// Returns the system contract whose deterministic identifier matches `id`, materialized /// for `platform_version`. /// @@ -208,14 +219,13 @@ impl SystemDataContracts { SystemDataContract::DocumentHistory => 13, // Written to state by the transition to protocol version 14. SystemDataContract::AppConnect => APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION, + // Written to state by the transition to protocol version 14. + SystemDataContract::ModerationCharters => { + MODERATION_CHARTERS_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), - // Registered but not yet written to state at any protocol version: the election a - // charter create opens does not exist yet. The PR that adds it writes the contract - // to state on the upgrade to protocol version 14 and gives it an activation version - // here; until then a lookup falls through to grovedb and reports it absent. - SystemDataContract::ModerationCharters => return Ok(None), }; if activated_at_protocol_version > platform_version.protocol_version { @@ -416,15 +426,29 @@ mod tests { } #[test] - fn should_not_serve_moderation_charters_until_it_is_written_to_state() { + fn should_serve_moderation_charters_only_from_its_activation_version() { let contracts = SystemDataContracts::new(); + assert!(contracts + .find_by_id( + SystemDataContract::ModerationCharters.id(), + platform_version(13) + ) + .expect("expected the pre-activation lookup to succeed") + .is_none()); assert!(contracts .find_by_id( SystemDataContract::ModerationCharters.id(), PlatformVersion::latest() ) - .expect("expected the lookup to succeed") + .expect("expected the v14 lookup to succeed") + .is_some()); + assert!(contracts + .find_by_id( + SystemDataContract::ModerationCharters.id(), + platform_version(13) + ) + .expect("an old-version lookup after materialization must succeed") .is_none()); } 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 c283d55151e..7f10698e68d 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 @@ -22,3 +22,8 @@ pub const CONTRACT_FEE_CLAIM_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; /// version 14 and registered at genesis from that version on; below it the contract does /// not exist and lookups must report it absent. pub const APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; + +/// The moderation charters system contract is written to state by the upgrade to protocol +/// version 14 and registered at genesis from that version on; below it the contract does not +/// exist and lookups must report it absent. +pub const MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index c4e3312c7d4..8eaab68daf8 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -792,8 +792,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// indexes), removals and resignations final, so the team that acts is the /// leader plus the elected members and the additions less the removals and /// the resignations (`ElectedCharter::active_members`). The cap on -/// additions, the target's `maxAddedModerators`, is checked by the seating -/// pull request, which first lets these documents be written. +/// additions, the target's `maxAddedModerators`, is a consensus rule of the +/// seating pull request. /// Its `byTargetContract` index is a contested unique index /// with `"resolution": 1`, the masternode vote without a Lock choice of /// item 23, so an elected charter create opens or joins the contest for @@ -803,10 +803,12 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// on the pure-data rules the seating path will run on a proposal: its /// reward split sums to 100 and its description fits /// `SystemLimits::max_moderation_charter_description_length` bytes (basic -/// errors 11000 to 11002). Nothing writes the contract to state yet, at -/// genesis or on upgrade, and the Drive cache does not serve it: the -/// seating of an elected team comes in a later pull request, which writes -/// the contract to state. +/// errors 11000 to 11002). Genesis registers it on chains born at this +/// version (`create_genesis_state` v1, behind the app-connect branch), +/// `transition_to_version_14` inserts it on upgrade, and the Drive system +/// contract cache serves it from this version +/// (`MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION`). Seating a +/// winning team comes in a later pull request. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index eb520875fe4..e2753a62de2 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -23,7 +23,10 @@ use dpp::data_contract::TokenConfiguration; ))] 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; +use dpp::version::feature_initial_protocol_versions::{ + APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION, + MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION, +}; use dpp::version::PlatformVersion; use lru::LruCache; @@ -907,6 +910,28 @@ impl ContextProvider for TrustedHttpContextProvider { )) }); } + + #[cfg(any( + feature = "moderation-charters-contract", + feature = "all-system-contracts" + ))] + // Below protocol version 14 the moderation charters contract is absent too. + if *id == SystemDataContract::ModerationCharters.id() + && platform_version.protocol_version + >= MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION + { + return load_system_data_contract( + SystemDataContract::ModerationCharters, + platform_version, + ) + .map(|contract| Some(Arc::new(contract))) + .map_err(|e| { + ContextProviderError::Generic(format!( + "Failed to load ModerationCharters contract: {}", + e + )) + }); + } } // If not found in known contracts or system contracts, delegate to fallback provider if available @@ -1603,6 +1628,38 @@ mod tests { assert_eq!(contract.id(), id); } + /// The moderation charters system contract is served only from its activation version + /// on, like the app-connect contract. + #[cfg(any( + feature = "moderation-charters-contract", + feature = "all-system-contracts" + ))] + #[test] + fn should_serve_moderation_charters_only_from_protocol_14() { + 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::ModerationCharters.id(); + + assert!(provider + .get_data_contract(&id, PlatformVersion::get(13).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 14") + .expect("the moderation charters contract must be served at protocol version 14"); + assert_eq!(contract.id(), id); + } + #[test] fn test_domain_resolution_check() { // Test with a domain that should resolve (using localhost) From 701dc05a0705b2f8800edad07992c29a66677e4c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 23:10:33 +0700 Subject: [PATCH 7/7] feat(contract): only team members may ask to leave, with a message to the leader Built on #4940 (listElement), #4941 (ownerRefersTo) and #4942 (anyOf), merged in from v4.2-dev. The resignation request: - may be filed only by a member of the seated team: ownerRefersTo holds an anyOf of a listElement (the writer is in the elected charter's members, the charter found through the electedCharterId $id pair) and a lookup of an addedModerator for the charter keyed by the writer; the leader is in neither list; - carries a message encrypted to the leader, with the leader's decryption key bound to submittedCharter and the member's encryption key bound to joinRequest, the keys join requests already use; - is deletable (Sam), so it is a request the leader acts on with a removal and the member withdraws by deleting it. It changes the team by itself no longer: ElectedCharter::active_members drops its resignations input. The charter changelog item is renumbered 36 after the three new items. Co-Authored-By: Claude Opus 5.5 --- docs/protocol/moderation-charters.md | 23 ++--- .../moderation-charters-contract/README.md | 11 ++- ...oderation-charters-contract-documents.json | 89 ++++++++++++++++++- .../unit/moderationChartersContract.spec.js | 29 +++++- packages/rs-dpp/src/moderation_charter/mod.rs | 23 +++-- .../rs-dpp/src/moderation_charter/tests.rs | 17 ++-- packages/rs-dpp/src/system_data_contracts.rs | 86 ++++++++++++++++-- .../rs-platform-version/src/version/v14.rs | 18 ++-- 8 files changed, 239 insertions(+), 57 deletions(-) diff --git a/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md index e058b5485e1..7196c69bafd 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -20,9 +20,9 @@ both come with the seating pull request. - Document types: `reason`, `submittedCharter`, `joinRequest`, `electedCharter`, `addedModerator`, `removedModerator`, `resignationRequest` -Every type is immutable and undeletable, so each document another one refers -to stays exactly as it was when it was referred to. Additional properties are -rejected on every type. +Every type is immutable, and every type but `resignationRequest` is +undeletable, so each document another one refers to stays exactly as it was +when it was referred to. Additional properties are rejected on every type. ## The flow @@ -37,8 +37,9 @@ rejected on every type. asked to join. That create opens or joins the contest for the target. 5. After the election the leader may add members from the same join requests, - up to the target's `maxAddedModerators`, and remove members, and a member - may resign. + up to the target's `maxAddedModerators`, and remove members. A member asks + to leave with a resignation request, which the leader acts on with a + removal. The seated team acts with the target contract's whole elected moderation declaration: every document type and ability it lists. A team narrows what it @@ -47,7 +48,7 @@ one. There are no powers: any one member acts alone. The team that acts is: ``` leader + (electedCharter.members + addedModerator.memberId) - - removedModerator.memberId - resignationRequest.$ownerId + - removedModerator.memberId ``` ## `reason` @@ -133,20 +134,20 @@ the contest until it is awarded, so this index lists seated charters only. All three types refer to an `electedCharter`. A reference finds a document in the type's own storage, and only a winner is ever written there (contenders live in the contest), so these documents can only name a seated charter. Each is -unique on the charter and the member, so it is written at most once per member, -and a removal or a resignation is final. +unique on the charter and the member, so it is written at most once per member +at a time, and a removal is final. | Type | Properties | Rules | | --- | --- | --- | | `addedModerator` | `electedCharterId`, `submittedCharterId`, `memberId` | `electedCharterId` carries `propertyAgreement: { "$ownerId": "$ownerId", "submittedCharterId": "submittedCharterId" }`: only the leader adds, and `submittedCharterId` is the charter's proposal. `memberId` refers to a `joinRequest` through the same `lookup` as `members` and is `distinctFrom: "$ownerId"`: an addition needs the member's consent, disclosed on the proposal | | `removedModerator` | `electedCharterId`, `memberId` | Only the leader removes (`propertyAgreement: { "$ownerId": "$ownerId" }`); no resignation is needed; `memberId` is `distinctFrom: "$ownerId"` | -| `resignationRequest` | `electedCharterId` | The owner is the member leaving; it takes effect when filed, whatever the leader does. A resignation by someone not on the team changes nothing, and neither does the leader's own: leader succession is not a resignation | +| `resignationRequest` | `electedCharterId`, `recipientId`, `recipientKeyId`, `senderKeyId`, `encryptedMessage` | The owner is the member asking to leave, and must be on the team: `ownerRefersTo: { "anyOf": [...] }` requires the writer to be an element of the elected charter's `members` (`listElement`, the charter found by `electedCharterId` through `propertyAgreement: { "electedCharterId": "$id" }`) or the `memberId` of an `addedModerator` for that charter (a `lookup` through `byElectedCharterMember`, `"."` the writer). The leader is in neither list, so it cannot file one. The message is encrypted to the leader (`propertyAgreement: { "recipientId": "$ownerId" }` on `electedCharterId`) with the leader's decryption key bound to `submittedCharter` and the member's encryption key bound to `joinRequest`, the keys join requests use. A request changes nothing by itself: the leader acts on it with a `removedModerator`. It is the only deletable type: deleting it withdraws the request, and nothing refers to it | **The cap on additions.** The target contract's elected declaration carries `maxAddedModerators`: how many members a seated team's leader may add, 0 when left out and at most `SystemLimits::max_contract_moderation_added_moderators` -(15). It counts additions ever filed against a charter, so a removal or a -resignation frees no slot. The schema cannot count documents, so a consensus +(15). It counts additions ever filed against a charter, so a removal frees no +slot. The schema cannot count documents, so a consensus rule refuses an addition over the cap; it comes with the seating pull request. ## The contest diff --git a/packages/moderation-charters-contract/README.md b/packages/moderation-charters-contract/README.md index 9f2d857e1ce..b287425a2e1 100644 --- a/packages/moderation-charters-contract/README.md +++ b/packages/moderation-charters-contract/README.md @@ -6,7 +6,7 @@ moderation team. It activates at protocol version 14, registered at genesis by chains born at 14 and inserted by the upgrade to 14, and has the same ID on every network: `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. -It has seven document types. All are immutable and undeletable, so +It has seven document types. All are immutable, and all but `resignationRequest` are undeletable, so everything a charter points at, and the charter itself, is a fixed text. The schema carries almost every rule through its keywords: typed arrays with @@ -89,12 +89,11 @@ Once an elected charter is seated, its team can change without a new vote: | --- | --- | --- | --- | | `addedModerator` | the leader | `electedCharterId`, `submittedCharterId`, `memberId` | `memberId` owns a `joinRequest` for the charter's proposal (`lookup`) and is not the leader; at most the target's `maxAddedModerators` additions per charter, a consensus rule that comes with seating | | `removedModerator` | the leader | `electedCharterId`, `memberId` | Needs no resignation; `memberId` is not the leader | -| `resignationRequest` | the member leaving | `electedCharterId` | Takes effect when filed; a leader's resignation changes nothing | +| `resignationRequest` | a member of the team | `electedCharterId`, `recipientId`, `recipientKeyId`, `senderKeyId`, `encryptedMessage` | The writer is in the charter's `members` or was added (`ownerRefersTo` with `anyOf`); a message only the leader can read; deletable, which withdraws it; the leader acts on it with a removal | -Each is written once per member and charter (unique indexes), and a removal or -a resignation is final. The team that acts is the leader plus the elected -members and the additions, less the removals and the resignations -(`ElectedCharter::active_members` in `rs-dpp`). +Each is written once per member and charter (unique indexes), and a removal is +final. The team that acts is the leader plus the elected members and the +additions, less the removals (`ElectedCharter::active_members` in `rs-dpp`). See [the protocol guide](../../docs/protocol/moderation-charters.md) for details. diff --git a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json index d921801d7ac..322ddfa767d 100644 --- a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json +++ b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json @@ -497,7 +497,30 @@ "resignationRequest": { "type": "object", "documentsMutable": false, - "canBeDeleted": false, + "canBeDeleted": true, + "ownerRefersTo": { + "anyOf": [ + { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + }, + { + "type": "permanentDocument", + "documentType": "addedModerator", + "lookup": { + "index": "byElectedCharterMember", + "keys": { + "electedCharterId": "electedCharterId", + "memberId": "." + } + } + } + ] + }, "indices": [ { "name": "byElectedCharterOwner", @@ -521,16 +544,74 @@ "contentMediaType": "application/x.dash.dpp.identifier", "refersTo": { "type": "permanentDocument", - "documentType": "electedCharter" + "documentType": "electedCharter", + "propertyAgreement": { + "recipientId": "$ownerId" + } }, "position": 0 + }, + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { + "purpose": "decryption", + "boundTo": "submittedCharter" + } + }, + "description": "The leader", + "position": 1 + }, + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 2 + }, + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { + "purpose": "encryption", + "boundTo": "joinRequest" + } + }, + "position": 3 + }, + "encryptedMessage": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 1040, + "encryptedFor": { + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }, + "description": "Why the member leaves, readable by the leader only", + "position": 4 } }, "required": [ "$createdAt", - "electedCharterId" + "electedCharterId", + "recipientId", + "recipientKeyId", + "senderKeyId", + "encryptedMessage" ], "additionalProperties": false, - "description": "A member leaving the team; takes effect when filed" + "description": "A member asking to leave; the leader removes them, and deleting it withdraws the request" } } diff --git a/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js index 24ee77b6644..da2f2b1c5cc 100644 --- a/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js +++ b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js @@ -398,13 +398,40 @@ describe('Moderation Charters Contract', () => { describe('resignationRequest', () => { const rawResignation = async () => ({ electedCharterId: await generateRandomIdentifier(), + recipientId: await generateRandomIdentifier(), + recipientKeyId: 3, + senderKeyId: 2, + // A 16-byte IV and two AES blocks. + encryptedMessage: crypto.randomBytes(48), }); it('should be valid', async () => { expect(validate('resignationRequest', await rawResignation()).isValid()).to.be.true(); }); - expectRequired('resignationRequest', rawResignation, ['electedCharterId']); + it('should be deletable, which withdraws it', () => { + expect(moderationChartersContractDocumentsSchema.resignationRequest.canBeDeleted).to.be.true(); + }); + + it('should let only a team member ask to leave', () => { + const { anyOf } = moderationChartersContractDocumentsSchema.resignationRequest.ownerRefersTo; + + expect(anyOf.map(({ type, documentType }) => [type, documentType])).to.deep.equal([ + ['listElement', 'electedCharter'], + ['permanentDocument', 'addedModerator'], + ]); + }); + + expectRequired('resignationRequest', rawResignation, ['electedCharterId', 'recipientId', 'recipientKeyId', 'senderKeyId', 'encryptedMessage']); expectNoAdditionalProperties('resignationRequest', rawResignation, 'memberId'); + + it('should refuse a message shorter than an IV and a block', async () => { + const raw = await rawResignation(); + raw.encryptedMessage = crypto.randomBytes(31); + + const error = expectJsonSchemaError(validate('resignationRequest', raw)); + + expect(error.keyword).to.equal('minItems'); + }); }); }); diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs index 006cdf77b3e..2a3bfdcc937 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -14,10 +14,11 @@ //! that asked to join it. Creating one opens or joins the contest for the target contract; //! - once a charter is seated, its leader may add members from the same join requests, up to //! the target's `maxAddedModerators` (`addedModerator`), and remove members -//! (`removedModerator`), and a member may leave on its own (`resignationRequest`). +//! (`removedModerator`); a member asks to leave with a `resignationRequest`, which the +//! leader acts on with a removal and the member withdraws by deleting it. //! //! The team that acts is the leader plus [`ElectedCharter::active_members`]: the elected -//! members and the additions, less the removals and the resignations. +//! members and the additions, less the removals. //! //! The schema carries almost every rule through its keywords (references, lookups, key //! requirements, `distinctFrom`). What it cannot say is here: [`SubmittedCharter`] and @@ -55,7 +56,7 @@ pub const ELECTED_CHARTER_DOCUMENT_TYPE_NAME: &str = "electedCharter"; pub const ADDED_MODERATOR_DOCUMENT_TYPE_NAME: &str = "addedModerator"; /// The name of the document type of a member the leader removes. pub const REMOVED_MODERATOR_DOCUMENT_TYPE_NAME: &str = "removedModerator"; -/// The name of the document type of a member leaving the team on its own. +/// The name of the document type of a member asking to leave the team. pub const RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME: &str = "resignationRequest"; /// The moderators share a proposal takes when it declares none: the full declared fee. @@ -306,23 +307,21 @@ impl SubmittedCharter { impl ElectedCharter { /// The members a seated team acts with besides its leader, `leader_id`: the elected - /// members and those the leader added after the election, less those the leader removed - /// and those who resigned. `added`, `removed` and `resigned` are the `memberId`s of the - /// charter's `addedModerator` and `removedModerator` documents and the owners of its - /// `resignationRequest` documents. A removal and a resignation are final, so the order - /// the documents were filed in does not matter. The leader is never among the result: - /// neither list may name it, and its own resignation does not remove it (leader - /// succession is not a resignation). + /// members and those the leader added after the election, less those the leader removed. + /// `added` and `removed` are the `memberId`s of the charter's `addedModerator` and + /// `removedModerator` documents. A removal is final, so the order the documents were + /// filed in does not matter. A `resignationRequest` changes nothing by itself: the leader + /// acts on it with a removal. The leader is never among the result: neither list may + /// name it. pub fn active_members<'a>( &self, leader_id: Identifier, added: impl IntoIterator, removed: impl IntoIterator, - resigned: impl IntoIterator, ) -> BTreeSet { let mut active: BTreeSet = self.members.iter().copied().collect(); active.extend(added.into_iter().copied()); - for gone in removed.into_iter().chain(resigned) { + for gone in removed { active.remove(gone); } active.remove(&leader_id); diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs index 3fd92011e68..95cb9c8e73e 100644 --- a/packages/rs-dpp/src/moderation_charter/tests.rs +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -206,7 +206,7 @@ fn should_round_trip_an_elected_charter_through_its_document_properties() { } #[test] -fn should_combine_the_elected_members_the_additions_the_removals_and_the_resignations() { +fn should_combine_the_elected_members_the_additions_and_the_removals() { let id = |byte: u8| Identifier::from([byte; 32]); let leader = id(1); let charter = ElectedCharter { @@ -217,26 +217,25 @@ fn should_combine_the_elected_members_the_additions_the_removals_and_the_resigna // Nothing filed since the election: the elected team assert_eq!( - charter.active_members(leader, &[], &[], &[]), + charter.active_members(leader, &[], &[]), [id(2), id(3), id(4)].into() ); - // An addition joins, a removal and a resignation leave, whether the member was elected - // or added + // An addition joins; a removal leaves, whether the member was elected or added assert_eq!( - charter.active_members(leader, &[id(5), id(6)], &[id(2), id(6)], &[id(3)]), - [id(4), id(5)].into() + charter.active_members(leader, &[id(5), id(6)], &[id(2), id(6)]), + [id(3), id(4), id(5)].into() ); // A removal is final: an addition of a removed member does not bring it back assert_eq!( - charter.active_members(leader, &[id(2)], &[id(2)], &[]), + charter.active_members(leader, &[id(2)], &[id(2)]), [id(3), id(4)].into() ); - // The leader is never among the members and its resignation changes nothing + // The leader is never among the members assert_eq!( - charter.active_members(leader, &[leader], &[], &[leader]), + charter.active_members(leader, &[leader], &[]), [id(2), id(3), id(4)].into() ); } diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index 2f93f27e3b9..d8641c708b5 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -311,7 +311,9 @@ mod moderation_charters_tests { use super::*; use crate::consensus::ConsensusError; use crate::data_contract::accessors::v0::DataContractV0Getters; - use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; + use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, + }; use crate::data_contract::document_type::random_document::CreateRandomDocument; use crate::data_contract::document_type::{ ContestedIndexResolution, ContractReferenceModeration, DistinctFrom, @@ -436,9 +438,11 @@ mod moderation_charters_tests { MODERATION_CHARTERS_CONTRACT_ID ); assert!(!document_type.documents_mutable(), "{name} is immutable"); - assert!( - !document_type.documents_can_be_deleted(), - "{name} is undeletable" + // A resignation request is withdrawn by deleting it; nothing refers to it + assert_eq!( + document_type.documents_can_be_deleted(), + name == RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, + "{name}: only a resignation request can be deleted" ); } } @@ -842,9 +846,11 @@ mod moderation_charters_tests { removed.get("$ownerId").map(String::as_str), Some("$ownerId") ); - assert!( - charter_agreement(RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME).is_empty(), - "anyone may resign; only a member's resignation changes the team" + let resignation = charter_agreement(RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME); + assert_eq!( + resignation.get("recipientId").map(String::as_str), + Some("$ownerId"), + "a resignation is addressed to the charter's leader" ); match reference( @@ -915,4 +921,70 @@ mod moderation_charters_tests { ); } } + + /// Only a member of the seated team may ask to leave: the writer is listed in the elected + /// charter's `members`, or the leader added it after the election. The request is + /// deletable, which withdraws it, and carries a message only the leader can read. + #[test] + fn should_let_only_a_team_member_ask_to_leave() { + let contract = contract(); + let resignation = document_type(&contract, RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME); + assert!(resignation.documents_can_be_deleted()); + + let Some(DocumentPropertyReferenceTarget::AnyOf(operands)) = resignation.owner_reference() + else { + panic!( + "the writer must meet any of the membership targets: {:?}", + resignation.owner_reference() + ); + }; + match operands.operands() { + [DocumentPropertyReferenceTarget::ListElement(listed), DocumentPropertyReferenceTarget::PermanentDocumentLookup { + document_type_name, + lookup, + .. + }] => { + assert_eq!( + listed.document_type_name, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME + ); + assert_eq!(listed.in_list, property_names::MEMBERS); + assert_eq!( + listed.document_id_property(), + Some(property_names::ELECTED_CHARTER_ID) + ); + assert_eq!(document_type_name, ADDED_MODERATOR_DOCUMENT_TYPE_NAME); + assert_eq!(lookup.index, "byElectedCharterMember"); + assert_eq!( + lookup.keys.get(property_names::MEMBER_ID), + Some(&LookupKeySource::ReferenceValue) + ); + } + other => panic!("resignation membership operands: {other:?}"), + } + + let encrypted_for = resignation + .flattened_properties() + .get("encryptedMessage") + .and_then(|property| property.encrypted_for.clone()) + .expect("the message declares its envelope"); + assert_eq!( + encrypted_for.recipient, + EncryptedForRecipient::Property("recipientId".to_string()) + ); + match reference( + &contract, + RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, + "senderKeyId", + ) { + PropertyReference::KeyId(key_reference) => { + assert_eq!( + key_reference.key_requirements.bound_to.as_deref(), + Some(JOIN_REQUEST_DOCUMENT_TYPE_NAME), + "the member's encryption key is the one its join request used" + ); + } + other => panic!("senderKeyId: {other:?}"), + } + } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 4af763d757a..e232b7420c3 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -896,10 +896,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// update. /// /// -/// 33. **The moderation charters system contract** +/// 36. **The moderation charters system contract** /// (`SystemDataContract::ModerationCharters`, schema v1, the first piece of /// decentralized moderation teams) carries seven document types, all -/// immutable and undeletable. A `reason` is a ground for a moderation +/// immutable and all but `resignationRequest` undeletable. A `reason` is a ground for a moderation /// action, keyed by its owner and a three-letter `code` unique among the /// owner's reasons. A `submittedCharter` is a leader's proposal to /// moderate one contract on that contract's own terms: its @@ -921,11 +921,15 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// the join request's unique index) and none of which is the leader /// (item 26). Once a charter is seated, its leader adds members from the /// same join requests (`addedModerator`, the same lookup) and removes -/// members (`removedModerator`), and a member leaves on its own -/// (`resignationRequest`): each once per member and charter (unique -/// indexes), removals and resignations final, so the team that acts is the -/// leader plus the elected members and the additions less the removals and -/// the resignations (`ElectedCharter::active_members`). The cap on +/// members (`removedModerator`), each once per member and charter (unique +/// indexes), removals final, so the team that acts is the leader plus the +/// elected members and the additions less the removals +/// (`ElectedCharter::active_members`). A member asks to leave with a +/// deletable `resignationRequest`, which only a member may file +/// (`ownerRefersTo` with an `anyOf` of a `listElement` into the elected +/// charter's `members` and a lookup of an `addedModerator`, items 33 to +/// 35) and which carries a message encrypted to the leader; the leader acts +/// on it with a removal. The cap on /// additions, the target's `maxAddedModerators`, is a consensus rule of the /// seating pull request. /// Its `byTargetContract` index is a contested unique index