Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions book/src/data-model/contract-moderation.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions book/src/error-handling/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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:

Expand Down
32 changes: 32 additions & 0 deletions book/src/fees/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 59 additions & 12 deletions docs/protocol/moderation-charters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down
13 changes: 10 additions & 3 deletions packages/moderation-charters-contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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 |

Expand Down
36 changes: 34 additions & 2 deletions packages/rs-dpp/src/data_contract/config/moderation/elected.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading