diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 1579dc2e7fb..35486131ac2 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -107,7 +107,7 @@ Deletions (`Delete` and `IndexOnlyDelete`) are never refused: a barred identity ### The Errors -Basic, in their own band (10900-10949): `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `ContractModerationReasonTooLongError` (10903; 10902 is reserved), `InvalidContractModerationReasonDocumentsError` (10904). State, in their own sub-band: `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractModerationTargetNotAllowedError` (41102), `ContractUserAlreadyBannedError` (41103), `ContractUserNotBannedError` (41104), `ContractUserNotSuspendedError` (41105), `ContractSuspensionNotInFutureError` (41106), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110, from the contract create and update, not from the moderation transition), `ContractModerationCounterpartyBarredError` (41114, from the document gate; 41111 to 41113 are reserved), `ContractUserNotWarnedError` (41117), `ContractUserWarningLimitReachedError` (41118). A contract update that turns a list on or off is refused with the existing `DataContractConfigUpdateError` (40002). Elected moderation has its own band (41200-41299): `ContractModeratedDocumentTypeNotYetUsableError` (41200). +Basic, in their own band (10900-10949): `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `ContractModerationReasonTooLongError` (10903; 10902 is reserved), `InvalidContractModerationReasonDocumentsError` (10904). State, in their own sub-band: `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractModerationTargetNotAllowedError` (41102), `ContractUserAlreadyBannedError` (41103), `ContractUserNotBannedError` (41104), `ContractUserNotSuspendedError` (41105), `ContractSuspensionNotInFutureError` (41106), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110, from the contract create and update, not from the moderation transition), `ContractModerationCounterpartyBarredError` (41114, from the document gate; 41111 to 41113 are reserved), `ContractUserNotWarnedError` (41117), `ContractUserWarningLimitReachedError` (41118). A contract update that turns a list on or off is refused with the existing `DataContractConfigUpdateError` (40002). Elected moderation has its own band (41200-41299): `ContractModeratedDocumentTypeNotYetUsableError` (41200), `ContractModerationAbilityNotGrantedError` (41201) and `ModerationCharterAddedModeratorLimitReachedError` (41202). A discounted action fee the seated charter does not give is refused with `DocumentActionFeeModeratorsShareMismatchError` (40139), beside the other fee agreement errors. ## Deleting Documents @@ -238,7 +238,7 @@ A moderation team can be paid. A document type may charge a fixed fee in credits The pots are not under the contract. The per-block total credits check (`calculate_total_credits_balance`) sums a fixed set of root sum trees, and `DataContractDocuments` is a normal tree: credits parked under a contract would leave that sum and fail every block with `CorruptedCreditsNotBalanced`. `PreFundedSpecializedBalances` is one of the summed trees, so the pots live there, in two sum trees beside the voting balances, created at genesis (state structure 4) and by the upgrade to protocol version 14 through the same helper, one after the other, so that both node populations build the same Merk. A pot is created by the first fee it receives, and so is its tree on a chain that reached protocol version 14 on a build from before the pots: that first fee checks, with a billed read, that the tree is there. The estimation of a voting balance write moves to generation 1 with them, because the prefunded balances layer now holds three trees instead of one. The two last claims are plain items of the contract's other tree, below `128` so the banlist stays on top, written by the first claim and replaced by every later one. A last claim (`ContractFeePotLastClaim`) is 42 bytes: the epoch of the claim, which the next claim is judged against, the time of its block in milliseconds, and the id of the identity that signed it. The owner pot's claimant is always the owner; the moderators pot's is whichever member of the team claimed for all of them, so the team can see who paid them and when. Every last claim has the same size, so a replacement never changes the size of the item, and the item carries no storage flags: it is never removed, and no claim adds bytes for anyone to own. -**The team** that shares the moderators pot is the set of identities the contract appoints, the owner among them only when appointed, and the owner alone when nobody is appointed (`ContractModerators::team`). It is about earnings, not authority: an owner who is not appointed still may moderate. `ContractFeePot::recipients` names who a payout of a pot goes to: the contract owner for the owner pot, the team for the moderators pot, nobody for the moderators pot of a contract that declares no moderation. +**The team** that shares the moderators pot is the set of identities the contract appoints, the owner among them only when appointed, and the owner alone when nobody is appointed (`ContractModerators::team`). It is about earnings, not authority: an owner who is not appointed still may moderate. For an elected contract it is the interim's team, and only until a charter is seated (see Elected Moderation below). `ContractFeePot::recipients` names who a payout of a pot goes to: the contract owner for the owner pot, the team for the moderators pot, nobody for the moderators pot of a contract that declares no moderation. `ContractFeeClaim` (state transition type 25) names a contract and a pot and pays the pot out. It is signed with a CRITICAL authentication key under the signer's contract nonce, and the claimant pays its gas like any other transition. @@ -267,7 +267,7 @@ The claim's proof is verified against the contract, which names who the pot pays ## Elected Moderation -A contract may hand the choice of its moderators to the network instead of keeping it: it declares that its moderators are a **team elected by masternodes and evonodes**. Teams will apply with a charter, masternodes will elect one, and the seated team will moderate under its charter, paid from the moderators pot. None of that exists yet. What exists is the declaration, frozen at the contract's creation, and the **interim**: how the contract is moderated until its first team is seated. +A contract may hand the choice of its moderators to the network instead of keeping it: it declares that its moderators are a **team elected by masternodes and evonodes**. Teams apply with a charter in the moderation charters system contract (see `docs/protocol/moderation-charters.md`), masternodes elect one in a contest for the contract's seat, and the seated team moderates with the contract's declaration, charging at most what the contract declares. What the contract itself holds is the declaration, frozen at its creation, and the **interim**: how the contract is moderated until its first team is seated. ```rust pub struct ElectedModerators { @@ -285,20 +285,29 @@ The declaration lives in `packages/rs-dpp/src/data_contract/config/moderation/el - **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 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, all of it until a charter is seated and none of it after (see The seated team below). `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. **Validation.** `validate_moderation_config` v0 checks the declaration with the rest of the moderation config, from the raw document schemas of the create or update transition, and refuses with `InvalidContractModerationConfigError` (10900, unpaid): a window or the cool-down outside its bounds, an empty moderated set, a moderated type the contract does not have, an empty ability set or an ability the type can not back, and an empty or oversized interim set. The create then checks that every interim moderator exists (41110), as it does for an appointed set. **The update.** `validate_config_update` 2 refuses, with `DataContractConfigUpdateError` (40002), every change to the declaration, the interim included, and entering or leaving elected moderation. A contract that declares elected moderation is elected for good, and one that did not can not become so; the merged kinds still swap their moderators freely. An update may add document types; the declaration keeps naming the ones it named. -**The interim block.** The batch transformer's `contract_moderation_gate` v0 runs it before the lists: on an elected contract whose interim is `NotYetUsable`, every document transition of a moderated document type, deletions included (nothing of those types was ever written), is refused, paid, with `ContractModeratedDocumentTypeNotYetUsableError` (41200) and its contract nonce bump, in a block and in the mempool. The lists are read only for the transitions on the other types, and not at all when nothing is left. The interim moderators of the other two kinds moderate through the same transition, the same gate and the same claim as the merged kinds; a moderation transition against a `NotYetUsable` contract fails as by a non-moderator (41101). +**The interim block.** The batch transformer's `contract_moderation_gate` v0 runs it before the lists: on an elected contract whose interim is `NotYetUsable`, every document transition of a moderated document type, deletions included (nothing of those types was ever written), is refused, paid, with `ContractModeratedDocumentTypeNotYetUsableError` (41200) and its contract nonce bump, in a block and in the mempool, until a charter is seated on the contract. Whether one is, is read (billed) only when a transition of the batch is on a type the block covers. The lists are read only for the transitions on the other types, and not at all when nothing is left. The interim moderators of the other two kinds moderate through the same transition, the same gate and the same claim as the merged kinds; a moderation transition against a `NotYetUsable` contract fails as by a non-moderator (41101). + +**The seated team.** Seating writes nothing. A team applies with an `electedCharter` of the moderation charters contract, a create on its contested unique index `byTargetContract`, keyed by the target contract; awarding that contest writes the winner's document to the charter contract's storage, the only `electedCharter` ever written there for the target (contenders live in the contest, and in protocol version 14 a seat is never replaced). So the charter seated on a contract is the one `byTargetContract` finds, and every moderation path reads it from there (`execution/validation/state_transition/common/seated_moderation_charter` in drive-abci): there is no block-end seating hook and no copy under the moderated contract. Its team is the charter's owner, the **leader**, plus the **active members**: its `members`, and the `memberId` of every `addedModerator` for it, less the `memberId` of every `removedModerator` for it (`ElectedCharter::active_members`). A `resignationRequest` changes nothing by itself; the leader acts on it with a removal. + +- **Who moderates.** Once a charter is seated, only its team moderates: the leader and the active members, each alone. The interim moderators, the owner among them, can no longer act (41101), whatever the interim was. Deciding whether the signer is on the team reads no list of it: the leader costs nothing beyond the charter lookup, an elected member one point read of its removal, anyone else a point read of its addition and, when there is one, of its removal (both types are unique on the charter and the member). What the interim did stands: its bans, suspensions, warnings and removals, which the team may lift. +- **With what.** The team holds the abilities the declaration gives it and no others. A deletion or a restore needs `deleteDocuments` on the document type; a ban, a suspension or a warning, or lifting one, needs the ability on some moderated type, since the lists are contract-wide. Anything else is refused, paid, with `ContractModerationAbilityNotGrantedError` (41201). +- **Who is protected.** The leader and the active members can be neither put on a list nor have their documents deleted (41102), and the owner too when the declaration sets `ownerProtected`. The interim moderators lose the protection they had. +- **How many join later.** The leader adds members from the proposal's join requests, at most the target's `maxAddedModerators`, counting the additions ever filed for the charter, so a removal frees no slot. The schema cannot count documents, so the batch's state validation refuses the addition past the cap, paid, with `ModerationCharterAddedModeratorLimitReachedError` (41202), after reading the charter, its target and at most the cap's number of additions, all billed; additions an earlier create of the same batch was accepted for count too. Like a unique index conflict it is judged in the block, not in the mempool, which runs no state validation for a batch. +- **What it charges.** An action on a moderated type may agree to the charter's `moderatorsShare` of the declared moderators part instead of the whole of it, and is then charged that (see [Document action fees](../fees/overview.md#document-action-fees)). An action agreeing to the declared amount reads no charter. +- **The pot.** The interim team claims the moderators pot only until a charter is seated; its claim is refused after (41113), so the pot carries over to the seated team, unsettled. How the seated team claims it, split by its proposal's `rewardSplit`, is not built yet. **Referencing an elected contract.** A document type that must point at a contract of this kind says so in its reference: `"refersTo": { "type": "contract", "contractRequirements": { "moderation": "elected" } }`. `contractRequirements` holds what the referenced contract must declare beyond existing, each key an aspect of the contract with a closed set of values or a bound: `moderation: "elected"`, or `moderation: "electionOpen"`, which also requires the contract's own election delay to have passed since its creation, or the contract to declare none (the delay between a contract's creation and the first charter against it, so a team cannot be seated before anyone has seen the contract, set by each contract for itself). Both have a user in the charter contract: a charter proposal only needs the target to be `elected`, so teams can form during the notice, and the charter that opens the contest needs its election `electionOpen`; `minimumAgeSeconds`, a number of seconds the reference fixes, which requires the contract's recorded creation time to be at least that far before the block time of the write; `minimumSecondsSinceUpdate`, the same of the later of the contract's creation and last update times (any update restarts the clock; an elected declaration can not be added by an update, so this one is for other uses than the charter); `owner`, `"self"` requiring the referenced contract to be owned by the writer of the referring document (its `$ownerId`, a write gate like the `$ownerId` property agreement of a document reference) and `"other"` by anyone else (so a charter may forbid an owner from chartering its own team); `readonly: true`, requiring the referenced contract's config to be read-only, one that can never be updated again (which makes `minimumSecondsSinceUpdate` moot for the same target); `keepsHistory: true`, requiring its config to keep history (only `true` is declarable for either flag); and `ownerProtected`, requiring the contract's elected moderation declaration to protect the owner from the team (`true`) or to leave it unprotected (`false`), which implies elected moderation without the schema having to say so, a contract without an elected declaration meeting neither value. A contract created before contracts recorded their creation time never meets a duration, its own election delay included. Consensus checks them when the referring document is written, against the contract it has already fetched for the existence check and the write itself (its owner and block time), so they cost no further read; a contract that exists but does not meet a requirement refuses the write, paid, with `ReferencedContractRequirementNotMetError` (40135) naming the requirement, where a contract that does not exist is still 40120. A changed `contractRequirements` is an incompatible schema change on update, like the rest of a `refersTo`. The charter system contract's `targetContractId` is the first user. **Referencing an identity key with requirements.** The same shape serves the key references the charter contract needs: `"refersTo": { "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } }`. `keyRequirements` holds what the referenced key must be beyond existing and not being disabled, each key an aspect of the key: `purpose`, the key's purpose by its wire name (`authentication`, `encryption`, `decryption`, `transfer`, `voting` or `owner`; never `system`), and `boundTo`, the name of a document type of the declaring contract, which requires the key's contract bounds to be exactly the declaring contract and that document type; a whole-contract bound or a contract group bound never meets it, even where the group holds the type, since the check reads nothing beyond the key. Registration (`create_document_types_from_document_schemas` 1, a post-pass edited in place since it is inert before protocol version 14, under full validation like the meta-schema) checks that `boundTo` names a document type the contract has, so the write-time check never needs a second contract fetch, and that a key meeting the pair can exist at all: only authentication, encryption and decryption keys carry a document type bound, and Drive registers an encryption or decryption key bound to a document type only when that type declares `requiresIdentityEncryptionBoundedKey` or `requiresIdentityDecryptionBoundedKey`, so a `boundTo` paired with `transfer`, `voting` or `owner`, or with an encryption purpose on a type without the matching keyword, is refused as a requirement no key could ever meet. Consensus checks the requirements when the referring document is written, against the key it has already fetched for the existence check, so they cost no further read; a key that exists and is enabled but does not meet one refuses the write, paid, with `ReferencedIdentityKeyRequirementNotMetError` (40136) naming the document type, the property, the requirement and what the key has, where a missing key is still 40123 and a disabled one 40124. A replace that repoints the reference at another key, through either the identity id or the key id, re-checks them. A changed `keyRequirements` is an incompatible schema change on update, like the rest of a `refersTo`. New requirements (a security level, say) are new keys of the same object, never a new reference type. The charter contract's `joinRequest.recipientId` (a decryption key bound to `submittedCharter`) is the first user. -**What comes next.** The charter system contract, applications and the election (new vote poll kinds), the seated team under the contract with its per-ability powers, charter-priced moderators amounts within the maximums, and challenges and amendments. Issue #4865 holds the design. +**What comes next.** The seated team's claim of the moderators pot, split by its proposal's `rewardSplit` with action counters and settled before every change of the team; the check that every moderation action names a reason the seated proposal lists; the election parameters (the windows and the application fund) read from the target contract; and, after protocol version 14, challenges and amendments. Issue #4865 holds the design. ## Versioning Touchpoints @@ -306,11 +315,11 @@ All in place for protocol version 14: `CONTRACT_VERSIONS_V6` makes config V2 the The document deletion adds, all for protocol version 14 as well: the `canBeDeletedByModerators` keyword in meta-schema v3 (`CONTRACT_VERSIONS_V6` already selects it); five slots in `DriveContractModerationMethodVersions` and one in the verify and query tables; and `batch_operations.apply_drive_operations = 1` in `DRIVE_VERSION_V9`, the generation that forfeits the refund. The transition's own tables do not move: the action joins a transition no release contains. -Elected moderation moves no table: the declaration is a variant of the same config V2, `validate_moderation_config` v0 and `validate_config_update` 2 take it on in place while protocol version 14 is unreleased, `contract_moderation_gate` v0 runs the interim block, and `SYSTEM_LIMITS_V4` gains the four bounds of the windows and the cool-down. +Elected moderation moves no table: the declaration is a variant of the same config V2, `validate_moderation_config` v0 and `validate_config_update` 2 take it on in place while protocol version 14 is unreleased, `contract_moderation_gate` v0 runs the interim block, and `SYSTEM_LIMITS_V4` gains the four bounds of the windows and the cool-down. The seated team moves none either: the moderation transition's state v0, the claim's, the gate v0 and the batch transformer's state v2 read the charter in place, all generations no release selects; the cap on additions is a hook in the batch's shipped `validate_state` v0 that only a create of the charter contract reaches, a contract absent from state before protocol version 14. ## What Is Not There Yet -Deleting indexOnly documents (the action would have to carry the owner and the values), deleting every document of an identity at once, action fees on token transitions, group-based moderators (`AuthorizedActionTakers::Group` through group actions), keys bound to the contract allowed to sign its moderation, ban codes declared by the contract (the reason's `code` is where they will go), the moderator's id on a ban or a suspension (a warning carries its block time but not who issued it), retracting one warning rather than all, a warning that expires by the clock, a contract-declared strike count that turns warnings into a suspension, the election and the seated team of an elected contract, and the Swift and Kotlin SDKs. The refusal a barred identity receives (41107, 41108, 41114) does not repeat the reason: the status query does. +Deleting indexOnly documents (the action would have to carry the owner and the values), deleting every document of an identity at once, action fees on token transitions, group-based moderators (`AuthorizedActionTakers::Group` through group actions), keys bound to the contract allowed to sign its moderation, ban codes declared by the contract (the reason's `code` is where they will go), the moderator's id on a ban or a suspension (a warning carries its block time but not who issued it), retracting one warning rather than all, a warning that expires by the clock, a contract-declared strike count that turns warnings into a suspension, the seated team's claim of the moderators pot and the reason its actions must name, challenges and amendments of a seated charter, and the Swift and Kotlin SDKs. The refusal a barred identity receives (41107, 41108, 41114) does not repeat the reason: the status query does. ## Tests @@ -320,4 +329,5 @@ Deleting indexOnly documents (the action would have to carry the owner and the v - `packages/rs-dpp/src/data_contract/config/moderation/mod.rs` and `config/methods/validate_update/v2`: the declaration's rules and the update rules, the elected declaration's among them (every bound, the moderated set, the envelope, the maximums, the interim set, the wire shape, and an update refused for each field and for entering or leaving); `moderation/elected.rs`: what each interim kind allows. - `packages/rs-drive/src/drive/contract/moderation/tests.rs`: tree creation on insert, the trees and their entries surviving a contract update, every writer with estimation, status and page proofs, paging, the refund going to the first moderator after another one replaces its suspension, a status proof over one list saying nothing about the other, the warning list tree created only when declared, warnings accumulating under the moderator that warned last and cleared with a refund to it, a warn never estimated below its cost up to the fullest entry, and the banlist on top of the other tree with every combination of lists. - `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs`: the gate is silent before protocol version 14 and for an unmoderated contract, refuses each barred operation of one batch on its own while keeping the deletions, and blocks the moderated types of an elected contract in its interim without reading the lists for them. +- `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/seated_team.rs`: a contest for an elected contract's seat awarded, then the leader and the members moderating instead of the interim, additions and removals, the protection of the team, the cap on additions, abilities the declaration does not give, the interim block ending, a discounted fee charged and read where the declared one reads nothing, every other discount refused in a block and on recheck, and the interim's claim refused once a charter is seated. - `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs`: the whole pipeline, including a warned user carrying on with its warnings accumulating, proved and cleared, the warning limit and the clearing that lifts it, warnings kept through a ban and out of the gate and the ban's proof, every refusal of a warn, and the warning list fixed at creation; the moderators' window (a deletion to the millisecond it ends on, refused one later for the contract owner too while the author's own still passes, reopened by a replace, measured from `$createdAt` on a type that never changes, fixed on update), a moderator deleting a post (record, execution proof, the author's balance unchanged, and the control where the author deletes it and is refunded), every refusal of a deletion, an update adding a document type moderators can delete from, a permanent reference to such a type refused, the mempool refusal, the lapse sweep, the moderator set, every refusal code, the lists staying as the contract was created with them, a barred identity deleting its own documents in a block and in the mempool, a barred identity refused as the recipient of a transfer and as the seller of a purchase, the ban's proof covering the suspension it removed, lifting the entry of an identity an update made moderator, the per-list execution proof, a named owner, a create or an update naming a moderator that does not exist, an update keeping its moderators, inactivity of the transition and of a moderated contract create or update before protocol version 14, and elected moderation (each interim kind moderating or not, the block of a moderated type next to an unmoderated one in a block and in the mempool, a create refused outside a bound and for an unknown type, and an update refused for a changed field and for entering or leaving). diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index ec12df01ede..9461ea31be0 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -107,7 +107,7 @@ The fee category currently has a single code. The 30000 range is reserved for fu | Range | Category | Examples | |-------|----------|----------| | 40000-40009 | Data Contract | `DataContractAlreadyPresentError` (40000), `DataContractIsReadonlyError` (40001), `DataContractNotFoundError` (40008) | -| 40100-40134 | Documents | `DocumentAlreadyPresentError` (40100), `DocumentNotFoundError` (40101), `DuplicateUniqueIndexError` (40105), `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134) | +| 40100-40139 | Documents | `DocumentAlreadyPresentError` (40100), `DocumentNotFoundError` (40101), `DuplicateUniqueIndexError` (40105), `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134), `DocumentActionFeeModeratorsShareMismatchError` (40139) | | 40200-40217 | Identity | `IdentityAlreadyExistsError` (40200), `InvalidIdentityRevisionError` (40203), `IdentityInsufficientBalanceError` (40210) | | 40300-40307 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304), `VoteChoiceNotAllowedForVotePollError` (40307) | | 40400-40401 | Prefunded Balances | `PrefundedSpecializedBalanceInsufficientError` (40400) | @@ -118,7 +118,7 @@ The fee category currently has a single code. The 30000 range is reserved for fu | 40900-40904 | Shielded | `InvalidAnchorError` (40900), `NullifierAlreadySpentError` (40901), `InsufficientShieldedFeeError` (40904) | | 41000-41003 | Contract Groups | `ContractGroupAlreadyExistsError` (41000), `ContractGroupNotFoundError` (41001), `IdentityNotContractGroupOwnerOrAdminError` (41002), `ContractGroupAdminNotFoundError` (41003) | | 41100-41122 | Contract Moderation | `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110), `ContractFeesAlreadyClaimedThisEpochError` (41111), `ContractFeesNothingToClaimError` (41112), `ContractFeeClaimNotAllowedError` (41113), `ContractModerationCounterpartyBarredError` (41114), `DocumentTypeNotDeletableByModeratorsError` (41115), `DocumentModerationWindowElapsedError` (41116), `ContractUserNotWarnedError` (41117), `ContractUserWarningLimitReachedError` (41118), `ContractDocumentRemovalNotFoundError` (41119), `DocumentRestoreWindowElapsedError` (41120), `DocumentRestoreHashMismatchError` (41121), `ContractDocumentAlreadyRestoredError` (41122) | -| 41200-41299 | Contract Moderation Teams | `ContractModeratedDocumentTypeNotYetUsableError` (41200) | +| 41200-41299 | Contract Moderation Teams | `ContractModeratedDocumentTypeNotYetUsableError` (41200), `ContractModerationAbilityNotGrantedError` (41201), `ModerationCharterAddedModeratorLimitReachedError` (41202) | Notice how the `DataTriggerError` sub-enum has its own `ErrorWithCode` implementation that the `StateError` delegates to: diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 18cb9f962e4..5021ba86f99 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -324,6 +324,38 @@ agreement from the contract it showed its user with `DocumentActionFeeAgreement::for_document_type_action`, never from a contract fetched behind their back at signing time. +**A seated team's discount.** On a document type an elected contract +moderates, the `moderators` part of an agreement may name less than the +declared amount: the share the contract's seated moderation charter takes +(its proposal's `moderatorsShare`, a percentage; none declared is the full +amount), applied to the declared amount and rounded down to the credit +(`moderation_charter::moderators_share_of`). Everything else must still match: +the `owner` part and the pricing. With a share of 60, the post above admits +exactly 60000000 for the moderators: + +```json +"$actionFeeAgreement": { + "$formatVersion": "0", + "owner": 10000000, + "moderators": 60000000, + "feeMultiplier": { "knownPermille": 1000, "increaseTolerancePercent": 20 } +} +``` + +The action is then charged the agreed amount, which is what reaches the +moderators pot (scaled by the multiplier for a `feeMultiplier` fee, like the +declared amount). An agreement to the declared amount stays valid whatever the +team charges and reads no charter; only one that names less has the batch +transformer read the seated charter (the `byTargetContract` index of the +moderation charters contract) and the proposal it runs on, billed to the +batch. Any other amount below the declared one, including a discount on a +contract with no seated charter yet, is refused like a mismatch, paid and +without a fee (`DocumentActionFeeModeratorsShareMismatchError`, 40139). A +lower amount anywhere else (a type the contract does not moderate, a contract +that is not elected) is the plain mismatch (40133). The mempool judges it +the same way on arrival and on every recheck, since the recheck transforms the +batch anew. + **The amounts do not change yet.** A contract update may not add, change or remove the `actionFees` of an existing document type, nor switch their pricing (`DocumentTypeUpdateError`). A document type *added* by an update may declare diff --git a/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md index 578c6d99193..c8aa0fa901d 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -9,9 +9,11 @@ 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. +Seating writes nothing. Awarding the contest for a target writes the winning +`electedCharter` to this contract's storage, the only one ever written there +for that target, so the charter seated on a contract is the one +`byTargetContract` finds, and the target's moderation paths read it from here +(see [Seating](#seating)). - Contract ID: `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88` - Owner: the all-zero system identity @@ -75,7 +77,7 @@ bound to it, the key join requests are encrypted to. | `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 and at most 4096 bytes (`maxBytes`), 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 | +| `moderatorsShare` | integer 0 to 100 | The percentage of each moderated document type's declared moderators fee the team takes, rounded down to the credit. Absent is the full amount; a lower number is a discount an action may agree to once the team is seated; 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. The sum is the type's `propertyConstraints` rule `rewardSplitIsWhole`, checked on every create (`DocumentPropertyConstraintViolatedError`, 10422) | Indexes: `byTargetContract` (`targetContractId`, `$createdAt`) lists the @@ -147,8 +149,13 @@ at a time, and a removal 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 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. +slot. The schema cannot count documents, so a consensus rule refuses an +addition over the cap, paid, with `ModerationCharterAddedModeratorLimitReachedError` +(41202): the batch's state validation reads the charter, its target and at most +the cap's number of additions, all billed, once the addition's own references +passed. Like a unique index conflict, it is judged in the block and not in the +mempool, which runs no state validation for a batch: an addition over the cap +is admitted and then refused, paid. ## The contest @@ -170,8 +177,45 @@ 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. +the fund from the target contract comes in a later pull request. + +## Seating + +Nothing is written when a contest is awarded, and nothing is copied under the +moderated contract: the charter seated on a contract is its `electedCharter` +in this contract's storage, found through `byTargetContract` (only a contest's +winner is ever written there, and in protocol version 14 a seat is never +replaced). The moderation paths of the target read it: + +- **Moderation.** Once a charter is seated, only its team moderates the + target: the leader and the active members, each alone. The interim + moderators, the owner among them, are refused + (`IdentityNotContractModeratorError`, 41101); before a charter is seated the + interim rules apply as they did. The signer check lists no team: the leader + is the charter's owner, an elected member costs a point read of + `removedModerator`, anyone else a point read of `addedModerator` and, when + there is one, of `removedModerator` (both unique on `electedCharterId` and + `memberId`). +- **Abilities.** The team holds the abilities the target's declaration gives + it: a deletion or a restore needs `deleteDocuments` on the type, a list + action the ability on some moderated type + (`ContractModerationAbilityNotGrantedError`, 41201 otherwise). +- **Protection.** The leader and the active members can be neither put on a + list nor have their documents deleted (41102), and the owner too when the + declaration protects it. +- **The interim block.** A `notYetUsable` interim stops blocking the + moderated types. +- **Fees.** An action agreeing to the declared moderators fee reads no + charter. One agreeing to less must agree to exactly the seated proposal's + `moderatorsShare` of it (rounded down to the credit) and is charged that, + at the cost of the charter lookup and the proposal fetch + (`DocumentActionFeeModeratorsShareMismatchError`, 40139, for any other + amount, and for a discount with no seated charter). +- **The pot.** The interim team's claim of the moderators pot is refused once + a charter is seated (41113); the pot waits for the seated team, whose claim + comes in a later pull request. +- **Resignations.** A `resignationRequest` changes nothing by itself: the + leader acts on it with a `removedModerator`. ## Validation beyond the schema @@ -180,15 +224,18 @@ written, the description's 4096-byte cap and the reward split's sum included: `maxBytes` refuses a longer description with `DocumentPropertyMaxBytesExceededError` (10421), and the `propertyConstraints` rule `rewardSplitIsWhole` refuses a split that does not add up to 100 with -`DocumentPropertyConstraintViolatedError` (10422). `validate_submitted_charter` -in `rs-dpp` (`packages/rs-dpp/src/moderation_charter/`) only reads a proposal, -without reading state, for the path that seats a team: +`DocumentPropertyConstraintViolatedError` (10422). The cap on additions is the +exception (see above). `validate_submitted_charter` in `rs-dpp` +(`packages/rs-dpp/src/moderation_charter/`) only reads a proposal, without +reading state: | Rule | Error | Code | | --- | --- | --- | | A property is missing or of the wrong type | `ModerationCharterMalformedFieldError` | 11000 | -`ElectedCharter` reads an elected charter's properties for the same path. +`ElectedCharter` reads an elected charter's properties, and +`moderation_charter::moderators_share_of` applies a proposal's share to a +declared moderators fee. ## Reading and writing from a client diff --git a/packages/moderation-charters-contract/README.md b/packages/moderation-charters-contract/README.md index 7907a4f50fb..9a16b2d5dd2 100644 --- a/packages/moderation-charters-contract/README.md +++ b/packages/moderation-charters-contract/README.md @@ -13,8 +13,15 @@ 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, the `encryptedFor` envelope, `maxBytes` for the description's 4096-byte cap and a -`propertyConstraints` rule holding the reward split to 100, so -`SubmittedCharter` in `rs-dpp` only reads a proposal when a team is seated. +`propertyConstraints` rule holding the reward split to 100. What it cannot say, +the cap on additions, is checked by the batch's state validation, and +`SubmittedCharter` in `rs-dpp` only reads a proposal. + +Seating writes nothing. Awarding the contest for a target writes the winning +`electedCharter` here, the only one ever written for that target, and the +target's moderation paths read it: its team moderates instead of the interim +moderators, is protected, and may charge its proposal's `moderatorsShare` of a +declared moderators fee (see [the protocol guide](../../docs/protocol/moderation-charters.md#seating)). ## `reason` @@ -87,7 +94,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, a consensus rule that comes with seating | +| `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, ever filed, a consensus rule of the batch's state validation (41202) | | `removedModerator` | the leader | `electedCharterId`, `memberId` | Needs no resignation; `memberId` is not the leader | | `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 | 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 35189046061..64066223ff7 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs @@ -386,9 +386,20 @@ impl ElectedModerators { .is_some_and(|abilities| abilities.contains(&ability)) } + /// Whether the seated team holds the ability on some moderated document type. The lists + /// are contract-wide, so this is what lets the team ban, suspend or warn (and lift each): + /// an ability on a type is what the team may do over the documents of that type, and an + /// identity is barred from the whole contract. + pub fn allows_on_any_type(&self, ability: ModerationAbility) -> bool { + self.moderated_document_types + .values() + .any(|abilities| abilities.contains(&ability)) + } + /// Whether the interim refuses every document transition of the document type: the - /// interim names nobody and the type is moderated. Once a team is seated (not yet - /// possible) this ends. + /// interim names nobody and the type is moderated. This is the declaration's side only: + /// the block ends once a charter is seated on the contract, which only state says, so the + /// document gate reads whether one is before it refuses. pub fn interim_blocks_document_type(&self, document_type_name: &str) -> bool { self.interim.blocks_moderated_document_types() && self.moderates_document_type(document_type_name) @@ -771,6 +782,27 @@ mod tests { assert!(!protected.protects(&owner, &user)); } + #[test] + fn should_give_a_seated_team_the_abilities_of_any_moderated_type_on_the_lists_only() { + let mut declaration = elected(); + declaration + .moderated_document_types + .insert("like".to_string(), moderated(&[ModerationAbility::Warn])); + declaration.moderated_document_types.insert( + "post".to_string(), + moderated(&[ModerationAbility::Ban, ModerationAbility::DeleteDocuments]), + ); + + // The lists are contract-wide: an ability on any moderated type lets the team use it. + assert!(declaration.allows_on_any_type(ModerationAbility::Ban)); + assert!(declaration.allows_on_any_type(ModerationAbility::Warn)); + assert!(!declaration.allows_on_any_type(ModerationAbility::Suspend)); + // A deletion is of one type's documents: only where that type carries the ability. + assert!(declaration.allows("post", ModerationAbility::DeleteDocuments)); + assert!(!declaration.allows("like", ModerationAbility::DeleteDocuments)); + assert!(!declaration.allows("comment", ModerationAbility::Ban)); + } + #[test] fn should_bound_the_members_a_leader_may_add() { let max = PlatformVersion::latest() 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 14d1c2678a6..9b7ba6e6d54 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/mod.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/mod.rs @@ -103,6 +103,10 @@ impl ContractModerators { /// Whether `identity_id` may moderate a contract owned by `owner_id`. Under an elected /// declaration, whether it may during the interim: the owner alone, the owner and the /// appointed interim set, or nobody, the moderated types unusable or unmoderated meanwhile. + /// + /// Once a charter is seated on an elected contract its team moderates instead, and the + /// interim moderators no longer may. Who is on the team is read from the moderation + /// charters contract, so that is decided where state is read, not here. pub fn may_moderate(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool { match self { ContractModerators::ContractOwner | ContractModerators::AppointedModerators(_) => { @@ -117,6 +121,9 @@ impl ContractModerators { /// Whether `identity_id` is protected from moderation on a contract owned by `owner_id`: /// it can be neither banned nor suspended, and its documents can not be deleted. Whoever /// may moderate is, and so is the owner of an elected contract whose declaration says so. + /// Under an elected declaration this is the interim's protection; once a charter is + /// seated, the leader and the active members of its team are protected instead, with the + /// owner when the declaration says so, as state says. pub fn protects(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool { self.may_moderate(owner_id, identity_id) || (owner_id == identity_id @@ -125,9 +132,9 @@ impl ContractModerators { .is_some_and(|elected| elected.owner_protected)) } - /// Whether every document transition of the document type is refused: an elected - /// declaration in its interim with nobody moderating blocks its moderated types until a - /// team is seated. + /// Whether every document transition of the document type is refused while no team is + /// seated: an elected declaration in its interim with nobody moderating blocks its + /// moderated types until one is. Whether one is, is state's to say. pub fn interim_blocks_document_type(&self, document_type_name: &str) -> bool { self.elected() .is_some_and(|elected| elected.interim_blocks_document_type(document_type_name)) @@ -141,6 +148,11 @@ impl ContractModerators { /// /// The team is about earnings, not authority: an owner who is not on it still may /// moderate ([`Self::may_moderate`]). + /// + /// Like [`Self::may_moderate`] and [`Self::protects`], this reads the config alone, so for + /// an elected contract it describes the interim only. Once a charter is seated its claim of + /// the moderators pot is refused and its moderations too; who is on the seated team is read + /// from the moderation charters contract, which a client asks rather than this. pub fn team(&self, owner_id: &Identifier) -> BTreeSet { match self { ContractModerators::ContractOwner => BTreeSet::from([*owner_id]), diff --git a/packages/rs-dpp/src/data_contract/document_type/action_fees/agreement/mod.rs b/packages/rs-dpp/src/data_contract/document_type/action_fees/agreement/mod.rs index 2b84af0900b..61f44cdc475 100644 --- a/packages/rs-dpp/src/data_contract/document_type/action_fees/agreement/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/action_fees/agreement/mod.rs @@ -159,6 +159,20 @@ impl DocumentActionFeeAgreement { pub fn matches_declared(&self, pricing: ActionFeePricing, fee: DocumentActionFee) -> bool { self.pricing() == pricing && self.fee() == fee } + + /// Whether the agreement asks for a discount on the moderators part: it names the declared + /// pricing and owner part, and less than the declared moderators part. Only the seated + /// moderation charter of an elected contract gives one, on a document type the contract + /// moderates: its `moderatorsShare` of the declared part + /// ([`moderators_share_of`](crate::moderation_charter::moderators_share_of)). Judging it + /// needs that charter, which is state; this only says whether there is anything to judge. + pub fn discounts_moderators_of( + &self, + pricing: ActionFeePricing, + fee: DocumentActionFee, + ) -> bool { + self.pricing() == pricing && self.owner() == fee.owner && self.moderators() < fee.moderators + } } #[cfg(test)] @@ -175,6 +189,34 @@ mod tests { moderators: 100_000_000, }; + #[test] + fn should_ask_for_a_discount_only_below_the_declared_moderators_part() { + let agreed = |owner, moderators, pricing| { + DocumentActionFeeAgreement::for_declared_fee( + pricing, + DocumentActionFee { owner, moderators }, + KNOWN, + ) + }; + let fixed = ActionFeePricing::Fixed; + assert!(agreed(FEE.owner, FEE.moderators - 1, fixed).discounts_moderators_of(fixed, FEE)); + assert!(agreed(FEE.owner, 0, fixed).discounts_moderators_of(fixed, FEE)); + // The declared amounts are no discount, nor is anything that also moves the owner + // part or the pricing, nor a higher moderators part. + for (owner, moderators, pricing) in [ + (FEE.owner, FEE.moderators, fixed), + (FEE.owner, FEE.moderators + 1, fixed), + (FEE.owner - 1, FEE.moderators - 1, fixed), + ( + FEE.owner, + FEE.moderators - 1, + ActionFeePricing::FeeMultiplier, + ), + ] { + assert!(!agreed(owner, moderators, pricing).discounts_moderators_of(fixed, FEE)); + } + } + #[test] fn should_tolerate_a_fee_multiplier_up_to_the_stated_increase() { assert!(KNOWN.tolerates(1000)); diff --git a/packages/rs-dpp/src/data_contract/document_type/action_fees/mod.rs b/packages/rs-dpp/src/data_contract/document_type/action_fees/mod.rs index 08f1cf675ac..bbcb80f0963 100644 --- a/packages/rs-dpp/src/data_contract/document_type/action_fees/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/action_fees/mod.rs @@ -112,6 +112,9 @@ impl ContractFeePot { /// The identities a payout of this pot of `contract` goes to: the contract owner for the /// owner pot, the contract's moderation team for the moderators pot, which is empty for a /// contract that declares no moderation. Only a recipient may claim the pot. + /// + /// For an elected contract this is the interim's team, which may claim the moderators pot + /// only until a charter is seated on the contract; the claim reads whether one is. pub fn recipients(&self, contract: &DataContract) -> BTreeSet { let owner_id = contract.owner_id(); match self { diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index b9d02082343..1978313e69c 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -370,6 +370,7 @@ impl ErrorWithCode for StateError { Self::ReferencedIdentityKeyRequirementNotMetError(_) => 40136, Self::ReferencedDocumentLookupInvalidError(_) => 40137, Self::ReferencedDocumentListInvalidError(_) => 40138, + Self::DocumentActionFeeModeratorsShareMismatchError(_) => 40139, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, @@ -490,6 +491,8 @@ impl ErrorWithCode for StateError { // Contract moderation team errors: 41200-41299 Self::ContractModeratedDocumentTypeNotYetUsableError(_) => 41200, + Self::ContractModerationAbilityNotGrantedError(_) => 41201, + Self::ModerationCharterAddedModeratorLimitReachedError(_) => 41202, } } } diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_moderation_ability_not_granted_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_moderation_ability_not_granted_error.rs new file mode 100644 index 00000000000..37a092efd62 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_moderation_ability_not_granted_error.rs @@ -0,0 +1,80 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::data_contract::config::moderation::ModerationAbility; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// A moderation action by the seated team of an elected contract that the contract's +/// declaration does not give the team: a ban, a suspension or a warning (or lifting one) when +/// no moderated document type carries the ability, or a document deletion or restore on a type +/// that does not carry `deleteDocuments`. The team acts with the declaration's abilities and no +/// others. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "The elected moderation declaration of contract {} does not give its seated team the {} ability{}", + contract_id, + ability, + document_type_name.as_ref().map(|name| format!(" on document type {name}")).unwrap_or_default() +)] +#[platform_serialize(unversioned)] +pub struct ContractModerationAbilityNotGrantedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + ability: ModerationAbility, + document_type_name: Option, +} + +impl ContractModerationAbilityNotGrantedError { + /// `document_type_name` is the type a deletion or a restore names, `None` for an action on + /// a list, which is contract-wide. + pub fn new( + contract_id: Identifier, + ability: ModerationAbility, + document_type_name: Option, + ) -> Self { + Self { + contract_id, + ability, + document_type_name, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn ability(&self) -> ModerationAbility { + self.ability + } + + pub fn document_type_name(&self) -> Option<&str> { + self.document_type_name.as_deref() + } +} + +impl From for ConsensusError { + fn from(err: ContractModerationAbilityNotGrantedError) -> Self { + Self::StateError(StateError::ContractModerationAbilityNotGrantedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs index ed29fe122b4..3bcf056ee76 100644 --- a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs @@ -4,6 +4,7 @@ mod contract_fee_claim_not_allowed_error; mod contract_fees_already_claimed_this_epoch_error; mod contract_fees_nothing_to_claim_error; mod contract_moderated_document_type_not_yet_usable_error; +mod contract_moderation_ability_not_granted_error; mod contract_moderation_counterparty_barred_error; mod contract_moderation_not_enabled_error; mod contract_moderation_target_not_allowed_error; @@ -22,6 +23,7 @@ mod document_restore_hash_mismatch_error; mod document_restore_window_elapsed_error; mod document_type_not_deletable_by_moderators_error; mod identity_not_contract_moderator_error; +mod moderation_charter_added_moderator_limit_reached_error; pub use contract_document_already_restored_error::*; pub use contract_document_removal_not_found_error::*; @@ -29,6 +31,7 @@ pub use contract_fee_claim_not_allowed_error::*; pub use contract_fees_already_claimed_this_epoch_error::*; pub use contract_fees_nothing_to_claim_error::*; pub use contract_moderated_document_type_not_yet_usable_error::*; +pub use contract_moderation_ability_not_granted_error::*; pub use contract_moderation_counterparty_barred_error::*; pub use contract_moderation_not_enabled_error::*; pub use contract_moderation_target_not_allowed_error::*; @@ -47,3 +50,4 @@ pub use document_restore_hash_mismatch_error::*; pub use document_restore_window_elapsed_error::*; pub use document_type_not_deletable_by_moderators_error::*; pub use identity_not_contract_moderator_error::*; +pub use moderation_charter_added_moderator_limit_reached_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/moderation_charter_added_moderator_limit_reached_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/moderation_charter_added_moderator_limit_reached_error.rs new file mode 100644 index 00000000000..b7c99047220 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/moderation_charter_added_moderator_limit_reached_error.rs @@ -0,0 +1,75 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// An `addedModerator` of the moderation charters contract for a seated charter that already +/// has as many additions as its target contract's elected declaration allows +/// (`maxAddedModerators`). Additions ever filed count, so a removal frees no slot. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Elected charter {} already has the {} added moderators contract {} allows", + elected_charter_id, + max_added_moderators, + target_contract_id +)] +#[platform_serialize(unversioned)] +pub struct ModerationCharterAddedModeratorLimitReachedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + elected_charter_id: Identifier, + target_contract_id: Identifier, + max_added_moderators: u16, +} + +impl ModerationCharterAddedModeratorLimitReachedError { + pub fn new( + elected_charter_id: Identifier, + target_contract_id: Identifier, + max_added_moderators: u16, + ) -> Self { + Self { + elected_charter_id, + target_contract_id, + max_added_moderators, + } + } + + pub fn elected_charter_id(&self) -> Identifier { + self.elected_charter_id + } + + pub fn target_contract_id(&self) -> Identifier { + self.target_contract_id + } + + pub fn max_added_moderators(&self) -> u16 { + self.max_added_moderators + } +} + +impl From for ConsensusError { + fn from(err: ModerationCharterAddedModeratorLimitReachedError) -> Self { + Self::StateError(StateError::ModerationCharterAddedModeratorLimitReachedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/document/document_action_fee_moderators_share_mismatch_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/document_action_fee_moderators_share_mismatch_error.rs new file mode 100644 index 00000000000..80b2b43aef1 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/document_action_fee_moderators_share_mismatch_error.rs @@ -0,0 +1,97 @@ +use crate::balances::credits::Credits; +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// The transition's action fee agreement names less than the moderators part a document type of +/// an elected contract declares, and that is not the discount the contract's seated moderation +/// charter gives: its `moderatorsShare` of the declared part, rounded down. With no seated +/// charter there is no discount (`moderators_share` is `None`). The signer agrees to the declared +/// part, or to the share the seated charter takes. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Document {} of type {} declares a moderators fee of {} credits; the transition agreed to {}, which is not {}", + action, + document_type_name, + declared_moderators, + agreed_moderators, + moderators_share.map(|share| format!("the seated moderation charter's {share}% share of it")).unwrap_or_else(|| "discounted: the contract has no seated moderation charter".to_string()) +)] +#[platform_serialize(unversioned)] +pub struct DocumentActionFeeModeratorsShareMismatchError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_type_name: String, + action: String, + declared_moderators: Credits, + agreed_moderators: Credits, + moderators_share: Option, +} + +impl DocumentActionFeeModeratorsShareMismatchError { + /// `moderators_share` is the percentage the contract's seated charter takes, `None` when no + /// charter is seated. + pub fn new( + document_type_name: String, + action: String, + declared_moderators: Credits, + agreed_moderators: Credits, + moderators_share: Option, + ) -> Self { + Self { + document_type_name, + action, + declared_moderators, + agreed_moderators, + moderators_share, + } + } + + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } + + pub fn action(&self) -> &str { + &self.action + } + + pub fn declared_moderators(&self) -> Credits { + self.declared_moderators + } + + pub fn agreed_moderators(&self) -> Credits { + self.agreed_moderators + } + + pub fn moderators_share(&self) -> Option { + self.moderators_share + } +} + +impl From for ConsensusError { + fn from(err: DocumentActionFeeModeratorsShareMismatchError) -> Self { + Self::StateError(StateError::DocumentActionFeeModeratorsShareMismatchError( + err, + )) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index f6e6a6cd72a..a9bcefd1d3e 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -1,5 +1,6 @@ pub mod document_action_fee_agreement_mismatch_error; pub mod document_action_fee_agreement_not_set_error; +pub mod document_action_fee_moderators_share_mismatch_error; pub mod document_action_fee_multiplier_not_tolerated_error; pub mod document_already_present_error; pub mod document_contest_currently_locked_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 36900800fe6..792496ee8b6 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -12,7 +12,8 @@ use crate::consensus::state::shielded::invalid_anchor_error::InvalidAnchorError; use crate::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use crate::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; use crate::consensus::state::contract_moderation::{ - ContractModeratedDocumentTypeNotYetUsableError, + ContractModeratedDocumentTypeNotYetUsableError, ContractModerationAbilityNotGrantedError, + ModerationCharterAddedModeratorLimitReachedError, ContractModerationNotEnabledError, ContractModerationTargetNotAllowedError, ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, ContractFeesNothingToClaimError, ContractModerationCounterpartyBarredError, @@ -35,6 +36,7 @@ use crate::consensus::state::data_contract::data_contract_config_update_error::D use crate::consensus::state::data_contract::data_contract_is_readonly_error::DataContractIsReadonlyError; use crate::consensus::state::data_trigger::DataTriggerError; use crate::consensus::state::document::document_action_fee_agreement_mismatch_error::DocumentActionFeeAgreementMismatchError; +use crate::consensus::state::document::document_action_fee_moderators_share_mismatch_error::DocumentActionFeeModeratorsShareMismatchError; use crate::consensus::state::document::document_action_fee_agreement_not_set_error::DocumentActionFeeAgreementNotSetError; use crate::consensus::state::document::document_action_fee_multiplier_not_tolerated_error::DocumentActionFeeMultiplierNotToleratedError; use crate::consensus::state::document::document_already_present_error::DocumentAlreadyPresentError; @@ -602,6 +604,18 @@ pub enum StateError { // References to an element of a list of a referenced document (protocol version 14). #[error(transparent)] ReferencedDocumentListInvalidError(ReferencedDocumentListInvalidError), + + // Elected moderation teams moderating from their seated charter (protocol version 14). + #[error(transparent)] + ContractModerationAbilityNotGrantedError(ContractModerationAbilityNotGrantedError), + + #[error(transparent)] + ModerationCharterAddedModeratorLimitReachedError( + ModerationCharterAddedModeratorLimitReachedError, + ), + + #[error(transparent)] + DocumentActionFeeModeratorsShareMismatchError(DocumentActionFeeModeratorsShareMismatchError), } impl From for ConsensusError { @@ -615,7 +629,7 @@ mod tests { use super::*; use crate::consensus::state::contract_moderation::ContractModerationCounterpartyRole; use crate::consensus::state::identity::identity_public_key_limit_not_set_error::KeyLimit; - use crate::data_contract::config::moderation::ContractModerationList; + use crate::data_contract::config::moderation::{ContractModerationList, ModerationAbility}; use crate::data_contract::document_type::action_fees::agreement::{ AgreedFeeMultiplier, DocumentActionFeeAgreement, }; @@ -639,7 +653,8 @@ mod tests { /// clients, which would then decode an existing error as a different one. /// These are the frozen discriminants of the first variant, of the variant /// that follows the document contest block (the one an insertion there - /// would shift first), and of the last four. + /// would shift first), and of the variants appended since, down to the + /// last one, which the test's final assertion pins. fn discriminant_of(error: StateError) -> u8 { let bytes = bincode::encode_to_vec(error, bincode::config::standard()) .expect("expected to encode the state error"); @@ -1213,8 +1228,7 @@ mod tests { )), 145 ); - // References to an element of a list of a referenced document (protocol version - // 14): the tail of the enum. + // References to an element of a list of a referenced document (protocol version 14). assert_eq!( discriminant_of(StateError::ReferencedDocumentListInvalidError( ReferencedDocumentListInvalidError::new( @@ -1225,5 +1239,37 @@ mod tests { )), 146 ); + // Elected moderation teams moderating from their seated charter (protocol version + // 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::ContractModerationAbilityNotGrantedError( + ContractModerationAbilityNotGrantedError::new( + group_id, + ModerationAbility::DeleteDocuments, + Some("post".to_string()), + ) + )), + 147 + ); + assert_eq!( + discriminant_of( + StateError::ModerationCharterAddedModeratorLimitReachedError( + ModerationCharterAddedModeratorLimitReachedError::new(group_id, identity_id, 2) + ) + ), + 148 + ); + assert_eq!( + discriminant_of(StateError::DocumentActionFeeModeratorsShareMismatchError( + DocumentActionFeeModeratorsShareMismatchError::new( + "post".to_string(), + "create".to_string(), + 100, + 50, + Some(60), + ) + )), + 149 + ); } } diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs index bfcda094b74..2186174f26c 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -20,15 +20,24 @@ //! The team that acts is the leader plus [`ElectedCharter::active_members`]: the elected //! members and the additions, less the removals. //! +//! Seating writes nothing. Awarding the contest for a target writes the winning +//! `electedCharter` to the contract's storage, the only one ever written there for that target +//! (contenders live in the contest, and in protocol version 14 a seat is never replaced), so the +//! charter seated on a contract is the one its `byTargetContract` index finds. The moderation +//! paths of the target read it from there: its team moderates, its proposal's +//! [`SubmittedCharter::moderators_share`] discounts the moderators part of an action fee +//! ([`moderators_share_of`]), and its additions are capped by the target's +//! `maxAddedModerators`. +//! //! The schema carries almost every rule through its keywords (references, lookups, key //! requirements, `distinctFrom`, `maxBytes` for the description's byte cap, and the //! `propertyConstraints` rule holding the reward split to 100). What it cannot say is here: //! [`SubmittedCharter`] and [`ElectedCharter`] read the documents' properties, and -//! [`validate_submitted_charter`] reads a proposal for the path that seats a team. Nothing here -//! reads state. +//! [`validate_submitted_charter`] reads a proposal. Nothing here reads state. mod v0; +use crate::balances::credits::Credits; use crate::consensus::basic::moderation_charter::ModerationCharterMalformedFieldError; use crate::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; use crate::ProtocolError; @@ -63,6 +72,21 @@ 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; +/// The moderators part a seated charter's team charges for an action whose document type +/// declares `declared_moderators`: `moderators_share` percent of it, rounded down to the credit. +/// A document action on a type the target moderates may agree to exactly this amount instead +/// of the declared one; it is then charged this amount, and nothing else below the declared +/// amount is accepted. A share of 100 (or none declared) gives the declared amount itself. +pub fn moderators_share_of(declared_moderators: Credits, moderators_share: u8) -> Credits { + let share = (declared_moderators as u128) * (moderators_share as u128) + / (FULL_MODERATORS_SHARE as u128); + // At most 100 percent of an amount that fits, so the share fits; a stored share over 100 + // is refused by the schema, and is held at the declared amount if one ever got through. + Credits::try_from(share) + .unwrap_or(declared_moderators) + .min(declared_moderators) +} + /// The properties of the charter document types. pub mod property_names { pub const TARGET_CONTRACT_ID: &str = "targetContractId"; diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs index 83ea9158138..97fa7cc7af4 100644 --- a/packages/rs-dpp/src/moderation_charter/tests.rs +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -1,7 +1,8 @@ use super::{ - property_names, validate_submitted_charter, ElectedCharter, ModerationCharterRewardSplit, - SubmittedCharter, FULL_MODERATORS_SHARE, + moderators_share_of, property_names, validate_submitted_charter, ElectedCharter, + ModerationCharterRewardSplit, SubmittedCharter, FULL_MODERATORS_SHARE, }; +use crate::balances::credits::MAX_CREDITS; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use platform_value::{Identifier, Value}; @@ -184,3 +185,24 @@ fn should_combine_the_elected_members_the_additions_and_the_removals() { [id(2), id(3), id(4)].into() ); } + +#[test] +fn should_take_the_share_of_the_declared_moderators_part_rounded_down() { + assert_eq!(moderators_share_of(100_000_000, 60), 60_000_000); + assert_eq!( + moderators_share_of(100_000_000, FULL_MODERATORS_SHARE), + 100_000_000 + ); + assert_eq!(moderators_share_of(100_000_000, 0), 0); + // 33% of 1001 credits is 330.33 credits: the team charges 330, never a fraction. + assert_eq!(moderators_share_of(1_001, 33), 330); + assert_eq!(moderators_share_of(1, 99), 0); + // The largest declarable amount does not overflow. + assert_eq!( + moderators_share_of(MAX_CREDITS, FULL_MODERATORS_SHARE), + MAX_CREDITS + ); + assert_eq!(moderators_share_of(MAX_CREDITS, 50), MAX_CREDITS / 2); + // A share over 100 is refused by the schema; one that got through never raises the amount. + assert_eq!(moderators_share_of(1_000, 150), 1_000); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs index e8594675fa1..f7fa449a67b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs @@ -1,5 +1,8 @@ /// A module for validating asset locks pub mod asset_lock; +/// The seated moderation charter of an elected contract, read from the moderation charters +/// contract +pub(crate) mod seated_moderation_charter; pub mod validate_identity_exists; pub mod validate_identity_public_key_contract_bounds; pub mod validate_identity_public_key_ids_dont_exist_in_state; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs new file mode 100644 index 00000000000..f2105c837da --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/seated_moderation_charter/mod.rs @@ -0,0 +1,277 @@ +//! The seated moderation charter of an elected contract (protocol version 14). +//! +//! Seating writes nothing. An application for a contract's moderation seat is an +//! `electedCharter` create of the moderation charters system contract on its contested unique +//! index `byTargetContract`, keyed by the target contract, and awarding the contest writes the +//! winner's document to the charter contract's storage. It is the only `electedCharter` ever +//! written there for that target: contenders live in the contest until it is awarded, and in +//! protocol version 14 a seat is never replaced. So the charter seated on a contract is the one +//! `byTargetContract` finds, and its team is its owner, the leader, plus its `members` and the +//! `memberId` of every `addedModerator` for it, less the `memberId` of every `removedModerator` +//! for it ([`ElectedCharter::active_members`]). The moderation paths of the target read it from +//! there: there is no block-end seating hook and no copy under the moderated contract. +//! +//! Every read is a query of the charter contract, served from the system contract cache (it +//! can not change: its owner is the zero identity), whose processing cost is billed to the +//! transition that needed it. These helpers are only reached for a contract with an elected +//! moderation declaration, which exists from protocol version 14, so they carry no version of +//! their own, as `fetch_document_through_lookup` does not. + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::state_transitions::batch::fetch_document_with_id; +use dpp::block::epoch::Epoch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::moderation_charter::{ + property_names, ElectedCharter, ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, FULL_MODERATORS_SHARE, + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, +}; +use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; +use dpp::platform_value::Value; +use dpp::version::PlatformVersion; +use drive::drive::document::query::QueryDocumentsOutcomeV0Methods; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; + +/// The charter seated on an elected contract, as stored by the moderation charters contract +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SeatedModerationCharter { + /// The id of the `electedCharter` document + pub(crate) id: Identifier, + /// The leader: the charter's owner, who filed the proposal it runs on + pub(crate) leader_id: Identifier, + /// The charter + pub(crate) charter: ElectedCharter, +} + +/// The charter seated on the elected contract `target_contract_id`, `None` while no contest for +/// its seat was awarded. One query of the `byTargetContract` index, billed. +pub(crate) fn fetch_seated_moderation_charter( + drive: &Drive, + target_contract_id: Identifier, + epoch: &Epoch, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result, Error> { + let Some(document) = query_charter_documents( + drive, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + [(property_names::TARGET_CONTRACT_ID, target_contract_id)], + 1, + epoch, + execution_context, + transaction, + platform_version, + )? + .into_iter() + .next() else { + return Ok(None); + }; + // The schema admitted the document when it was filed, so it reads. + let charter = ElectedCharter::from_document_properties(document.properties()) + .into_data() + .map_err(|_| { + Error::Execution(ExecutionError::DriveIncoherence( + "a stored elected charter does not read as one", + )) + })?; + Ok(Some(SeatedModerationCharter { + id: document.id(), + leader_id: document.owner_id(), + charter, + })) +} + +impl SeatedModerationCharter { + /// Whether `identity_id` is on the seated team: the leader, or an active member. The + /// leader costs nothing more; an elected member costs a point read of its removal, and + /// anyone else a point read of its addition and, when there is one, of its removal. Both + /// types are unique on the charter and the member, so the team is never listed whole. + #[allow(clippy::too_many_arguments)] + pub(crate) fn seats( + &self, + drive: &Drive, + identity_id: Identifier, + epoch: &Epoch, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + if identity_id == self.leader_id { + return Ok(true); + } + let member_document = |document_type_name: &str, + execution_context: &mut StateTransitionExecutionContext| + -> Result { + Ok(!query_charter_documents( + drive, + document_type_name, + [ + (property_names::ELECTED_CHARTER_ID, self.id), + (property_names::MEMBER_ID, identity_id), + ], + 1, + epoch, + execution_context, + transaction, + platform_version, + )? + .is_empty()) + }; + let joined = self.charter.members.contains(&identity_id) + || member_document(ADDED_MODERATOR_DOCUMENT_TYPE_NAME, execution_context)?; + // A removal is final: whoever it names is off the team for good. + Ok(joined && !member_document(REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, execution_context)?) + } + + /// The share of each moderated type's declared moderators fee the team takes: its + /// proposal's `moderatorsShare`, the full amount when it declares none. One read of the + /// `submittedCharter` by id, billed. + pub(crate) fn fetch_moderators_share( + &self, + drive: &Drive, + epoch: &Epoch, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let contract = drive + .cache + .system_data_contracts + .load_moderation_charters(platform_version)?; + let document_type = + contract.document_type_for_name(SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME)?; + // The elected charter's reference proved the proposal when the charter was filed, and + // a proposal can not be deleted. + let proposal = fetch_document_with_id( + drive, + &contract, + document_type, + self.charter.submitted_charter_id, + epoch, + execution_context, + transaction, + platform_version, + )? + .ok_or(Error::Execution(ExecutionError::DriveIncoherence( + "the proposal of a seated charter is not stored", + )))?; + // The share alone is read: nothing else of the proposal decides the discount. The + // schema bounds it to 0 to 100 and leaves it out for the full amount. + let share = proposal + .properties() + .get_optional_integer::(property_names::MODERATORS_SHARE) + .map_err(|_| { + Error::Execution(ExecutionError::DriveIncoherence( + "a stored moderation charter proposal's share is not a percentage", + )) + })?; + Ok(share.unwrap_or(FULL_MODERATORS_SHARE)) + } +} + +/// How many `addedModerator` documents name the elected charter `elected_charter_id`, counted up +/// to `up_to`: the additions ever filed for it (the type is immutable and undeletable), which the +/// target's `maxAddedModerators` caps. One billed query of the `byElectedCharterMember` index, +/// limited to `up_to` documents, so the cost is bounded by the cap. +pub(crate) fn count_added_moderators( + drive: &Drive, + elected_charter_id: Identifier, + up_to: u16, + epoch: &Epoch, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result { + if up_to == 0 { + return Ok(0); + } + let additions = query_charter_documents( + drive, + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + [(property_names::ELECTED_CHARTER_ID, elected_charter_id)], + up_to, + epoch, + execution_context, + transaction, + platform_version, + )?; + // At most `up_to` documents come back. + Ok(u16::try_from(additions.len()).unwrap_or(up_to)) +} + +/// The documents of a moderation charters contract type whose identifier properties equal the +/// given ones, at most `limit`, with the processing cost of the query billed. +#[allow(clippy::too_many_arguments)] +fn query_charter_documents( + drive: &Drive, + document_type_name: &str, + equal_to: [(&str, Identifier); N], + limit: u16, + epoch: &Epoch, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result, Error> { + let contract = drive + .cache + .system_data_contracts + .load_moderation_charters(platform_version)?; + let document_type = contract.document_type_for_name(document_type_name)?; + let equal_clauses = equal_to + .into_iter() + .map(|(field, value)| { + ( + field.to_string(), + WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(value.to_buffer()), + }, + ) + }) + .collect(); + let drive_query = DriveDocumentQuery { + contract: &contract, + document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: None, + primary_key_equal_clause: None, + in_clauses: Vec::new(), + range_clause: None, + equal_clauses, + }, + offset: None, + limit: Some(limit), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + let outcome = drive.query_documents( + drive_query, + Some(epoch), + false, + transaction, + Some(platform_version.protocol_version), + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(FeeResult { + storage_fee: 0, + processing_fee: outcome.cost(), + fee_refunds: Default::default(), + removed_bytes_from_system: 0, + })); + Ok(outcome.documents_owned()) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs index 5876fa86998..593ed83d9d5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs @@ -9,7 +9,7 @@ mod transformer; // A moderator's document deletion (`contract_user_moderation`) reads the document the way a // document's own deletion does, billed the same. -pub(in crate::execution::validation::state_transition::state_transitions) use state::v0::fetch_documents::fetch_document_with_id; +pub(in crate::execution::validation::state_transition) use state::v0::fetch_documents::fetch_document_with_id; #[cfg(test)] mod tests; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/added_moderator_cap.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/added_moderator_cap.rs new file mode 100644 index 00000000000..8b2600a8f30 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/added_moderator_cap.rs @@ -0,0 +1,179 @@ +//! The cap on the members the leader of a seated moderation team adds after the election. +//! +//! An `addedModerator` of the moderation charters system contract names a seated +//! `electedCharter` and a member the leader adds from the proposal's join requests. The target +//! contract's elected declaration caps them: at most `maxAddedModerators` per charter, counting +//! the additions ever filed (the type is immutable and undeletable), so a removal frees no slot. +//! The schema can not count documents, so this is a consensus rule of the document create, +//! judged here once the create's own state validation passed: its references then proved the +//! charter is seated, that its leader is the writer, and that the member asked to join. +//! +//! It is state validation, which check tx does not run for a batch, so an addition over the +//! cap is admitted to the mempool and refused, paid, in the block, as a unique index conflict +//! is: counting the additions takes reads the batch transformer does not make. +//! +//! The additions are counted in committed state, plus those an earlier create of the same batch +//! was accepted for: the batch applies as one grove batch, so none of its creates is in state +//! yet. That second half is dormant while `max_transitions_in_documents_batch` is 1, as it is at +//! every protocol version; two additions in two batches of one block are counted from state, +//! since the second batch is validated after the first applied. +//! +//! Only a create of the charter contract's `addedModerator` takes this path. The charter +//! contract exists in state from protocol version 14 (genesis or the upgrade to 14), and a +//! document create against a contract that is not in state fails in the transformer, before this +//! loop, so no batch of an earlier protocol version reaches it: the shipped +//! `validate_state_v0` it hooks into behaves as it did for every such batch. + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::common::seated_moderation_charter::count_added_moderators; +use crate::execution::validation::state_transition::state_transitions::batch::fetch_document_with_id; +use crate::platform_types::platform::PlatformStateRef; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::contract_moderation::ModerationCharterAddedModeratorLimitReachedError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v2::DataContractConfigGettersV2; +use dpp::document::DocumentV0Getters; +use dpp::identifier::Identifier; +use dpp::moderation_charter::{ + property_names, ADDED_MODERATOR_DOCUMENT_TYPE_NAME, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + MODERATION_CHARTERS_CONTRACT_ID, +}; +use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{ + DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0, +}; +use std::collections::BTreeMap; + +/// The additions each seated charter was given by the creates of one batch accepted so far. One +/// per batch state validation. +#[derive(Default)] +pub(super) struct AddedModeratorCap { + accepted_in_batch: BTreeMap, +} + +impl AddedModeratorCap { + /// Refuses `create_action` when it is an `addedModerator` of the moderation charters + /// contract for a charter that already has as many additions as its target allows, and + /// counts it otherwise. Call it only for a create state validation accepted. A no-op, with + /// nothing read, for every other create. + /// + /// The reads are billed: the elected charter by id, its target contract, and the additions + /// of the charter, at most the cap of them. + pub(super) fn validate_and_record_create( + &mut self, + create_action: &DocumentCreateTransitionAction, + platform: &PlatformStateRef, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let base = create_action.base(); + if base.data_contract_id() != MODERATION_CHARTERS_CONTRACT_ID + || base.document_type_name() != ADDED_MODERATOR_DOCUMENT_TYPE_NAME + { + return Ok(SimpleConsensusValidationResult::new()); + } + let epoch = &block_info.epoch; + // The schema requires the charter, and the create's reference validation proved it. + let elected_charter_id = create_action + .data() + .get_identifier(property_names::ELECTED_CHARTER_ID) + .map_err(|_| { + Error::Execution(ExecutionError::CorruptedCodeExecution( + "an addedModerator that passed state validation names its elected charter", + )) + })?; + + let charters_contract = &base.data_contract_fetch_info_ref().contract; + let elected_charter = fetch_document_with_id( + platform.drive, + charters_contract, + charters_contract.document_type_for_name(ELECTED_CHARTER_DOCUMENT_TYPE_NAME)?, + elected_charter_id, + epoch, + execution_context, + transaction, + platform_version, + )? + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "the elected charter an addedModerator refers to was found by its reference", + )))?; + let target_contract_id = elected_charter + .properties() + .get_identifier(property_names::TARGET_CONTRACT_ID) + .map_err(|_| { + Error::Execution(ExecutionError::DriveIncoherence( + "a stored elected charter names its target contract", + )) + })?; + + // The fee this call returns is billed, never the one a cached fetch info carries, + // which depends on the cache. + let (fee, target_contract) = platform.drive.get_contract_with_fetch_info_and_fee( + target_contract_id.to_buffer(), + Some(epoch), + false, + transaction, + platform_version, + )?; + let fee = fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "fee must exist when fetching a contract with an epoch", + )))?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + // A charter is only filed for a contract that declares elected moderation (its + // `electionOpen` requirement), which is fixed at the contract's creation, and a contract + // is never deleted. + let max_added_moderators = target_contract + .as_ref() + .and_then(|fetch_info| { + fetch_info + .contract + .config() + .moderation() + .and_then(|moderation| moderation.moderators.elected()) + .map(|elected| elected.max_added_moderators) + }) + .ok_or(Error::Execution(ExecutionError::DriveIncoherence( + "the target of a stored elected charter declares elected moderation", + )))?; + + let accepted_in_batch = self + .accepted_in_batch + .get(&elected_charter_id) + .copied() + .unwrap_or_default(); + let room_in_state = max_added_moderators.saturating_sub(accepted_in_batch); + let in_state = count_added_moderators( + platform.drive, + elected_charter_id, + room_in_state, + epoch, + execution_context, + transaction, + platform_version, + )?; + if in_state.saturating_add(accepted_in_batch) >= max_added_moderators { + return Ok(SimpleConsensusValidationResult::new_with_error( + ModerationCharterAddedModeratorLimitReachedError::new( + elected_charter_id, + target_contract_id, + max_added_moderators, + ) + .into(), + )); + } + self.accepted_in_batch + .insert(elected_charter_id, accepted_in_batch.saturating_add(1)); + Ok(SimpleConsensusValidationResult::new()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs index b27a00d8ecf..75245a879ac 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/mod.rs @@ -34,6 +34,7 @@ use crate::execution::validation::state_transition::batch::action_validation::to use crate::execution::validation::state_transition::batch::action_validation::token::token_transfer_transition_action::TokenTransferTransitionActionValidation; use crate::execution::validation::state_transition::batch::action_validation::token::token_unfreeze_transition_action::TokenUnfreezeTransitionActionValidation; use crate::execution::validation::state_transition::batch::data_triggers::{data_trigger_bindings_list, DataTriggerExecutionContext, DataTriggerExecutor}; +use crate::execution::validation::state_transition::batch::state::v0::added_moderator_cap::AddedModeratorCap; use crate::execution::validation::state_transition::batch::state::v0::index_only_batch_entries::IndexOnlyBatchEntries; use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionActionAccessorsV0; use crate::platform_types::platform::{PlatformStateRef}; @@ -41,6 +42,7 @@ use crate::execution::validation::state_transition::state_transitions::batch::tr use crate::execution::validation::state_transition::ValidationMode; use crate::platform_types::platform_state::PlatformStateV0Methods; +mod added_moderator_cap; pub mod fetch_contender; pub mod fetch_documents; mod index_only_batch_entries; @@ -95,6 +97,12 @@ impl DocumentsBatchStateTransitionStateValidationV0 for BatchTransition { // silently replaces the first — see `index_only_batch_entries`. let mut index_only_batch_entries = IndexOnlyBatchEntries::default(); + // The additions to each seated moderation charter this batch was accepted for, which + // the charter's cap counts beside those in state. Only a create of the moderation + // charters contract's `addedModerator` is counted, and that contract is in state from + // protocol version 14 only, so no earlier batch takes this path. + let mut added_moderator_cap = AddedModeratorCap::default(); + // Next we need to validate the structure of all actions (this means with the data contract) for transition in state_transition_action.transitions_take() { let transition_validation_result = match &transition { @@ -383,6 +391,29 @@ impl DocumentsBatchStateTransitionStateValidationV0 for BatchTransition { )); continue; } + + // A seated moderation team's leader adds at most the target's + // `maxAddedModerators` members: a count the schema can not express. + let cap_result = added_moderator_cap.validate_and_record_create( + create_action, + platform, + block_info, + execution_context, + transaction, + platform_version, + )?; + if !cap_result.is_valid() { + validation_result.add_errors(cap_result.errors); + validated_transitions + .push(BatchedTransitionAction::BumpIdentityDataContractNonce( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action( + create_action.base(), + owner_id, + state_transition_action.user_fee_increase(), + ), + )); + continue; + } } validated_transitions.push(transition); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v2/mod.rs index cba296f7cdd..64761679622 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v2/mod.rs @@ -22,6 +22,7 @@ use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; +use crate::execution::validation::state_transition::common::seated_moderation_charter::fetch_seated_moderation_charter; use crate::execution::validation::state_transition::state_transitions::batch::transformer::v0::BatchTransitionTransformerV0; use crate::platform_types::platform::PlatformStateRef; use crate::platform_types::platform_state::PlatformStateV0Methods; @@ -173,6 +174,47 @@ impl DocumentsBatchStateTransitionStateValidationV2 for BatchTransition { } } + // A discounted moderators part (decentralized moderation teams): a transition on a + // document type an elected contract moderates may agree to less than the declared + // moderators part, and is judged against the share of the contract's seated charter. + // That share is read here, billed, once per contract, only for a transition that asks + // for less: an agreement to the declared amounts reads nothing. What is read is a + // charter lookup through the charter contract's `byTargetContract` index and a fetch of + // the proposal it runs on, which carries the share. The agreement is judged against it + // by advanced structure validation, and again on every recheck, which transforms anew. + // A result that already carries errors never reaches that judgement, so it reads + // nothing for it. + if let Some(action) = validation_result + .data + .as_mut() + .filter(|_| validation_result.errors.is_empty()) + { + let contract_ids = action.contracts_with_moderators_discounts(); + if !contract_ids.is_empty() { + let platform_version = platform.state.current_platform_version()?; + for contract_id in contract_ids { + let share = match fetch_seated_moderation_charter( + platform.drive, + contract_id, + &block_info.epoch, + execution_context, + tx, + platform_version, + )? { + None => None, + Some(charter) => Some(charter.fetch_moderators_share( + platform.drive, + &block_info.epoch, + execution_context, + tx, + platform_version, + )?), + }; + action.set_seated_moderators_share(contract_id, share); + } + } + } + Ok(validation_result.map(Into::into)) } } 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 01bc8fad7a7..b8f4cd4ecb5 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 @@ -33,7 +33,8 @@ pub(super) struct ContractModerationRefusal<'a> { pub(super) trait BatchTransitionContractModerationGate { /// Gates the document transitions of `owner_id` against one contract: on the interim /// block of an elected declaration that names no interim moderators, which refuses every - /// transition of a moderated document type until a team is seated, and on the contract's + /// transition of a moderated document type until a charter is seated on the contract (read + /// from the moderation charters contract), and on the contract's /// moderation lists. Returns the refusal when the block covers a transition, or when the /// signer is banned or under a live suspension and asks for anything but deletions; /// `None` to carry on with every transition. A suspension found lapsed is recorded in @@ -399,8 +400,9 @@ mod tests { /// An elected contract whose interim names nobody: a create on the moderated type is /// refused with its nonce bump, a create on another type passes, and the lists are read - /// once for the batch, for the transition that passes. A batch on the moderated type - /// alone reads nothing. + /// once for the batch, for the transition that passes. Whether a charter is seated is read + /// once too, since the block applies to one of them; a batch on the moderated type alone + /// reads that and nothing else. #[tokio::test] async fn should_block_the_moderated_types_of_an_elected_contract_in_its_interim() { use crate::execution::validation::state_transition::tests::setup_identity; @@ -517,7 +519,8 @@ mod tests { .collect() } - // Both types: the moderated one refused, the other passed, one read. + // Both types: the moderated one refused, the other passed; the charter lookup and the + // lists read. let document_transitions = by_type(&transitions); let mut execution_context = StateTransitionExecutionContext::default_for_platform_version(platform_version) @@ -545,9 +548,9 @@ mod tests { .map(|transition| transition.base().identity_contract_nonce()) .collect(); assert_eq!(passed, vec![2]); - assert_eq!(execution_context.operations_slice().len(), 1); + assert_eq!(execution_context.operations_slice().len(), 2); - // The moderated type alone: refused without a read. + // The moderated type alone: refused after the charter lookup, without reading the lists. let document_transitions = by_type(&transitions[..1]); let mut execution_context = StateTransitionExecutionContext::default_for_platform_version(platform_version) @@ -567,6 +570,6 @@ mod tests { .expect("expected the moderated type to be refused"); assert!(refusal.passed.is_empty()); assert_eq!(refusal.refused.errors.len(), 1); - assert!(execution_context.operations_slice().is_empty()); + assert_eq!(execution_context.operations_slice().len(), 1); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/v0/mod.rs index a7c74b4536c..abbc5a72063 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/v0/mod.rs @@ -3,6 +3,7 @@ use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; +use crate::execution::validation::state_transition::common::seated_moderation_charter::fetch_seated_moderation_charter; use crate::execution::validation::state_transition::state_transitions::batch::transformer::v0::contract_moderation_gate::ContractModerationRefusal; use crate::execution::validation::state_transition::state_transitions::batch::transformer::v0::BatchTransitionInternalTransformerV0; use dpp::block::block_info::BlockInfo; @@ -64,8 +65,9 @@ impl BatchTransitionContractModerationGateV0 for BatchTransition { /// An elected contract whose declaration names no interim moderators refuses, first, /// every transition of a document type it moderates until a team is seated /// (`ContractModeratedDocumentTypeNotYetUsableError`), deletions included: nothing of - /// those types was ever written. The lists are then read only for the transitions on the - /// other types, and not at all when nothing is left. + /// those types was ever written. Whether a charter is seated is read, billed, only when a + /// transition is on such a type; once one is, the block is over. The lists are then read + /// only for the transitions on the other types, and not at all when nothing is left. fn contract_moderation_gate_v0<'a>( drive: &Drive, block_info: &BlockInfo, @@ -96,10 +98,25 @@ impl BatchTransitionContractModerationGateV0 for BatchTransition { ) }; - // The interim block: the types an elected contract moderates wait for a team. + // The interim block: the types an elected contract moderates wait for a team, until + // a contest for its seat is awarded. Seating writes nothing under the contract, so the + // charter contract is asked whether one was, and only when the block would apply. + let interim_would_block = document_transitions + .keys() + .any(|document_type_name| moderation.interim_blocks_document_type(document_type_name)); + let interim_blocks = interim_would_block + && fetch_seated_moderation_charter( + drive, + data_contract_id, + &block_info.epoch, + execution_context, + transaction, + platform_version, + )? + .is_none(); let mut unblocked: BTreeMap<&'a String, Vec<&'a DocumentTransition>> = BTreeMap::new(); for (document_type_name, transitions) in document_transitions { - if moderation.interim_blocks_document_type(document_type_name) { + if interim_blocks && moderation.interim_blocks_document_type(document_type_name) { for transition in transitions { let refusal = failed( transition, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/state/v0/mod.rs index 68d75a0a242..ece48a435a4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/state/v0/mod.rs @@ -4,6 +4,7 @@ use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; +use crate::execution::validation::state_transition::common::seated_moderation_charter::fetch_seated_moderation_charter; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; @@ -13,6 +14,9 @@ use dpp::consensus::state::contract_moderation::{ ContractFeesNothingToClaimError, }; use dpp::consensus::ConsensusError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v2::DataContractConfigGettersV2; +use dpp::data_contract::document_type::action_fees::ContractFeePot; use dpp::fee::Credits; use dpp::prelude::{ConsensusValidationResult, Identifier}; use dpp::state_transition::contract_fee_claim_transition::accessors::ContractFeeClaimTransitionAccessorsV0; @@ -108,6 +112,32 @@ impl ContractFeeClaimStateTransitionStateValidationV0 for ContractFeeClaimTransi ContractFeeClaimNotAllowedError::new(contract_id, pot, claimant_id).into(), ); } + // The recipients of an elected contract's moderators pot are its interim team, who may + // claim it only until a charter is seated (decentralized moderation teams): from + // then on the pot is the seated team's, and it accumulates for that team, unsettled, + // as it does under an interim that names nobody. Whether one is seated is read, billed, + // only for an interim recipient. + let elected = contract_fetch_info + .contract + .config() + .moderation() + .is_some_and(|moderation| moderation.moderators.elected().is_some()); + if pot == ContractFeePot::Moderators + && elected + && fetch_seated_moderation_charter( + platform.drive, + contract_id, + &block_info.epoch, + execution_context, + tx, + platform_version, + )? + .is_some() + { + return refuse( + ContractFeeClaimNotAllowedError::new(contract_id, pot, claimant_id).into(), + ); + } let (fee, fee_pot) = platform.drive.fetch_contract_fee_pot_with_fee( contract_id, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs index 10de400402a..7b2b1bf0768 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs @@ -4,20 +4,24 @@ use crate::execution::types::execution_operation::{ValidationOperation, SHA256_B use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; +use crate::execution::validation::state_transition::common::seated_moderation_charter::{ + fetch_seated_moderation_charter, SeatedModerationCharter, +}; use crate::execution::validation::state_transition::common::validate_identity_exists::validate_identity_exists; use crate::execution::validation::state_transition::state_transitions::batch::fetch_document_with_id; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; +use dpp::block::epoch::Epoch; use dpp::consensus::basic::decode::DecodingError; use dpp::consensus::basic::document::{DataContractNotPresentError, InvalidDocumentTypeError}; use dpp::consensus::basic::BasicError; use dpp::consensus::state::contract_moderation::{ ContractDocumentAlreadyRestoredError, ContractDocumentRemovalNotFoundError, - ContractModerationNotEnabledError, ContractModerationTargetNotAllowedError, - ContractModerationTargetNotFoundError, ContractSuspensionNotInFutureError, - ContractUserAlreadyBannedError, ContractUserBannedError, ContractUserNotBannedError, - ContractUserNotSuspendedError, ContractUserNotWarnedError, + ContractModerationAbilityNotGrantedError, ContractModerationNotEnabledError, + ContractModerationTargetNotAllowedError, ContractModerationTargetNotFoundError, + ContractSuspensionNotInFutureError, ContractUserAlreadyBannedError, ContractUserBannedError, + ContractUserNotBannedError, ContractUserNotSuspendedError, ContractUserNotWarnedError, ContractUserWarningLimitReachedError, DocumentModerationWindowElapsedError, DocumentRestoreHashMismatchError, DocumentRestoreWindowElapsedError, DocumentTypeNotDeletableByModeratorsError, IdentityNotContractModeratorError, @@ -28,7 +32,7 @@ use dpp::consensus::ConsensusError; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::config::moderation::{ ContractDocumentRemoval, ContractDocumentRestoration, ContractModerationConfig, - ContractModerationList, ContractModerationStatus, + ContractModerationList, ContractModerationStatus, ElectedModerators, ModerationAbility, }; use dpp::data_contract::config::v2::DataContractConfigGettersV2; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; @@ -44,6 +48,7 @@ use dpp::state_transition::StateTransitionOwned; use dpp::util::hash::hash_double; use dpp::version::PlatformVersion; use drive::drive::contract::DataContractFetchInfo; +use drive::drive::Drive; use drive::grovedb::TransactionArg; use drive::state_transition_action::contract::contract_user_moderation::v0::{ ContractDocumentDeletionContext, ContractDocumentRestorationContext, @@ -73,11 +78,12 @@ impl ContractUserModerationStateTransitionStateValidationV0 for ContractUserMode /// A document deletion is checked by `transform_document_deletion_v0` and a document /// restore by `transform_document_restore_v0`. For the rest: /// reads the contract and the target's status and checks the moderation: the contract - /// keeps the list the action edits, the signer is its owner or one of its moderators, the - /// target is neither and exists, and the action fits the target's status (a warn fits - /// while the target carries fewer than `SystemLimits::max_contract_warnings_per_identity` - /// warnings). Every refusal, a contract that does not exist included, is paid for by - /// bumping the signer's contract nonce. + /// keeps the list the action edits, the signer moderates the contract (see [`Moderators`]: + /// on an elected contract with a seated charter, the charter's team, which must also hold + /// the ability the list needs), the target is not protected and exists, and the action + /// fits the target's status (a warn fits while the target carries fewer than + /// `SystemLimits::max_contract_warnings_per_identity` warnings). Every refusal, a contract + /// that does not exist included, is paid for by bumping the signer's contract nonce. /// /// The action carries what Drive needs of the target's status as read here (and for a /// warn the block time the warning is stamped with), so Drive edits the lists without @@ -192,11 +198,37 @@ impl ContractUserModerationStateTransitionStateValidationV0 for ContractUserMode } let owner_id = contract.owner_id(); - if !moderation.may_moderate(&owner_id, &moderator_id) { + let epoch = &block_info.epoch; + let moderators = Moderators::read( + moderation, + contract_id, + platform.drive, + epoch, + execution_context, + tx, + platform_version, + )?; + if !moderators.may_moderate( + owner_id, + moderator_id, + platform.drive, + epoch, + execution_context, + tx, + platform_version, + )? { return refuse( IdentityNotContractModeratorError::new(contract_id, moderator_id).into(), ); } + // The lists are contract-wide: a seated team uses one when the declaration gives it + // the ability on some moderated document type. + let ability = ability_of(list); + if moderators.lacks(ability, None) { + return refuse( + ContractModerationAbilityNotGrantedError::new(contract_id, ability, None).into(), + ); + } // Whoever the contract protects (the owner and the moderators, and the owner of an // elected contract whose declaration says so) cannot be put on a list. They can // be taken off one: a contract update may name as moderator an identity that already @@ -208,7 +240,17 @@ impl ContractUserModerationStateTransitionStateValidationV0 for ContractUserMode | ContractUserModerationAction::Suspend { .. } | ContractUserModerationAction::Warn { .. } ); - if adds_an_entry && moderation.protects(&owner_id, &target_id) { + if adds_an_entry + && moderators.protects( + owner_id, + target_id, + platform.drive, + epoch, + execution_context, + tx, + platform_version, + )? + { return refuse( ContractModerationTargetNotAllowedError::new(contract_id, target_id).into(), ); @@ -260,10 +302,10 @@ impl ContractUserModerationStateTransitionStateValidationV0 for ContractUserMode } /// A document deletion: the document type exists and says moderators may delete its -/// documents, the signer is the contract's owner or one of its moderators, the document -/// exists, it is not the owner's or a moderator's, and it was last modified within the window -/// the document type gives its moderators, if it gives one. Every refusal is paid for by -/// bumping the signer's contract nonce. +/// documents, the signer moderates the contract (see [`Moderators`]: a seated team must also +/// hold `deleteDocuments` on the type), the document exists, its owner is not protected, and it +/// was last modified within the window the document type gives its moderators, if it gives one. +/// Every refusal is paid for by bumping the signer's contract nonce. /// /// The action carries the contract and the document's owner, so Drive deletes the document /// and writes its record without reading again. Nothing the document type prices is charged, neither @@ -318,9 +360,37 @@ fn transform_document_deletion_v0( }; let owner_id = contract.owner_id(); - if !moderation.may_moderate(&owner_id, &moderator_id) { + let epoch = &block_info.epoch; + let moderators = Moderators::read( + moderation, + contract_id, + platform.drive, + epoch, + execution_context, + tx, + platform_version, + )?; + if !moderators.may_moderate( + owner_id, + moderator_id, + platform.drive, + epoch, + execution_context, + tx, + platform_version, + )? { return refuse(IdentityNotContractModeratorError::new(contract_id, moderator_id).into()); } + if moderators.lacks(ModerationAbility::DeleteDocuments, Some(document_type_name)) { + return refuse( + ContractModerationAbilityNotGrantedError::new( + contract_id, + ModerationAbility::DeleteDocuments, + Some(document_type_name.to_string()), + ) + .into(), + ); + } let Some(document) = fetch_document_with_id( platform.drive, @@ -339,9 +409,18 @@ fn transform_document_deletion_v0( }; // What protects the owner and the moderators from a ban protects their documents: - // the owner demotes a moderator by a contract update before deleting what it wrote. + // the owner demotes a moderator by a contract update before deleting what it wrote, and the + // leader of a seated team removes a member before anyone deletes what it wrote. let document_owner_id = document.owner_id(); - if moderation.protects(&owner_id, &document_owner_id) { + if moderators.protects( + owner_id, + document_owner_id, + platform.drive, + epoch, + execution_context, + tx, + platform_version, + )? { return refuse( ContractModerationTargetNotAllowedError::new(contract_id, document_owner_id).into(), ); @@ -426,7 +505,8 @@ fn transform_document_deletion_v0( } /// A document restore: the document type exists and says moderators may delete its -/// documents, the signer is the contract's owner or one of its moderators, the bytes decode +/// documents, the signer moderates the contract (see [`Moderators`]: a seated team must also +/// hold `deleteDocuments` on the type, which is what a restore undoes), the bytes decode /// under the type, the document has a removal record that is not yet restored, block time is /// within the restore window after the removal, the bytes hash to what the record holds, and /// no other document holds a value of one of the type's unique indexes. Every refusal is paid @@ -489,9 +569,37 @@ fn transform_document_restore_v0( }; let owner_id = contract.owner_id(); - if !moderation.may_moderate(&owner_id, &moderator_id) { + let epoch = &block_info.epoch; + let moderators = Moderators::read( + moderation, + contract_id, + platform.drive, + epoch, + execution_context, + tx, + platform_version, + )?; + if !moderators.may_moderate( + owner_id, + moderator_id, + platform.drive, + epoch, + execution_context, + tx, + platform_version, + )? { return refuse(IdentityNotContractModeratorError::new(contract_id, moderator_id).into()); } + if moderators.lacks(ModerationAbility::DeleteDocuments, Some(document_type_name)) { + return refuse( + ContractModerationAbilityNotGrantedError::new( + contract_id, + ModerationAbility::DeleteDocuments, + Some(document_type_name.to_string()), + ) + .into(), + ); + } // The bytes are the moderator's: whatever they fail to decode as is a refusal, never an // execution error, which would fail the block. @@ -622,6 +730,139 @@ fn transform_document_restore_v0( )) } +/// Who moderates a contract, as state has it now. +/// +/// Until an elected contract has a seated charter, the moderators its declaration names do: the +/// merged kinds, or the elected declaration's interim ([`ContractModerationConfig::may_moderate`] +/// and [`ContractModerationConfig::protects`]). Once a contest for its seat was awarded, the +/// team of the seated charter does, and only it (decentralized moderation teams): the +/// leader and the active members moderate, with the abilities the declaration gives the team +/// and no others, and they are protected, with the owner when the declaration says so. Interim +/// moderators are then neither. +enum Moderators<'a> { + /// The moderators the declaration names + Declared(&'a ContractModerationConfig), + /// The team of the charter seated on an elected contract + Seated { + elected: &'a ElectedModerators, + charter: SeatedModerationCharter, + }, +} + +impl<'a> Moderators<'a> { + /// Who moderates a contract declaring `moderation`: for an elected declaration, whether a + /// charter is seated is read (billed); nothing is read for the merged kinds. + #[allow(clippy::too_many_arguments)] + fn read( + moderation: &'a ContractModerationConfig, + contract_id: Identifier, + drive: &Drive, + epoch: &Epoch, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let Some(elected) = moderation.moderators.elected() else { + return Ok(Moderators::Declared(moderation)); + }; + Ok( + match fetch_seated_moderation_charter( + drive, + contract_id, + epoch, + execution_context, + tx, + platform_version, + )? { + None => Moderators::Declared(moderation), + Some(charter) => Moderators::Seated { elected, charter }, + }, + ) + } + + /// Whether `identity_id` may moderate the contract owned by `owner_id`. For a seated team, + /// whether it is the leader or an active member, at most two point reads, billed. + #[allow(clippy::too_many_arguments)] + fn may_moderate( + &self, + owner_id: Identifier, + identity_id: Identifier, + drive: &Drive, + epoch: &Epoch, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match self { + Moderators::Declared(moderation) => { + Ok(moderation.may_moderate(&owner_id, &identity_id)) + } + Moderators::Seated { charter, .. } => charter.seats( + drive, + identity_id, + epoch, + execution_context, + tx, + platform_version, + ), + } + } + + /// Whether `identity_id` is protected from moderation on the contract owned by `owner_id`: + /// it can not be put on a list, and its documents can not be deleted. For a seated team, + /// the owner when the declaration protects it, and the leader and the active members. + #[allow(clippy::too_many_arguments)] + fn protects( + &self, + owner_id: Identifier, + identity_id: Identifier, + drive: &Drive, + epoch: &Epoch, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match self { + Moderators::Declared(moderation) => Ok(moderation.protects(&owner_id, &identity_id)), + Moderators::Seated { elected, charter } => { + if identity_id == owner_id && elected.owner_protected { + return Ok(true); + } + charter.seats( + drive, + identity_id, + epoch, + execution_context, + tx, + platform_version, + ) + } + } + } + + /// Whether a seated team lacks `ability`: on `document_type_name` for a deletion or a + /// restore, on every moderated type for a list, which is contract-wide. The moderators a + /// declaration names hold every ability the contract backs. + fn lacks(&self, ability: ModerationAbility, document_type_name: Option<&str>) -> bool { + match self { + Moderators::Declared(_) => false, + Moderators::Seated { elected, .. } => match document_type_name { + Some(document_type_name) => !elected.allows(document_type_name, ability), + None => !elected.allows_on_any_type(ability), + }, + } + } +} + +/// The ability a seated team needs to edit `list`, putting an identity on it or taking one off. +fn ability_of(list: ContractModerationList) -> ModerationAbility { + match list { + ContractModerationList::Banlist => ModerationAbility::Ban, + ContractModerationList::Suspensions => ModerationAbility::Suspend, + ContractModerationList::Warnings => ModerationAbility::Warn, + } +} + /// The list the action edits, `None` for a document deletion, which edits none. fn list_of(action: &ContractUserModerationAction) -> Option { match action { 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 c143e41fb79..9dc7e8e255c 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 @@ -73,6 +73,8 @@ use simple_signer::signer::SimpleSigner; use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, BTreeSet}; +mod seated_team; + const DATA_CONTRACT_NOT_PRESENT: u32 = 10400; const CONTRACT_MODERATION_SELF_TARGET: u32 = 10901; const CONTRACT_MODERATION_REASON_TOO_LONG: u32 = 10903; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/seated_team.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/seated_team.rs new file mode 100644 index 00000000000..455d1cfeab2 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/seated_team.rs @@ -0,0 +1,1333 @@ +//! Elected moderation teams moderating from their stored charter (protocol version 14), through +//! the whole pipeline: a contest for the seat of an elected contract is awarded, and from then on +//! the seated charter's team moderates instead of the interim moderators, is protected, grows +//! within the target's `maxAddedModerators`, and may charge less than the declared moderators +//! fee. +//! +//! The proposal and the elected charter are filed through the pipeline, as their references +//! require; the join requests are written to Drive directly, since their key requirements and +//! envelope are covered where those keywords are. + +use super::*; +use crate::execution::check_tx::CheckTxLevel; +use crate::execution::check_tx::CheckTxLevel::Recheck; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::common::seated_moderation_charter::fetch_seated_moderation_charter; +use crate::execution::validation::state_transition::processor::state::StateTransitionStateValidation; +use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; +use crate::execution::validation::state_transition::ValidationMode; +use dpp::block::epoch::Epoch; +use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0; +use dpp::data_contract::document_type::action_fees::agreement::{ + AgreedFeeMultiplier, DocumentActionFeeAgreement, +}; +use dpp::data_contract::document_type::action_fees::{ + ActionFeePricing, ContractFeePot, DocumentActionFee, +}; +use dpp::document::DocumentV0; +use dpp::fee::fee_result::FeeResult; +use dpp::moderation_charter::{ + moderators_share_of, ElectedCharter, ModerationCharterRewardSplit, SubmittedCharter, + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + JOIN_REQUEST_DOCUMENT_TYPE_NAME, REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, +}; +use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; +use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; +use dpp::state_transition::batch_transition::BatchTransitionV0; +use dpp::state_transition::contract_fee_claim_transition::methods::ContractFeeClaimTransitionMethodsV0; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use dpp::version::DefaultForPlatformVersion; +use drive::drive::credit_pools::epochs::operations_factory::EpochOperations; +use drive::query::VotePollsByEndDateDriveQuery; +use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; +use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; +use drive::state_transition_action::StateTransitionAction; +use drive::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; +use drive::util::batch::GroveDbOpBatch; +use drive::util::object_size_info::DocumentInfo::DocumentRefInfo; +use drive::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use std::sync::Arc; + +const CONTRACT_MODERATION_ABILITY_NOT_GRANTED: u32 = 41201; +const MODERATION_CHARTER_ADDED_MODERATOR_LIMIT_REACHED: u32 = 41202; +const DOCUMENT_ACTION_FEE_MODERATORS_SHARE_MISMATCH: u32 = 40139; +const CONTRACT_FEE_CLAIM_NOT_ALLOWED: u32 = 41113; +const DOCUMENT_ACTION_FEE_AGREEMENT_MISMATCH: u32 = 40133; + +/// What creating a post costs on top of the gas: 0.0001 Dash for the contract owner and 0.001 +/// Dash for the moderation team. +const OWNER_PART: Credits = 10_000_000; +const MODERATORS_PART: Credits = 100_000_000; +/// The percentage of the declared moderators part the team's proposal takes. +const MODERATORS_SHARE: u8 = 60; +/// How many members the leader may add after the election. +const MAX_ADDED_MODERATORS: u16 = 2; +/// When the suspensions of these tests end: after the award too, which the mempool judges +/// against, the award's block time being the last committed one. +const LATER: TimestampMillis = 4_000_000_000_000; + +/// A document type whose creation charges a fee priced by the fee multiplier, moderated with +/// bans only. +const REPLY: &str = "reply"; +/// A document type whose creation charges a fixed fee, not moderated. +const NOTE: &str = "note"; + +/// An elected declaration keeping all three lists, moderating `post` with `abilities` and +/// `reply` with bans, with `interim` until a team is seated, room for `MAX_ADDED_MODERATORS` +/// additions and the owner protected from the team when `owner_protected` +fn elected_posts( + interim: InterimModerators, + abilities: &[ModerationAbility], + owner_protected: bool, +) -> ContractModerationConfig { + ContractModerationConfig { + banlist: true, + suspensions: true, + warnings: true, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: 1_209_600, + election_delay: None, + max_added_moderators: MAX_ADDED_MODERATORS, + moderated_document_types: BTreeMap::from([ + (POST.to_string(), abilities.iter().copied().collect()), + (REPLY.to_string(), BTreeSet::from([ModerationAbility::Ban])), + ]), + interim, + owner_protected, + })), + } +} + +/// Every ability on `post` +const ALL_ABILITIES: [ModerationAbility; 4] = [ + ModerationAbility::Ban, + ModerationAbility::Suspend, + ModerationAbility::Warn, + ModerationAbility::DeleteDocuments, +]; + +/// An agreement to a post's creation fee naming `moderators` for the moderators part +fn agreeing_to(moderators: Credits) -> DocumentActionFeeAgreement { + DocumentActionFeeAgreement::for_declared_fee( + ActionFeePricing::Fixed, + DocumentActionFee { + owner: OWNER_PART, + moderators, + }, + AgreedFeeMultiplier { + known_permille: 1_000, + increase_tolerance_percent: 0, + }, + ) +} + +/// The moderators part the seated charter's share gives +fn discounted() -> Credits { + moderators_share_of(MODERATORS_PART, MODERATORS_SHARE) +} + +/// A contract with an elected declaration and a team on its way: the leader filed a proposal +/// and put it to the vote with one member, and three more identities asked to join it. The +/// contest is open until `award` ends it. +struct Team { + setup: Setup, + leader: Actor, + member: Actor, + joiners: [Actor; 3], + charters: Arc, + submitted_charter_id: Identifier, + elected_charter_id: Identifier, +} + +impl Team { + async fn new(interim: InterimModerators) -> Self { + Self::with_abilities(interim, &ALL_ABILITIES).await + } + + async fn with_abilities(interim: InterimModerators, abilities: &[ModerationAbility]) -> Self { + Self::build(interim, abilities, false).await + } + + async fn build( + interim: InterimModerators, + abilities: &[ModerationAbility], + owner_protected: bool, + ) -> Self { + let platform_version = PlatformVersion::latest(); + let mut setup = Setup::new_at_with( + Some(elected_posts(interim, abilities, owner_protected)), + platform_version, + |c| { + for (name, pricing) in [(POST, "fixed"), (REPLY, "feeMultiplier"), (NOTE, "fixed")] + { + add_document_type( + c, + name, + post_schema_with(platform_value!({ + "actionFees": { + "pricing": pricing, + "create": { "owner": OWNER_PART, "moderators": MODERATORS_PART }, + }, + })), + ) + } + }, + ) + .await; + let leader = Actor::new(&mut setup.platform, 21); + let member = Actor::new(&mut setup.platform, 22); + let joiners = [ + Actor::new(&mut setup.platform, 23), + Actor::new(&mut setup.platform, 24), + Actor::new(&mut setup.platform, 25), + ]; + let charters = setup + .platform + .drive + .cache + .system_data_contracts + .load_moderation_charters(platform_version) + .expect("expected the moderation charters contract"); + + let proposal = SubmittedCharter { + target_contract_id: setup.contract.id(), + description: "We keep the posts civil".to_string(), + reasons: vec![], + moderators_share: Some(MODERATORS_SHARE), + reward_split: ModerationCharterRewardSplit { + leader: 10, + equal: 40, + actions: 50, + }, + }; + let mut team = Self { + setup, + leader, + member, + joiners, + charters, + submitted_charter_id: Identifier::default(), + elected_charter_id: Identifier::default(), + }; + let (proposal, filing) = team + .charter_document( + &team.leader, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + proposal.to_document_properties(), + ) + .await; + team.process_and_commit(&filing); + team.submitted_charter_id = proposal.id(); + + for (index, actor) in [&team.member] + .into_iter() + .chain(team.joiners.iter()) + .enumerate() + { + team.write_join_request(actor, index as u8); + } + + let charter = ElectedCharter { + target_contract_id: team.setup.contract.id(), + submitted_charter_id: team.submitted_charter_id, + members: vec![team.member.id()], + }; + let (charter, application) = team + .charter_document( + &team.leader, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + charter.to_document_properties(), + ) + .await; + team.process_and_commit(&application); + team.elected_charter_id = charter.id(); + team + } + + /// A creation by `actor` of a moderation charters document, and the document it creates + async fn charter_document( + &self, + actor: &Actor, + document_type_name: &str, + properties: BTreeMap, + ) -> (Document, StateTransition) { + let platform_version = PlatformVersion::latest(); + let document_type = self + .charters + .document_type_for_name(document_type_name) + .expect("expected the charter document type"); + let nonce = actor.contract_nonce(); + let (entropy, document) = { + let mut rng = self.setup.rng.borrow_mut(); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + actor.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::MinDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document.set_properties(properties); + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected to set the document id"); + (entropy, document) + }; + let transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + &actor.key, + nonce, + 0, + None, + &actor.signer, + platform_version, + None, + ) + .await + .expect("expected to build the charter document creation"); + (document, transition) + } + + /// `actor`'s offer to join the proposal, written to Drive as a join request create leaves it + fn write_join_request(&self, actor: &Actor, discriminator: u8) { + let platform_version = PlatformVersion::latest(); + let document_type = self + .charters + .document_type_for_name(JOIN_REQUEST_DOCUMENT_TYPE_NAME) + .expect("expected the join request type"); + let document = Document::V0(DocumentV0 { + id: Identifier::from([0xA0 + discriminator; 32]), + owner_id: actor.id(), + properties: BTreeMap::from([ + ( + "submittedCharterId".to_string(), + Value::Identifier(self.submitted_charter_id.to_buffer()), + ), + ( + "recipientId".to_string(), + Value::Identifier(self.leader.id().to_buffer()), + ), + ("recipientKeyId".to_string(), Value::U32(3)), + ("senderKeyId".to_string(), Value::U32(3)), + ("encryptedMessage".to_string(), Value::Bytes(vec![7; 48])), + ]), + created_at: Some(BLOCK_TIME_MS), + ..Default::default() + }); + self.setup + .platform + .drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&document, None)), + owner_id: Some(actor.id().to_buffer()), + }, + contract: &self.charters, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("expected to write the join request"); + } + + fn process_and_commit(&self, transition: &StateTransition) { + let transaction = self.setup.platform.drive.grove.start_transaction(); + assert_success(&self.setup.process(transition, &transaction)); + self.setup.commit(transaction); + } + + /// Ends the contest for the seat as the block at the end of the join window does: with a + /// single contender, the charter is awarded there. + fn award(&self) { + let platform_version = PlatformVersion::latest(); + let platform = &self.setup.platform; + let (end_time, _) = VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("expected the end date of the contest") + .into_iter() + .next() + .expect("expected an open contest"); + let block_info = BlockInfo { + time_ms: end_time, + height: 2, + core_height: 42, + epoch: Default::default(), + }; + let mut platform_state = (**platform.state.load()).clone(); + platform_state.set_last_committed_block_info(Some( + ExtendedBlockInfoV0 { + basic_info: block_info, + app_hash: platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected the root hash"), + quorum_hash: [0u8; 32], + block_id_hash: [0u8; 32], + proposer_pro_tx_hash: [0u8; 32], + signature: [0u8; 96], + round: 0, + } + .into(), + )); + platform.state.store(Arc::new(platform_state)); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + platform + .check_for_ended_vote_polls( + &platform_state, + &platform_state, + &block_info, + Some(&transaction), + platform_version, + ) + .expect("expected to end the contest"); + self.setup.commit(transaction); + assert_eq!( + self.stored_charter(ELECTED_CHARTER_DOCUMENT_TYPE_NAME, self.elected_charter_id) + .map(|document| document.owner_id()), + Some(self.leader.id()), + "the contest is awarded to its single contender, the leader" + ); + } + + /// The charter contract's document of `document_type_name` stored at `document_id` + fn stored_charter( + &self, + document_type_name: &str, + document_id: Identifier, + ) -> Option { + let document_type = self + .charters + .document_type_for_name(document_type_name) + .expect("expected the charter document type"); + let query = DriveDocumentQuery::new_primary_key_single_item_query( + &self.charters, + document_type, + document_id, + ); + self.setup + .platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected to query the charter document") + .documents_owned() + .pop() + } + + /// The leader's addition of `actor` to the seated team + async fn addition_of(&self, actor: &Actor) -> StateTransition { + let properties = BTreeMap::from([ + ( + "electedCharterId".to_string(), + Value::Identifier(self.elected_charter_id.to_buffer()), + ), + ( + "submittedCharterId".to_string(), + Value::Identifier(self.submitted_charter_id.to_buffer()), + ), + ( + "memberId".to_string(), + Value::Identifier(actor.id().to_buffer()), + ), + ]); + self.charter_document(&self.leader, ADDED_MODERATOR_DOCUMENT_TYPE_NAME, properties) + .await + .1 + } + + /// The leader's removal of `actor` from the seated team + async fn removal_of(&self, actor: &Actor) -> StateTransition { + let properties = BTreeMap::from([ + ( + "electedCharterId".to_string(), + Value::Identifier(self.elected_charter_id.to_buffer()), + ), + ( + "memberId".to_string(), + Value::Identifier(actor.id().to_buffer()), + ), + ]); + self.charter_document( + &self.leader, + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + properties, + ) + .await + .1 + } + + /// A post by `actor`, agreeing to `agreement` (`None`: no agreement at all), and the post + async fn post_by( + &self, + actor: &Actor, + agreement: Option, + ) -> (Document, StateTransition) { + let (document, mut transitions) = self.one_document_by(actor, POST, &[agreement]).await; + let transition = transitions.pop().expect("expected the post creation"); + (document, transition) + } + + /// One document of `document_type_name` by `actor`, the same document under the same + /// nonce, created once for each of `agreements`: transitions that differ in their agreement + /// alone + async fn one_document_by( + &self, + actor: &Actor, + document_type_name: &str, + agreements: &[Option], + ) -> (Document, Vec) { + let platform_version = PlatformVersion::latest(); + let document_type = self + .setup + .contract + .document_type_for_name(document_type_name) + .expect("expected the document type"); + let nonce = actor.contract_nonce(); + let (entropy, document) = { + let mut rng = self.setup.rng.borrow_mut(); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + actor.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::MinDocumentFillSize, + platform_version, + ) + .expect("expected a random post"); + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected to set the post id"); + (entropy, document) + }; + let mut transitions = vec![]; + for agreement in agreements { + transitions.push( + BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + &actor.key, + nonce, + 0, + None, + &actor.signer, + platform_version, + Some(StateTransitionCreationOptions { + action_fee_agreement: *agreement, + ..Default::default() + }), + ) + .await + .expect("expected to build the post creation"), + ); + } + (document, transitions) + } + + /// A post by `actor` that agrees to the declared fee, created and committed + async fn posted_by(&self, actor: &Actor) -> Document { + let (post, create) = self + .post_by(actor, Some(agreeing_to(MODERATORS_PART))) + .await; + self.process_and_commit(&create); + post + } + + /// The credits in the contract's moderators pot + fn moderators_pot(&self, transaction: &Transaction) -> Credits { + self.setup + .platform + .drive + .fetch_contract_fee_pot( + self.setup.contract.id(), + ContractFeePot::Moderators, + Some(transaction), + PlatformVersion::latest(), + ) + .expect("expected to fetch the moderators pot") + .credits + } + + /// The codes check tx refuses `transition` with at `level` + fn check_tx_codes(&self, transition: &StateTransition, level: CheckTxLevel) -> Vec { + let version = PlatformVersion::latest(); + let state = self.setup.platform.state.load(); + let platform_ref = PlatformRef { + drive: &self.setup.platform.drive, + state: &state, + config: &self.setup.platform.config, + core_rpc: &self.setup.platform.core_rpc, + }; + let raw = transition + .serialize_to_bytes() + .expect("expected to serialize"); + self.setup + .platform + .check_tx(&raw, level, &platform_ref, version) + .expect("expected to check tx") + .errors + .iter() + .map(|error| error.code()) + .collect() + } +} + +/// The fees a processed transition paid, the action fee aside +fn fees_of(execution: &StateTransitionExecutionResult) -> FeeResult { + match execution { + StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. } => { + fee_result.clone() + } + StateTransitionExecutionResult::PaidConsensusError { actual_fees, .. } => { + actual_fees.clone() + } + other => panic!("expected a paid execution, got {other:?}"), + } +} + +/// The gas a processed transition paid, the action fee aside +fn gas_of(execution: &StateTransitionExecutionResult) -> Credits { + fees_of(execution).total_base_fee() +} + +/// The interim moderates until the contest for the seat is awarded; from then on the leader and +/// the elected member moderate, with every ability the declaration gives them, and the interim +/// moderators, the owner and everyone else are refused. What the interim did stands. +#[tokio::test] +async fn should_seat_the_winner_of_the_contest_and_let_its_team_moderate_instead_of_the_interim() { + let team = Team::new(InterimModerators::AppointedModerators( + [THE_MODERATOR].into(), + )) + .await; + let setup = &team.setup; + let interim = &setup.moderator; + + // Before the award: the interim moderates and the contender's team does not. + let transaction = setup.platform.drive.grove.start_transaction(); + let ban = setup.moderate(interim, ban_action(setup.user.id())).await; + assert_success(&setup.process(&ban, &transaction)); + for actor in [&team.leader, &team.member] { + let ban = setup.moderate(actor, ban_action(setup.stranger.id())).await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + } + setup.commit(transaction); + let post = team.posted_by(&setup.stranger).await; + + team.award(); + + // The team moderates: the leader and the elected member ban, suspend, warn, lift and + // delete, in a block and in the mempool. + let transaction = setup.platform.drive.grove.start_transaction(); + let suspend = setup + .moderate(&team.leader, suspend_action(setup.stranger.id(), LATER)) + .await; + assert!(setup.check_tx(&suspend).is_empty()); + assert_success(&setup.process(&suspend, &transaction)); + let warn = setup + .moderate(&team.member, warn_action(setup.stranger.id(), "calm down")) + .await; + assert_success(&setup.process(&warn, &transaction)); + let unsuspend = setup + .moderate(&team.member, unsuspend_action(setup.stranger.id())) + .await; + assert_success(&setup.process(&unsuspend, &transaction)); + let stored = setup + .stored_document(POST, post.id(), Some(&transaction)) + .expect("expected the post to be stored"); + let delete = setup + .moderate(&team.member, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&delete, &transaction)); + // The leader undoes the member's deletion: a restore needs the same ability. + let restore = setup + .moderate( + &team.leader, + restore_action(POST, setup.document_bytes(POST, &stored)), + ) + .await; + assert_success(&setup.process(&restore, &transaction)); + assert_eq!( + setup.stored_document(POST, post.id(), Some(&transaction)), + Some(stored) + ); + // What the interim did stands, and the team may undo it. + assert_eq!( + setup.status(setup.user.id(), Some(&transaction)).ban, + banned() + ); + let unban = setup + .moderate(&team.leader, unban_action(setup.user.id())) + .await; + assert_success(&setup.process(&unban, &transaction)); + let ban = setup + .moderate(&team.member, ban_action(setup.user.id())) + .await; + assert_success(&setup.process(&ban, &transaction)); + + // Nobody else does: the interim moderator, the owner and a stranger, in the mempool too. + for actor in [interim, &setup.owner, &team.joiners[0]] { + let ban = setup.moderate(actor, ban_action(setup.stranger.id())).await; + let mempool_errors = setup.check_tx(&ban); + assert_eq!( + mempool_errors.iter().map(|e| e.code()).collect::>(), + vec![IDENTITY_NOT_CONTRACT_MODERATOR] + ); + assert_paid_with_code( + &setup.process(&ban, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + let delete = setup.moderate(actor, delete_action(POST, post.id())).await; + assert_paid_with_code( + &setup.process(&delete, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + } +} + +/// The leader adds members from the join requests and removes members: an added member +/// moderates and is protected until it is removed, a removed elected member no longer +/// moderates and can be moderated, and the leader and the active members can be neither banned +/// nor have their documents deleted, while the interim moderators and the owner lost that. +#[tokio::test] +async fn should_follow_additions_and_removals_and_protect_the_team() { + let team = Team::new(InterimModerators::AppointedModerators( + [THE_MODERATOR].into(), + )) + .await; + let setup = &team.setup; + let added = &team.joiners[0]; + let member_post = team.posted_by(&team.member).await; + let added_post = team.posted_by(added).await; + team.award(); + + let transaction = setup.platform.drive.grove.start_transaction(); + // Before its addition the joiner does not moderate. + let ban = setup.moderate(added, ban_action(setup.user.id())).await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + assert_success(&setup.process(&team.addition_of(added).await, &transaction)); + let ban = setup.moderate(added, ban_action(setup.user.id())).await; + assert_success(&setup.process(&ban, &transaction)); + + // The leader, an elected member and an added member are protected, from a ban and from a + // deletion of what they wrote. + for (by, target) in [ + (&team.member, &team.leader), + (&team.leader, &team.member), + (&team.member, added), + ] { + let ban = setup.moderate(by, ban_action(target.id())).await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + CONTRACT_MODERATION_TARGET_NOT_ALLOWED, + ); + } + for post in [&member_post, &added_post] { + let delete = setup + .moderate(&team.leader, delete_action(POST, post.id())) + .await; + assert_paid_with_code( + &setup.process(&delete, &transaction), + CONTRACT_MODERATION_TARGET_NOT_ALLOWED, + ); + } + // The interim moderator and the owner (the declaration leaves it unprotected) are not. + for target in [&setup.moderator, &setup.owner] { + let warn = setup + .moderate(&team.leader, warn_action(target.id(), "noted")) + .await; + assert_success(&setup.process(&warn, &transaction)); + } + + // Removed, the added member and the elected member moderate no more, and are moderated. + for removed in [added, &team.member] { + assert_success(&setup.process(&team.removal_of(removed).await, &transaction)); + let unban = setup.moderate(removed, unban_action(setup.user.id())).await; + assert_paid_with_code( + &setup.process(&unban, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); + let warn = setup + .moderate(&team.leader, warn_action(removed.id(), "you left")) + .await; + assert_success(&setup.process(&warn, &transaction)); + } + let delete = setup + .moderate(&team.leader, delete_action(POST, added_post.id())) + .await; + assert_success(&setup.process(&delete, &transaction)); +} + +/// The target's `maxAddedModerators` caps the additions to a seated charter: the Nth passes, the +/// (N+1)th is refused, paid, and a removal frees no slot. +#[tokio::test] +async fn should_cap_the_members_a_leader_adds_and_free_no_slot_on_a_removal() { + let team = Team::new(InterimModerators::ContractOwner).await; + let setup = &team.setup; + team.award(); + + let transaction = setup.platform.drive.grove.start_transaction(); + let [first, second, third] = &team.joiners; + assert_success(&setup.process(&team.addition_of(first).await, &transaction)); + assert_success(&setup.process(&team.addition_of(second).await, &transaction)); + let over_the_cap = team.addition_of(third).await; + assert_paid_with_code( + &setup.process(&over_the_cap, &transaction), + MODERATION_CHARTER_ADDED_MODERATOR_LIMIT_REACHED, + ); + + assert_success(&setup.process(&team.removal_of(first).await, &transaction)); + let after_a_removal = team.addition_of(third).await; + assert_paid_with_code( + &setup.process(&after_a_removal, &transaction), + MODERATION_CHARTER_ADDED_MODERATOR_LIMIT_REACHED, + ); + // The third joiner never made it onto the team. + let ban = setup.moderate(third, ban_action(setup.user.id())).await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); +} + +/// A seated team acts with the declaration's abilities and no others; the interim moderators +/// before it acted with every ability the contract backs. +#[tokio::test] +async fn should_refuse_a_seated_team_an_ability_the_declaration_does_not_give_it() { + let team = + Team::with_abilities(InterimModerators::ContractOwner, &[ModerationAbility::Ban]).await; + let setup = &team.setup; + let post = team.posted_by(&setup.user).await; + + // The owner in the interim suspends and warns, which the declaration gives no team. + let transaction = setup.platform.drive.grove.start_transaction(); + let suspend = setup + .moderate(&setup.owner, suspend_action(setup.stranger.id(), LATER)) + .await; + assert_success(&setup.process(&suspend, &transaction)); + drop(transaction); + + team.award(); + let transaction = setup.platform.drive.grove.start_transaction(); + let ban = setup + .moderate(&team.leader, ban_action(setup.user.id())) + .await; + assert_success(&setup.process(&ban, &transaction)); + for action in [ + suspend_action(setup.stranger.id(), LATER), + warn_action(setup.stranger.id(), "no"), + clear_warnings_action(setup.stranger.id()), + delete_action(POST, post.id()), + // Refused before the bytes are even decoded. + restore_action(POST, vec![]), + ] { + let refused = setup.moderate(&team.member, action.clone()).await; + assert_paid_with_code( + &setup.process(&refused, &transaction), + CONTRACT_MODERATION_ABILITY_NOT_GRANTED, + ); + } +} + +/// A `notYetUsable` interim blocks the moderated type until the contest for the seat is +/// awarded, and not a block longer. +#[tokio::test] +async fn should_end_the_interim_block_once_a_charter_is_seated() { + let team = Team::new(InterimModerators::NotYetUsable).await; + let setup = &team.setup; + + let (_, blocked) = team + .post_by(&setup.user, Some(agreeing_to(MODERATORS_PART))) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + assert_paid_with_code( + &setup.process(&blocked, &transaction), + CONTRACT_MODERATED_DOCUMENT_TYPE_NOT_YET_USABLE, + ); + drop(transaction); + + team.award(); + assert!(setup.check_tx(&blocked).is_empty()); + let transaction = setup.platform.drive.grove.start_transaction(); + assert_success(&setup.process(&blocked, &transaction)); + // Nobody moderated in the interim; the seated team does now. + let ban = setup + .moderate(&team.leader, ban_action(setup.stranger.id())) + .await; + assert_success(&setup.process(&ban, &transaction)); +} + +/// An action agreeing to the declared moderators part pays it, before and after a charter is +/// seated, and reads no charter. One agreeing to the seated charter's share of it pays the +/// share, into the pot, and the gas of the charter lookup and the proposal fetch on top: from +/// the same state, it costs exactly those two reads more than the one paying the declared part. +#[tokio::test] +async fn should_charge_the_declared_moderators_part_without_a_read_and_a_discount_with_one() { + let team = Team::new(InterimModerators::ContractOwner).await; + let setup = &team.setup; + let platform_version = PlatformVersion::latest(); + // The same post, agreeing to the declared part or to the charter's share. + let (_, posts) = team + .one_document_by( + &setup.user, + POST, + &[ + Some(agreeing_to(MODERATORS_PART)), + Some(agreeing_to(discounted())), + ], + ) + .await; + let [full, at_the_share] = [&posts[0], &posts[1]]; + + let transaction = setup.platform.drive.grove.start_transaction(); + let pot_before = team.moderators_pot(&transaction); + assert_success(&setup.process(full, &transaction)); + assert_eq!( + team.moderators_pot(&transaction) - pot_before, + MODERATORS_PART + ); + drop(transaction); + + team.award(); + + // The declared part, with a charter seated: charged in full. + let transaction = setup.platform.drive.grove.start_transaction(); + let seated_full = setup.process(full, &transaction); + assert_success(&seated_full); + assert_eq!( + team.moderators_pot(&transaction) - pot_before, + MODERATORS_PART + ); + drop(transaction); + + // The charter's share, from the same state: charged the share. + assert_eq!(discounted(), 60_000_000); + let transaction = setup.platform.drive.grove.start_transaction(); + let user_before = setup.balance(setup.user.id(), Some(&transaction)); + let seated_discount = setup.process(at_the_share, &transaction); + assert_success(&seated_discount); + assert_eq!(team.moderators_pot(&transaction) - pot_before, discounted()); + assert_eq!( + user_before - setup.balance(setup.user.id(), Some(&transaction)), + gas_of(&seated_discount) + OWNER_PART + discounted() + ); + drop(transaction); + + // What the two reads cost, read on their own from the same state, in a transaction as a + // block reads them. + let mut reads = StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("expected an execution context"); + let transaction = setup.platform.drive.grove.start_transaction(); + let charter = fetch_seated_moderation_charter( + &setup.platform.drive, + setup.contract.id(), + &Default::default(), + &mut reads, + Some(&transaction), + platform_version, + ) + .expect("expected to read the seated charter") + .expect("expected a seated charter"); + assert_eq!(charter.leader_id, team.leader.id()); + assert_eq!( + charter + .fetch_moderators_share( + &setup.platform.drive, + &Default::default(), + &mut reads, + Some(&transaction), + platform_version, + ) + .expect("expected to read the share"), + MODERATORS_SHARE + ); + let mut read_fee = FeeResult::default(); + ValidationOperation::add_many_to_fee_result( + reads.operations_slice(), + &mut read_fee, + platform_version, + ) + .expect("expected to price the reads"); + assert!(read_fee.processing_fee > 0); + let (full_fees, discount_fees) = (fees_of(&seated_full), fees_of(&seated_discount)); + assert_eq!(discount_fees.storage_fee, full_fees.storage_fee); + assert_eq!( + discount_fees.processing_fee - full_fees.processing_fee, + read_fee.processing_fee, + "the declared part reads nothing; the share reads the charter and its proposal" + ); +} + +/// Less than the declared moderators part is the seated charter's share or nothing: a discount +/// before a charter is seated, one below the share and one above it are refused, paid and +/// charged no action fee, in a block and on every recheck of the mempool, which agrees with +/// the block on the share too. +#[tokio::test] +async fn should_refuse_any_discount_but_the_seated_charters_share_and_agree_on_recheck() { + let team = Team::new(InterimModerators::ContractOwner).await; + let setup = &team.setup; + let (_, at_the_share) = team + .post_by(&setup.user, Some(agreeing_to(discounted()))) + .await; + let refused_without_a_fee = |transition: &StateTransition| { + let transaction = setup.platform.drive.grove.start_transaction(); + let pot_before = team.moderators_pot(&transaction); + assert_paid_with_code( + &setup.process(transition, &transaction), + DOCUMENT_ACTION_FEE_MODERATORS_SHARE_MISMATCH, + ); + assert_eq!(team.moderators_pot(&transaction), pot_before); + }; + + // No charter is seated: no discount. + assert_eq!( + team.check_tx_codes(&at_the_share, CheckTxLevel::FirstTimeCheck), + vec![DOCUMENT_ACTION_FEE_MODERATORS_SHARE_MISMATCH] + ); + assert_eq!( + team.check_tx_codes(&at_the_share, Recheck), + vec![DOCUMENT_ACTION_FEE_MODERATORS_SHARE_MISMATCH] + ); + refused_without_a_fee(&at_the_share); + + team.award(); + assert_eq!( + team.check_tx_codes(&at_the_share, CheckTxLevel::FirstTimeCheck), + Vec::::new() + ); + assert_eq!( + team.check_tx_codes(&at_the_share, Recheck), + Vec::::new() + ); + for moderators in [discounted() - 1, discounted() + 1, 0] { + let (_, other_discount) = team + .post_by(&setup.user, Some(agreeing_to(moderators))) + .await; + assert_eq!( + team.check_tx_codes(&other_discount, Recheck), + vec![DOCUMENT_ACTION_FEE_MODERATORS_SHARE_MISMATCH], + "{moderators}" + ); + refused_without_a_fee(&other_discount); + } +} + +/// The interim team claims the moderators pot until a charter is seated, and not after: the pot +/// carries over to the seated team, unsettled. +#[tokio::test] +async fn should_stop_the_interim_team_claiming_the_moderators_pot_once_a_charter_is_seated() { + let team = Team::new(InterimModerators::ContractOwner).await; + let setup = &team.setup; + team.posted_by(&setup.user).await; + let claim = || async { + ContractFeeClaimTransition::try_from_identity_with_signer( + &setup.owner.identity, + &CRITICAL_KEY_ID, + setup.contract.id(), + ContractFeePot::Moderators, + setup.owner.contract_nonce(), + 0, + &setup.owner.signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("expected to build the claim") + }; + + let transaction = setup.platform.drive.grove.start_transaction(); + assert_success(&setup.process(&claim().await, &transaction)); + drop(transaction); + + team.award(); + let transaction = setup.platform.drive.grove.start_transaction(); + assert_paid_with_code( + &setup.process(&claim().await, &transaction), + CONTRACT_FEE_CLAIM_NOT_ALLOWED, + ); + assert_eq!(team.moderators_pot(&transaction), MODERATORS_PART); +} + +/// With `ownerProtected`, a seated team protects the contract owner as it protects its own +/// members, though the owner is not one: it can not be banned and its documents can not be +/// deleted, and it does not moderate. +#[tokio::test] +async fn should_protect_the_owner_from_a_seated_team_when_the_declaration_says_so() { + let team = Team::build(InterimModerators::ContractOwner, &ALL_ABILITIES, true).await; + let setup = &team.setup; + let owner_post = team.posted_by(&setup.owner).await; + team.award(); + + let transaction = setup.platform.drive.grove.start_transaction(); + let ban = setup + .moderate(&team.leader, ban_action(setup.owner.id())) + .await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + CONTRACT_MODERATION_TARGET_NOT_ALLOWED, + ); + let delete = setup + .moderate(&team.member, delete_action(POST, owner_post.id())) + .await; + assert_paid_with_code( + &setup.process(&delete, &transaction), + CONTRACT_MODERATION_TARGET_NOT_ALLOWED, + ); + let ban = setup + .moderate(&setup.owner, ban_action(setup.user.id())) + .await; + assert_paid_with_code( + &setup.process(&ban, &transaction), + IDENTITY_NOT_CONTRACT_MODERATOR, + ); +} + +/// A discount is the seated charter's to give on the types the contract moderates, and only +/// there: on a type it does not moderate an agreement to less is the plain mismatch. On a fee +/// priced by the fee multiplier the share applies to the declared part, and the epoch's +/// multiplier to the share. +#[tokio::test] +async fn should_discount_only_a_moderated_type_and_scale_the_share_by_the_fee_multiplier() { + let team = Team::new(InterimModerators::ContractOwner).await; + let setup = &team.setup; + team.award(); + + let (_, note) = team + .one_document_by(&setup.user, NOTE, &[Some(agreeing_to(discounted()))]) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + assert_paid_with_code( + &setup.process(¬e[0], &transaction), + DOCUMENT_ACTION_FEE_AGREEMENT_MISMATCH, + ); + drop(transaction); + + // The epoch's fee multiplier is 1.5. + let mut batch = GroveDbOpBatch::new(); + batch.push( + Epoch::new(0) + .expect("expected epoch 0") + .update_fee_multiplier_operation(1_500), + ); + setup + .platform + .drive + .grove_apply_batch(batch, false, None, &PlatformVersion::latest().drive) + .expect("expected to set the fee multiplier of epoch 0"); + let agreement = DocumentActionFeeAgreement::for_declared_fee( + ActionFeePricing::FeeMultiplier, + DocumentActionFee { + owner: OWNER_PART, + moderators: discounted(), + }, + AgreedFeeMultiplier { + known_permille: 1_500, + increase_tolerance_percent: 0, + }, + ); + let (_, reply) = team + .one_document_by(&setup.user, REPLY, &[Some(agreement)]) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + let pot_before = team.moderators_pot(&transaction); + assert_success(&setup.process(&reply[0], &transaction)); + assert_eq!( + team.moderators_pot(&transaction) - pot_before, + discounted() * 3 / 2 + ); +} + +/// The document transition a single-transition batch carries +fn document_transition_of(transition: &StateTransition) -> DocumentTransition { + let StateTransition::Batch(batch) = transition else { + panic!("expected a batch"); + }; + match batch.transitions_iter().next() { + Some(BatchedTransitionRef::Document(document_transition)) => document_transition.clone(), + _ => panic!("expected a document transition"), + } +} + +/// Two additions in one batch are counted together: with one slot left, the first is accepted +/// and the second refused, though neither is in state when the other is judged. Driven through +/// the transformer and the batch state validation directly: `max_transitions_in_documents_batch` +/// is 1 at every protocol version, so no such batch reaches them from the network today. +#[tokio::test] +async fn should_count_the_additions_an_earlier_create_of_the_same_batch_was_accepted_for() { + let team = Team::new(InterimModerators::ContractOwner).await; + let setup = &team.setup; + team.award(); + let [first, second, third] = &team.joiners; + team.process_and_commit(&team.addition_of(first).await); + + let batch: StateTransition = BatchTransition::from(BatchTransitionV0 { + owner_id: team.leader.id(), + transitions: vec![ + document_transition_of(&team.addition_of(second).await), + document_transition_of(&team.addition_of(third).await), + ], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + }) + .into(); + + let platform_version = PlatformVersion::latest(); + let state = setup.platform.state.load(); + let platform_ref = PlatformRef { + drive: &setup.platform.drive, + state: &state, + config: &setup.platform.config, + core_rpc: &setup.platform.core_rpc, + }; + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("expected an execution context"); + let transformed = batch + .transform_into_action( + &platform_ref, + &BlockInfo::default(), + &None, + ValidationMode::Validator, + &mut execution_context, + None, + ) + .expect("expected to transform the batch"); + assert!(transformed.errors.is_empty(), "{:?}", transformed.errors); + let validated = batch + .validate_state( + transformed.data, + &platform_ref, + ValidationMode::Validator, + &BlockInfo::default(), + &mut execution_context, + None, + ) + .expect("expected to validate the batch against state"); + assert_eq!( + validated + .errors + .iter() + .map(|error| error.code()) + .collect::>(), + vec![MODERATION_CHARTER_ADDED_MODERATOR_LIMIT_REACHED] + ); + let Some(StateTransitionAction::BatchAction(action)) = validated.data else { + panic!("expected a batch action back from state validation"); + }; + let survived_as_creates: Vec = action + .transitions() + .iter() + .map(|transition| { + matches!( + transition, + BatchedTransitionAction::DocumentAction(DocumentTransitionAction::CreateAction(_)) + ) + }) + .collect(); + assert_eq!(survived_as_creates, vec![true, false]); +} + +/// The addition cap hooks into the batch's state validation that protocol version 13 runs too, +/// but no batch of that version reaches it: the moderation charters contract is not in state +/// before protocol version 14, so an `addedModerator` create is refused when its contract is +/// fetched, before state validation, as any document create on a missing contract always was. +#[tokio::test] +async fn should_refuse_an_addition_before_protocol_version_14_before_the_cap_is_judged() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let setup = Setup::new_at(None, platform_version).await; + let charters = setup + .platform + .drive + .cache + .system_data_contracts + .load_moderation_charters(PlatformVersion::latest()) + .expect("expected the moderation charters contract"); + let document_type = charters + .document_type_for_name(ADDED_MODERATOR_DOCUMENT_TYPE_NAME) + .expect("expected the addedModerator type"); + let nonce = setup.owner.contract_nonce(); + let (entropy, document) = { + let mut rng = setup.rng.borrow_mut(); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + setup.owner.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::MinDocumentFillSize, + PlatformVersion::latest(), + ) + .expect("expected a random document"); + document.set_properties(BTreeMap::from([ + ("electedCharterId".to_string(), Value::Identifier([1; 32])), + ("submittedCharterId".to_string(), Value::Identifier([2; 32])), + ( + "memberId".to_string(), + Value::Identifier(setup.user.id().to_buffer()), + ), + ])); + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected to set the document id"); + (entropy, document) + }; + let addition = BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + entropy.0, + &setup.owner.key, + nonce, + 0, + None, + &setup.owner.signer, + platform_version, + None, + ) + .await + .expect("expected to build the addition"); + + let transaction = setup.platform.drive.grove.start_transaction(); + let execution = setup.process(&addition, &transaction); + let code = match &execution { + StateTransitionExecutionResult::PaidConsensusError { error, .. } + | StateTransitionExecutionResult::UnpaidConsensusError(error) => error.code(), + other => panic!("expected the addition to be refused, got {other:?}"), + }; + assert_eq!(code, DATA_CONTRACT_NOT_PRESENT); +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/mod.rs index c4adc673cad..0cb98efb335 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/mod.rs @@ -1,10 +1,11 @@ use derive_more::From; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v2::DataContractConfigGettersV2; use dpp::platform_value::Identifier; use dpp::balances::credits::TokenAmount; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::action_fees::{ActionFeePricing, DocumentActionFee}; + use dpp::data_contract::document_type::DocumentTypeRef; use dpp::prelude::IdentityNonce; use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; @@ -98,18 +99,24 @@ impl DocumentBaseTransitionActionAccessorsV0 for DocumentBaseTransitionAction { } } - fn declared_action_fee(&self) -> Option<(ActionFeePricing, DocumentActionFee)> { - match self { - DocumentBaseTransitionAction::V0(v0) => v0 - .declared_action_fee - .as_deref() - .map(|declared| (declared.pricing, declared.fee)), - } - } - fn declared_action_fee_with_agreement(&self) -> Option { match self { DocumentBaseTransitionAction::V0(v0) => v0.declared_action_fee.as_deref().copied(), } } + + fn agrees_to_a_moderators_discount(&self) -> bool { + let Some(declared) = self.declared_action_fee_with_agreement() else { + return false; + }; + declared.agreement.is_some_and(|agreement| { + agreement.discounts_moderators_of(declared.pricing, declared.fee) + }) && self + .data_contract_fetch_info_ref() + .contract + .config() + .moderation() + .and_then(|moderation| moderation.moderators.elected()) + .is_some_and(|elected| elected.moderates_document_type(self.document_type_name())) + } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/v0/mod.rs index a4233ab166a..9dfbf83ebd3 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/v0/mod.rs @@ -21,11 +21,31 @@ pub struct DeclaredDocumentActionFee { /// The declared amounts pub fee: DocumentActionFee, /// The transition's action fee agreement, as it came on the wire. The batch's advanced - /// structure validation judges it against the declaration, and against the fee multiplier - /// the batch transformer read. + /// structure validation judges it against the declaration, against the fee multiplier the + /// batch transformer read, and, for a discounted moderators part, against the share of the + /// contract's seated moderation charter the transformer read. pub agreement: Option, } +impl DeclaredDocumentActionFee { + /// The amounts the action is charged before the fee multiplier: the declared ones, with + /// the moderators part the agreement names when it asks for a discount on it (see + /// [`DocumentActionFeeAgreement::discounts_moderators_of`]). Advanced structure validation + /// refuses every discount but the one the contract's seated moderation charter gives, so + /// only that one reaches execution, and the action is charged what it agreed to. + pub fn agreed_fee(&self) -> DocumentActionFee { + match self.agreement { + Some(agreement) if agreement.discounts_moderators_of(self.pricing, self.fee) => { + DocumentActionFee { + owner: self.fee.owner, + moderators: agreement.moderators(), + } + } + _ => self.fee, + } + } +} + #[derive(Debug, Clone)] /// document base transition action v0 pub struct DocumentBaseTransitionActionV0 { @@ -85,9 +105,14 @@ pub trait DocumentBaseTransitionActionAccessorsV0 { /// Who the document type's token cost offers to pay the gas (`DocumentOwner` without a /// token cost) fn contract_gas_fees_paid_by(&self) -> GasFeesPaidBy; - /// The fee the document type declares for this action and how it is priced - fn declared_action_fee(&self) -> Option<(ActionFeePricing, DocumentActionFee)>; /// The fee the document type declares for this action, with what the transition agreed /// to pay fn declared_action_fee_with_agreement(&self) -> Option; + + /// Whether the transition agrees to a discounted moderators part on a document type an + /// elected contract moderates: the only place a discount may come from, the share of the + /// contract's seated moderation charter. The batch transformer reads that share for every + /// such contract, and advanced structure validation judges the agreement against it. An + /// agreement to less anywhere else is a mismatch, judged without reading anything. + fn agrees_to_a_moderators_discount(&self) -> bool; } diff --git a/packages/rs-drive/src/state_transition_action/batch/mod.rs b/packages/rs-drive/src/state_transition_action/batch/mod.rs index 69c3a59e82a..7acdd669e96 100644 --- a/packages/rs-drive/src/state_transition_action/batch/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/mod.rs @@ -283,9 +283,12 @@ impl BatchTransitionAction { } } - /// The fee each document transition of the batch declares for its action, with the - /// contract it goes to, that contract's owner, and how it is priced. A transition that - /// became a nonce bump declares nothing: only an action that executes is charged. + /// The fee each document transition of the batch owes for its action, before the fee + /// multiplier, with the contract it goes to, that contract's owner, and how it is priced: + /// what its document type declares, with the moderators part it agreed to when that is a + /// discount the contract's seated moderation charter gives (advanced structure validation + /// refuses any other). A transition that became a nonce bump declares nothing: only an + /// action that executes is charged. pub fn declared_action_fees( &self, ) -> Vec<(Identifier, Identifier, ActionFeePricing, DocumentActionFee)> { @@ -296,9 +299,14 @@ impl BatchTransitionAction { .filter_map(|transition| match transition { BatchedTransitionAction::DocumentAction(document_action) => { let base = document_action.base(); - let (pricing, fee) = base.declared_action_fee()?; + let declared = base.declared_action_fee_with_agreement()?; let contract = &base.data_contract_fetch_info_ref().contract; - Some((contract.id(), contract.owner_id(), pricing, fee)) + Some(( + contract.id(), + contract.owner_id(), + declared.pricing, + declared.agreed_fee(), + )) } _ => None, }) @@ -306,6 +314,36 @@ impl BatchTransitionAction { } } + /// The elected contracts on whose moderated document types some document transition of the + /// batch agrees to a discounted moderators part (protocol version 14): the contracts whose + /// seated moderation charter the batch transformer reads the moderators share of. + pub fn contracts_with_moderators_discounts(&self) -> BTreeSet { + match self { + BatchTransitionAction::V0(v0) => v0 + .transitions + .iter() + .filter_map(|transition| match transition { + BatchedTransitionAction::DocumentAction(document_action) => { + let base = document_action.base(); + base.agrees_to_a_moderators_discount() + .then(|| base.data_contract_id()) + } + _ => None, + }) + .collect(), + } + } + + /// Records the moderators share of the seated moderation charter of `contract_id`, `None` + /// when no charter is seated on it + pub fn set_seated_moderators_share(&mut self, contract_id: Identifier, share: Option) { + match self { + BatchTransitionAction::V0(v0) => { + v0.seated_moderators_shares.insert(contract_id, share); + } + } + } + /// The fee multiplier, in permille, of the epoch the batch executes in, as the batch /// transformer read it (protocol version 14). Only read when some document transition of /// the batch declares an action fee priced by it. @@ -352,10 +390,13 @@ impl BatchTransitionAction { } /// Whether every document transition that owes an action fee agreed to it: the transition - /// names the amounts and the pricing its document type declares, and, for a fee priced by - /// the fee multiplier, accepts the multiplier of the epoch the batch executes in. The - /// inner error is the consensus error of the first transition that did not. Both sides - /// travel on the action, so this reads no state. + /// names the amounts and the pricing its document type declares, or, on a document type an + /// elected contract moderates, the declared owner part and pricing with the share of the + /// declared moderators part the contract's seated moderation charter takes; and, for a fee + /// priced by the fee multiplier, accepts the multiplier of the epoch the batch executes in. + /// The inner error is the consensus error of the first transition that did not. Everything + /// it is judged against travels on the action (the charter's share as the transformer read + /// it), so this reads no state. pub fn validate_action_fee_agreements( &self, ) -> Result, ProtocolError> { diff --git a/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs index 0005f748125..7bdaf6a8b36 100644 --- a/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/v0/mod.rs @@ -5,6 +5,8 @@ use crate::state_transition_action::batch::{ use dpp::prelude::FeeMultiplier; use dpp::consensus::state::document::document_action_fee_agreement_mismatch_error::DocumentActionFeeAgreementMismatchError; use dpp::consensus::state::document::document_action_fee_agreement_not_set_error::DocumentActionFeeAgreementNotSetError; +use dpp::consensus::state::document::document_action_fee_moderators_share_mismatch_error::DocumentActionFeeModeratorsShareMismatchError; +use dpp::moderation_charter::moderators_share_of; use dpp::consensus::state::document::document_action_fee_multiplier_not_tolerated_error::DocumentActionFeeMultiplierNotToleratedError; use dpp::consensus::state::token::{GasFeesPaidByNotAllowedError, InconsistentGasFeesPaidByInBatchError}; use dpp::consensus::ConsensusError; @@ -44,6 +46,12 @@ pub struct BatchTransitionActionV0 { /// fee priced by it. The fees themselves are not kept: they are read off the transitions /// when the batch executes, after state validation had its say on each of them. pub action_fee_multiplier_permille: Option, + /// The moderators share of the seated moderation charter of each elected contract on whose + /// moderated document types some document transition of the batch agrees to a discounted + /// moderators part, read by the batch transformer from protocol version 14: `None` when no + /// charter is seated on the contract. Empty for a batch that asks for no discount, which + /// reads nothing. + pub seated_moderators_shares: BTreeMap>, /// The contracts, among those the batch touches, on which the transformer found the batch /// owner's suspension lapsed (protocol version 14). Each such suspension is deleted when @@ -76,7 +84,31 @@ impl BatchTransitionActionV0 { ) .into())); }; - if !agreement.matches_declared(declared.pricing, declared.fee) { + if base.agrees_to_a_moderators_discount() { + // Less than the declared moderators part, on a type an elected contract + // moderates: exactly the share the contract's seated charter takes of it, and + // nothing without a seated charter. + let moderators_share = *self + .seated_moderators_shares + .get(&base.data_contract_id()) + .ok_or(ProtocolError::CorruptedCodeExecution( + "the batch transformer reads the seated moderators share of every \ + contract a document transition agrees to a discount on" + .to_string(), + ))?; + let offered = moderators_share + .map(|share| moderators_share_of(declared.fee.moderators, share)); + if offered != Some(agreement.moderators()) { + return Ok(Err(DocumentActionFeeModeratorsShareMismatchError::new( + document_type_name(), + action(), + declared.fee.moderators, + agreement.moderators(), + moderators_share, + ) + .into())); + } + } else if !agreement.matches_declared(declared.pricing, declared.fee) { return Ok(Err(DocumentActionFeeAgreementMismatchError::new( document_type_name(), action(), diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index d8d273850df..12c73e885c1 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -522,7 +522,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// accumulates for the team to come, and with the types not yet usable /// `contract_moderation_gate` v0 refuses, paid, every document transition /// of a moderated type (`ContractModeratedDocumentTypeNotYetUsableError`, -/// 41200). No election exists yet. +/// 41200) until a charter is seated (item 40). /// /// 23. **Contested indexes without a Lock choice, and ties to the earliest /// contender**: a contested unique index may declare `"resolution": 1`, @@ -961,25 +961,24 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// 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. +/// additions, the target's `maxAddedModerators`, is a consensus rule of +/// item 40. /// 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 step the seating path will run on a proposal, which reads it -/// (basic error 11000) and holds no rule of its own: the reward split sums -/// to 100 through the contract's `propertyConstraints` rule (item 39), so -/// 11001 is no longer produced, and the description fits 4096 bytes through -/// the schema's own `maxBytes` (item 38); every document validation checks -/// both. 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. +/// on reading a proposal (basic error 11000), which holds no rule of its +/// own: the reward split sums to 100 through the contract's +/// `propertyConstraints` rule (item 39), so 11001 is no longer produced, +/// and the description fits 4096 bytes through the schema's own `maxBytes` +/// (item 38); every document validation checks both. 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`). Item 40 seats +/// the winning team. /// /// 38. **`maxBytes` on strings**: a property keyword for the bound plain JSON /// Schema cannot count, the most UTF-8 bytes a string may take @@ -1030,6 +1029,39 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `submittedCharter`'s `rewardSplit` members add up to 100, which /// `validate_submitted_charter` therefore no longer checks (11001). /// +/// 40. **Elected moderation teams moderate from their stored charter**: seating +/// writes nothing. Awarding the contest of item 37 writes the winning +/// `electedCharter`, the only one ever stored for its target, so the +/// charter seated on an elected contract is the one the charter contract's +/// `byTargetContract` index finds, and the moderation paths read it, each +/// read a billed document query of the system contract. Once one is seated, +/// only its team moderates the contract: the leader (the charter's owner) +/// and the active members (its `members` and additions, less removals), +/// each alone, found by at most two point reads of the unique +/// `addedModerator` and `removedModerator` indexes; the interim moderators +/// are refused (41101). The team holds the abilities the declaration gives +/// it: a deletion or restore needs `deleteDocuments` on the type, a list +/// action the ability on some moderated type +/// (`ContractModerationAbilityNotGrantedError`, 41201). The leader and the +/// active members are protected (41102), with the owner when the +/// declaration says so, and the interim moderators no longer are. A +/// `notYetUsable` interim stops blocking the moderated types +/// (`contract_moderation_gate` v0). An `addedModerator` past the target's +/// `maxAddedModerators` additions ever filed for the charter is refused, +/// paid (`ModerationCharterAddedModeratorLimitReachedError`, 41202), by a +/// hook in the batch's `validate_state` v0 that only a create of the +/// charter contract reaches. A document action on a moderated type may +/// agree to the seated proposal's `moderatorsShare` of the declared +/// moderators part (rounded down) instead of the whole, and is charged +/// that: the batch transformer (state v2) reads the charter and its +/// proposal only for such an agreement, and advanced structure validation +/// and every recheck judge it (`DocumentActionFeeModeratorsShareMismatchError`, +/// 40139, for any other lower amount or with no seated charter). The +/// interim team's claim of the moderators pot is refused once a charter is +/// seated (41113). No table moves: every generation involved is unreleased, +/// but for the shipped batch `validate_state` v0, which no batch of an +/// earlier version reaches through the new hook. +/// /// 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 diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 1fa6b41750c..935b6a65d50 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -101,6 +101,7 @@ use dpp::consensus::basic::moderation_charter::{ use dpp::consensus::state::contract_moderation::{ ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, ContractFeesNothingToClaimError, ContractModeratedDocumentTypeNotYetUsableError, + ContractModerationAbilityNotGrantedError, ModerationCharterAddedModeratorLimitReachedError, ContractModerationNotEnabledError, ContractModerationTargetNotAllowedError, ContractModerationCounterpartyBarredError, ContractModerationTargetNotFoundError, ContractModeratorIdentityNotFoundError, @@ -151,6 +152,7 @@ use dpp::consensus::state::document::document_immutable_property_changed_error:: use dpp::consensus::state::identity::gas_sponsor_insufficient_balance_error::GasSponsorInsufficientBalanceError; use dpp::consensus::state::token::{GasFeesPaidByNotAllowedError, InconsistentGasFeesPaidByInBatchError}; use dpp::consensus::state::document::document_action_fee_agreement_mismatch_error::DocumentActionFeeAgreementMismatchError; +use dpp::consensus::state::document::document_action_fee_moderators_share_mismatch_error::DocumentActionFeeModeratorsShareMismatchError; use dpp::consensus::state::document::document_action_fee_agreement_not_set_error::DocumentActionFeeAgreementNotSetError; use dpp::consensus::state::document::document_action_fee_multiplier_not_tolerated_error::DocumentActionFeeMultiplierNotToleratedError; use dpp::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; @@ -702,6 +704,15 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::ReferencedDocumentListInvalidError(e) => { generic_consensus_error!(ReferencedDocumentListInvalidError, e).into() } + StateError::ContractModerationAbilityNotGrantedError(e) => { + generic_consensus_error!(ContractModerationAbilityNotGrantedError, e).into() + } + StateError::ModerationCharterAddedModeratorLimitReachedError(e) => { + generic_consensus_error!(ModerationCharterAddedModeratorLimitReachedError, e).into() + } + StateError::DocumentActionFeeModeratorsShareMismatchError(e) => { + generic_consensus_error!(DocumentActionFeeModeratorsShareMismatchError, e).into() + } } }