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
36 changes: 35 additions & 1 deletion book/src/data-model/contract-moderation.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,47 @@ The writers, readers and provers live in `packages/rs-drive/src/drive/contract/m

A status query answers for the lists it names and no others: `Drive::verify_contract_moderation_status` and the SDK result both return `ContractModerationListStatuses`, one `ContractModerationListStatus` per list queried, so a list that was not read is absent rather than reported as empty (`banned()` is `None` unless the banlist was queried). `ContractModerationStatusQuery::for_contract` names every list the contract keeps; the wasm-sdk does the same, fetching the contract, when the query names no list. Both have `Fetch` and `FetchUnproved` impls in the Rust SDK (`platform::contract_moderation`), wasm-sdk functions and `contracts.moderationStatus` / `contracts.moderationEntries` on the JavaScript SDK. The proof of a moderation transition's execution covers the lists the moderation touched and is classified as affected state: an earlier or later moderation leaving the same entries verifies just the same. A ban does two things, adds the ban and removes a suspension, so its proof covers every list the contract keeps (the banlist entry present, the suspension absent), which the prover and the verifier both read from the contract's config (so the SDKs fetch and cache the contract before broadcasting a ban, as they do for the contracts a document batch touches); an unban, a suspend and an unsuspend prove the one entry they edit. The result, `VerifiedContractModerationListStatuses`, holds one `ContractModerationListStatus` per list proved, never a full status: a list that was not proved is left unknown rather than reported as empty. An identity whose unsuspend was just proved may be banned; the status query answers that.

## Fee Pots and the Claim

A moderation team can be paid. A document type may charge a fixed fee in credits for an action on its documents (the `actionFees` keyword, see [Document action fees](../fees/overview.md#document-action-fees)), split in two parts. The `owner` parts collect in the contract's **owner pot**, the `moderators` parts in its **moderators pot**.

```text
[40] PreFundedSpecializedBalances (sum tree)
├── [64] owner fee pots (sum tree) -> <contract id> -> SumItem(credits)
├── [128] voting balances
└── [192] moderators fee pots (sum tree) -> <contract id> -> SumItem(credits)

[64] DataContractDocuments -> <contract id> -> [2] other
├── [32] epoch the owner pot was last claimed in Item(u16 BE) (after a claim)
└── [96] epoch the moderators pot was last claimed in Item(u16 BE) (after a claim)
```

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 claim epochs are plain items of the contract's other tree, below `128` so the banlist stays on top, written by the first claim.

**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.

`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.

| Stage | Check | Error |
|---|---|---|
| Transform (state, paid) | the contract exists | `DataContractNotPresentError`, unpaid |
| | the signer is a recipient of the pot: the owner for the owner pot, a member of the team for the moderators pot | 41113 |
| | the pot was not paid out in this epoch yet | 41111 |
| | every recipient gets at least a credit | 41112 |

The owner pot goes to the owner whole. The moderators pot is split equally between the team, and what the split leaves over, less than a credit per member, stays in the pot for the next claim, so no member is favoured by the order of the identity ids. Each pot is paid out at most once per epoch and the two are independent: the owner's claim does not use up the team's, nor the reverse. A refused claim is paid for by a nonce bump and leaves the pot and its last claim epoch alone. As for moderation, state validation *is* the transform, so the mempool refuses with the same codes as a block.

The team is read when the claim executes. An owner who changes the appointed set by a contract update and then claims pays the new set: that follows from the owner controlling the contract's config, and is not prevented. The claim credits every recipient's balance, which is why a named moderator must exist (41110): crediting a balance that is not there is an internal error.

The proof of a claim's execution shows the pot with its last claim epoch and the balance of every recipient, which the prover and the verifier both read from the contract. `VerifiedContractFeeClaim` carries the contract id, the pot, that epoch, the credits left in the pot and the balances. A pot that was never claimed proves no claim; a later claim of the same pot verifies just the same, so the result is classified as affected state.

## Versioning Touchpoints

All in place for protocol version 14: `CONTRACT_VERSIONS_V6` makes config V2 the config of every new contract (`max_version` and `default_current_version` 2) and `validate_config_update` 2; `STATE_TRANSITION_SERIALIZATION_VERSIONS_V3` and `DRIVE_ABCI_VALIDATION_VERSIONS_V10` carry the transition's slots and `batch_state_transition.contract_moderation_gate`, and the contract update's basic structure moves to 2 to validate the declaration; `DRIVE_CONTRACT_METHOD_VERSIONS_V4` bumps `insert_contract` to 2 and adds the `moderation` table (its `update_contract` 2 belongs to token distribution and does nothing for moderation); `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4` adds the converter slot and bumps `documents_batch_transition` to 1 for the sweep; `DRIVE_VERIFY_METHOD_VERSIONS` and `DRIVE_ABCI_QUERY_VERSIONS` gain their moderation tables; `SYSTEM_LIMITS_V4` gains `max_contract_moderators`, `max_contract_suspension_until` and `max_contract_moderation_reason_length`.

## What Is Not There Yet

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), further entry metadata such as a timestamp or the moderator's id, and the Swift and Kotlin SDKs. The refusal a barred identity receives (41107, 41108, 41114) does not repeat the reason: the status query does.
Action fees on token transitions, a DAPI query and SDK methods for the fee pots and the claim, 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), further entry metadata such as a timestamp or the moderator's id, 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

Expand Down
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 @@ -60,7 +60,7 @@ Error codes are organized into ranges that correspond to error categories and su
| 10700-10700 | General | `OverflowError` (10700) |
| 10800-10818 | Address | `TransitionOverMaxInputsError` (10800), `WithdrawalBelowMinAmountError` (10818) |
| 10819-10827 | Shielded | `ShieldedNoActionsError` (10819), `ShieldedTooManyActionsError` (10825), `ShieldedImplicitFeeCapExceededError` (10826), `ShieldedInvalidDenominationError` (10827 — `IdentityCreateFromShieldedPool` exit amount not a member of the versioned denomination set) |
| 10900-10949 | Contract Moderation | `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `ContractModerationReasonTooLongError` (10903); 10902 reserved |
| 10900-10949 | Contract Moderation | `InvalidContractModerationConfigError` (10900), `ContractModerationSelfTargetError` (10901), `DocumentActionFeesWithoutModerationError` (10902), `ContractModerationReasonTooLongError` (10903) |

### SignatureError codes (20000-20012)

Expand Down Expand Up @@ -117,7 +117,7 @@ The fee category currently has a single code. The 30000 range is reserved for fu
| 40800-40804 | Groups | `IdentityNotMemberOfGroupError` (40800), `GroupActionAlreadyCompletedError` (40802) |
| 40900-40904 | Shielded | `InvalidAnchorError` (40900), `NullifierAlreadySpentError` (40901), `InsufficientShieldedFeeError` (40904) |
| 41000-41003 | Contract Groups | `ContractGroupAlreadyExistsError` (41000), `ContractGroupNotFoundError` (41001), `IdentityNotContractGroupOwnerOrAdminError` (41002), `ContractGroupAdminNotFoundError` (41003) |
| 41100-41114 | Contract Moderation | `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110), `ContractModerationCounterpartyBarredError` (41114; 41111-41113 reserved) |
| 41100-41114 | Contract Moderation | `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110), `ContractFeesAlreadyClaimedThisEpochError` (41111), `ContractFeesNothingToClaimError` (41112), `ContractFeeClaimNotAllowedError` (41113), `ContractModerationCounterpartyBarredError` (41114) |

Notice how the `DataTriggerError` sub-enum has its own `ErrorWithCode` implementation that the `StateError` delegates to:

Expand Down
75 changes: 75 additions & 0 deletions book/src/fees/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,81 @@ the client chooses between token and credits before signing. Together with
contract-owner gas this is the "free usage" pattern: an app hands out tokens,
a user posts for free while they last, and keeps posting on credits after.

### Document action fees

From protocol version 14 a document type may charge a fixed fee in credits for
an action on one of its documents, on top of the gas. The `actionFees` keyword
(v3 document meta-schema) sits beside `tokenCost` and prices the same six
actions:

```json
"post": {
"type": "object",
"actionFees": {
"pricing": "feeMultiplier",
"create": { "moderators": 100000000, "owner": 10000000 }
}
}
```

Creating a post here costs an extra 0.001 Dash for the contract's moderation
team and 0.0001 Dash for its owner. Each fee has those two parts, either of
which may be left out. The `owner` parts collect in the contract's **owner
pot** and the `moderators` parts in its **moderators pot**, and a
`ContractFeeClaim` state transition pays a pot out (see
[Contract Moderation](../data-model/contract-moderation.md#fee-pots-and-the-claim)).
A `moderators` part needs a contract that declares moderation
(`DocumentActionFeesWithoutModerationError`, 10902): the moderation team is
who that pot is for.

**Pricing.** `fixed` charges the declared amounts as written. `feeMultiplier`,
the default, scales them by the fee multiplier of the epoch the action
executes in (`declared * multiplier_permille / 1000`, rounded down), so a fee
follows the network's fees. The multiplier is the item every epoch tree
records, read once per batch and billed to it
(`fetch_action_fee_multiplier_with_fee`). In the first block of an epoch that
item is not there yet, because state transitions execute before the end of the
block, where the epoch is initialized; the multiplier the epoch is about to be
initialized with, the fee schedule's, is used then. Nothing else reads the
epoch multiplier today: the metered fees do not scale with it. A scaled
amount is held at the maximum number of credits rather than overflowing: a fee
nobody can pay refuses the action for an insufficient balance, a consensus
error, where an overflow would have failed every transition on the action with
an internal one.

**The amounts never change.** Nobody signs the fee on a transition, so what a
contract showed when it was published is the only thing its users agreed to.
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 its own, so a live contract
gets fees through new document types only.

**Who pays.** Whoever pays the gas pays the action fee: the signer, or the
contract owner when they sponsor the gas. A sponsor's balance has to cover the
gas *and* the fees they would owe; one that insisted and falls short is the
same unpaid refusal as before (40222), and one that only preferred hands the
gas and the fees back to the signer. Fee validation and execution ask that one
question through one function (`gas_sponsor_pays`), on one estimate, so they
always name the same payer. The contract owner never pays the `owner` part:
it would travel through the owner pot back to them and only cost writes. A
sponsor is always the contract owner, so a sponsored action pays into the
moderators pot only, which a contract that sponsors gas should price in. The
fee counts against the budget of a budgeted signing key when its identity pays
it.

**Only an action that executes is charged.** A transition that fails, in the
transformer or later in state validation, becomes a nonce bump, and a bump owes
nothing. The fees are therefore read off the transitions when the execution
event is built, after state validation had its say, not when the transformer
ran.

**The fee is not part of the `FeeResult`.** It moves as balance operations in
the batch's own operation list: one removal from the payer, one addition per
pot. The fee pools and the proposers see exactly what they saw before. The
pots sit under the `PreFundedSpecializedBalances` root sum tree, which the
per-block total credits check already sums, so the credits stay accounted for
while they wait to be claimed.

## FeeResult

All fee calculations produce a `FeeResult`:
Expand Down
52 changes: 52 additions & 0 deletions packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,25 @@
"amount"
],
"additionalProperties": false
},
"documentActionFee": {
"type": "object",
"properties": {
"owner": {
"type": "integer",
"minimum": 0,
"maximum": 9223372036854775807,
"description": "Credits added to the contract's owner pot, which the contract owner claims."
},
"moderators": {
"type": "integer",
"minimum": 0,
"maximum": 9223372036854775807,
"description": "Credits added to the contract's moderators pot, which the contract's moderation team shares equally. Requires the contract config to declare moderation."
}
},
"minProperties": 1,
"additionalProperties": false
}
},
"properties": {
Expand Down Expand Up @@ -871,6 +890,39 @@
"type": "boolean",
"description": "When true, documents of this type are never written to primary storage: the index entries are the rows, each terminating in an Item keyed by the index's `terminal` property instead of a Reference keyed by the document id. Only what is in the indexes exists and is recoverable. Requires: every property required and appearing in at least one index (except a `skipIfAbsent` index's optional first property), $ownerId in at least one index (as a property or terminal), documentsMutable: false, no transfers/trading/history/transient properties, and no doctype-level aggregate keywords (use the index-level count flags). Available from protocol version 14."
},
"actionFees": {
"type": "object",
"description": "A fixed fee in credits charged, on top of the gas, for actions on documents of this type, split between the contract's owner pot and its moderators pot (each paid out by a ContractFeeClaim state transition). Whoever pays the gas of the action pays its fee. At least one action must be priced and a priced action must charge something. Fixed when the document type is published: a contract update cannot add, change or remove the fees of an existing document type. Available from protocol version 14.",
"properties": {
"pricing": {
"type": "string",
"enum": [
"feeMultiplier",
"fixed"
],
"description": "feeMultiplier (default): the declared amounts are scaled by the fee multiplier of the epoch the action executes in. fixed: the declared amounts are charged as written."
},
"create": {
"$ref": "#/$defs/documentActionFee"
},
"replace": {
"$ref": "#/$defs/documentActionFee"
},
"delete": {
"$ref": "#/$defs/documentActionFee"
},
"transfer": {
"$ref": "#/$defs/documentActionFee"
},
"update_price": {
"$ref": "#/$defs/documentActionFee"
},
"purchase": {
"$ref": "#/$defs/documentActionFee"
}
},
"additionalProperties": false
},
"tokenCost": {
"type": "object",
"properties": {
Expand Down
Loading
Loading