diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index d426983176c..d3a75657d1c 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -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) -> -> SumItem(credits) +├── [128] voting balances +└── [192] moderators fee pots (sum tree) -> -> SumItem(credits) + +[64] DataContractDocuments -> -> [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 diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 4b78eff608b..a62f0e2a31f 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -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) @@ -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: diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 4c6590432a4..26801a1eb3d 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -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`: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index a8055d3061e..86f837b7df8 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -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": { @@ -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": { 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 612cccb6da3..5b4056fc661 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/mod.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/mod.rs @@ -59,6 +59,19 @@ impl ContractModerators { pub fn may_moderate(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool { owner_id == identity_id || self.names(identity_id) } + + /// The moderation team of a contract owned by `owner_id`: the identities that share its + /// moderators fee pot. It is the set the contract appoints, the owner among them only + /// when appointed, and the owner alone when nobody is appointed. + /// + /// The team is about earnings, not authority: an owner who is not on it still may + /// moderate ([`Self::may_moderate`]). + pub fn team(&self, owner_id: &Identifier) -> BTreeSet { + match self { + ContractModerators::ContractOwner => BTreeSet::from([*owner_id]), + ContractModerators::AppointedModerators(ids) => ids.clone(), + } + } } // The wire shape is a flat `{"$type": "contractOwner"}` or @@ -240,6 +253,12 @@ impl ContractModerationConfig { self.moderators.may_moderate(owner_id, identity_id) } + /// The moderation team of a contract owned by `owner_id`: the identities that share its + /// moderators fee pot. See [`ContractModerators::team`]. + pub fn team(&self, owner_id: &Identifier) -> BTreeSet { + self.moderators.team(owner_id) + } + /// The pure-data rules of the declaration: at least one list is kept, and a moderator set /// is non-empty and within `SystemLimits::max_contract_moderators` (a named owner counts). /// Whether the named identities exist is state validation, done by the contract create @@ -618,6 +637,23 @@ mod tests { assert_eq!(config.lists().count(), 2); } + #[test] + fn should_put_the_owner_on_the_team_only_when_appointed_or_alone() { + let owner = Identifier::from([9; 32]); + assert_eq!( + ContractModerators::ContractOwner.team(&owner), + BTreeSet::from([owner]) + ); + assert_eq!( + ContractModerators::AppointedModerators(set(&[1, 2])).team(&owner), + set(&[1, 2]) + ); + assert_eq!( + ContractModerators::AppointedModerators(set(&[1, 9])).team(&owner), + set(&[1, 9]) + ); + } + #[test] fn should_tell_barred_from_lapsed() { let banned = ContractModerationStatus { diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs index 4581a528e4d..b7aa6a4d6a3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs @@ -2,6 +2,7 @@ mod v0; mod v1; mod v2; +use crate::data_contract::document_type::action_fees::DocumentActionFees; use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index_level::IndexLevel; use crate::data_contract::document_type::property::DocumentProperty; @@ -1001,6 +1002,14 @@ impl DocumentTypeV2Getters for DocumentType { DocumentType::V2(v2) => v2.immutable_fields_allow_setting(), } } + + fn action_fees(&self) -> Option<&DocumentActionFees> { + match self { + DocumentType::V0(_) => None, + DocumentType::V1(_) => None, + DocumentType::V2(v2) => v2.action_fees(), + } + } } impl DocumentTypeV2Setters for DocumentType { @@ -1093,6 +1102,14 @@ impl DocumentTypeV2Getters for DocumentTypeRef<'_> { DocumentTypeRef::V2(v2) => v2.immutable_fields_allow_setting(), } } + + fn action_fees(&self) -> Option<&DocumentActionFees> { + match self { + DocumentTypeRef::V0(_) => None, + DocumentTypeRef::V1(_) => None, + DocumentTypeRef::V2(v2) => v2.action_fees(), + } + } } impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { @@ -1151,6 +1168,14 @@ impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { DocumentTypeMutRef::V2(v2) => v2.immutable_fields_allow_setting(), } } + + fn action_fees(&self) -> Option<&DocumentActionFees> { + match self { + DocumentTypeMutRef::V0(_) => None, + DocumentTypeMutRef::V1(_) => None, + DocumentTypeMutRef::V2(v2) => v2.action_fees(), + } + } } #[cfg(test)] diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index c577979d8f0..463bbdf0f0e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -1,3 +1,4 @@ +use crate::data_contract::document_type::action_fees::DocumentActionFees; use std::collections::BTreeSet; /// Trait providing getters for DocumentTypeV2-specific fields. @@ -48,6 +49,11 @@ pub trait DocumentTypeV2Getters { /// [`Self::immutable_fields`]; empty on document types that predate the /// keyword. fn immutable_fields_allow_setting(&self) -> &BTreeSet; + + /// The fixed fees in credits this document type charges for actions on its documents + /// (the `actionFees` keyword, protocol version 14). `None` on document types that + /// declare none and on those that predate the keyword. + fn action_fees(&self) -> Option<&DocumentActionFees>; } /// Trait providing setters for DocumentTypeV2-specific fields. 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 new file mode 100644 index 00000000000..0f27e2eb593 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/action_fees/mod.rs @@ -0,0 +1,659 @@ +//! Document action fees: a fixed amount of credits a document type charges, on top of the gas, +//! for an action on one of its documents (the `actionFees` keyword, protocol version 14). +//! +//! Each fee has two parts. The `owner` part accumulates in the contract's owner pot and the +//! `moderators` part in its moderators pot; a `ContractFeeClaim` state transition pays a pot +//! out. Whoever pays the gas of the action pays its fee. The amounts are fixed when the +//! document type is published and never change. + +use crate::balances::credits::{Credits, MAX_CREDITS}; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::config::v2::DataContractConfigGettersV2; +use crate::data_contract::document_type::class_methods::{ + consensus_or_protocol_data_contract_error, consensus_or_protocol_value_error, +}; +use crate::data_contract::document_type::property_names::ACTION_FEES; +use crate::data_contract::errors::DataContractError; +use crate::data_contract::DataContract; +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonSafeFields; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::From; +use platform_value::{Identifier, Value}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fmt; + +pub mod v0; + +use v0::DocumentActionFeesV0; + +/// The pricing values of the `actionFees.pricing` schema keyword +pub mod pricing_names { + /// The declared amounts are scaled by the epoch's fee multiplier (the default) + pub const FEE_MULTIPLIER: &str = "feeMultiplier"; + /// The declared amounts are charged as written + pub const FIXED: &str = "fixed"; +} + +/// One thousand permille: a fee multiplier that changes nothing +pub const FEE_MULTIPLIER_PERMILLE_BASE: u64 = 1000; + +/// How the declared amounts of a document type's action fees become the amounts charged. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Hash)] +pub enum ActionFeePricing { + /// The declared amounts are scaled by the fee multiplier of the epoch the action executes + /// in, so they follow the network's fees. + #[default] + FeeMultiplier, + /// The declared amounts are charged as written. + Fixed, +} + +impl ActionFeePricing { + /// The schema value of the pricing + pub fn as_str(&self) -> &'static str { + match self { + ActionFeePricing::FeeMultiplier => pricing_names::FEE_MULTIPLIER, + ActionFeePricing::Fixed => pricing_names::FIXED, + } + } + + /// The pricing a schema value names + pub fn from_schema_value(value: &str) -> Option { + match value { + pricing_names::FEE_MULTIPLIER => Some(ActionFeePricing::FeeMultiplier), + pricing_names::FIXED => Some(ActionFeePricing::Fixed), + _ => None, + } + } +} + +/// The two pots a contract's action fees accumulate in. A `ContractFeeClaim` state transition +/// names the one it pays out. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + DecodeUntrusted, + Hash, + Serialize, + Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub enum ContractFeePot { + /// The pot the contract owner claims + Owner, + /// The pot the contract's moderation team shares equally + Moderators, +} + +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. + pub fn recipients(&self, contract: &DataContract) -> BTreeSet { + let owner_id = contract.owner_id(); + match self { + ContractFeePot::Owner => BTreeSet::from([owner_id]), + ContractFeePot::Moderators => contract + .config() + .moderation() + .map(|moderation| moderation.team(&owner_id)) + .unwrap_or_default(), + } + } +} + +impl fmt::Display for ContractFeePot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ContractFeePot::Owner => write!(f, "owner"), + ContractFeePot::Moderators => write!(f, "moderators"), + } + } +} + +#[cfg(feature = "json-conversion")] +impl JsonSafeFields for ContractFeePot {} + +/// The fee of one document action, in credits. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Hash)] +pub struct DocumentActionFee { + /// The credits that go to the contract's owner pot + pub owner: Credits, + /// The credits that go to the contract's moderators pot + pub moderators: Credits, +} + +impl DocumentActionFee { + /// The fee, `None` when both parts are zero + pub fn non_zero(self) -> Option { + (self.owner != 0 || self.moderators != 0).then_some(self) + } + + /// The sum of both parts + pub fn total(&self) -> Result { + self.owner + .checked_add(self.moderators) + .filter(|total| *total <= MAX_CREDITS) + .ok_or(ProtocolError::Overflow( + "document action fee parts overflow the maximum credits", + )) + } + + /// The sum of both parts, held at `MAX_CREDITS` + pub fn saturating_total(&self) -> Credits { + self.owner.saturating_add(self.moderators).min(MAX_CREDITS) + } + + /// The part that goes to `pot` + pub fn part(&self, pot: ContractFeePot) -> Credits { + match pot { + ContractFeePot::Owner => self.owner, + ContractFeePot::Moderators => self.moderators, + } + } + + /// The amounts charged for the declared fee under `pricing`. + /// + /// `fee_multiplier_permille` is the fee multiplier of the epoch the action executes in. It + /// is only read under [`ActionFeePricing::FeeMultiplier`]. + pub fn charged( + &self, + pricing: ActionFeePricing, + fee_multiplier_permille: u64, + ) -> Result { + match pricing { + ActionFeePricing::Fixed => Ok(*self), + ActionFeePricing::FeeMultiplier => Ok(DocumentActionFee { + owner: scale(self.owner, fee_multiplier_permille)?, + moderators: scale(self.moderators, fee_multiplier_permille)?, + }), + } + } +} + +/// `amount` scaled by the multiplier, held at `MAX_CREDITS`. The multiplier is the network's and +/// has no upper bound, so a declared amount that fits today may not tomorrow. A fee held at the +/// maximum is one nobody can pay: the action is refused for an insufficient balance, a consensus +/// error a client can read, where an overflow would have failed every transition on the action +/// with an internal one. +fn scale(amount: Credits, fee_multiplier_permille: u64) -> Result { + let scaled = (amount as u128).saturating_mul(fee_multiplier_permille as u128) + / FEE_MULTIPLIER_PERMILLE_BASE as u128; + Ok(Credits::try_from(scaled) + .unwrap_or(MAX_CREDITS) + .min(MAX_CREDITS)) +} + +/// The action fees of a document type +#[derive(Debug, PartialEq, Eq, Clone, From)] +pub enum DocumentActionFees { + /// Version 0 of the action fees + V0(DocumentActionFeesV0), +} + +impl DocumentActionFees { + /// Reads the `actionFees` keyword of a document type's schema, `None` when the schema + /// declares none. + /// + /// A declaration must price at least one action, every priced action must charge + /// something, and no amount (nor the sum of an action's two parts) may exceed + /// `MAX_CREDITS`. + pub fn try_from_document_schema( + schema: &Value, + name: &str, + ) -> Result, ProtocolError> { + let structure_error = |message: String| { + consensus_or_protocol_data_contract_error(DataContractError::InvalidContractStructure( + message, + )) + }; + + let Ok(schema_map) = schema.to_map() else { + return Ok(None); + }; + let Some(action_fees) = Value::get_optional_from_map(schema_map, ACTION_FEES) else { + return Ok(None); + }; + if !action_fees.is_map() { + return Err(structure_error(format!( + "document type \"{name}\": `{ACTION_FEES}` must be an object" + ))); + } + + let pricing = match action_fees + .get_optional_str("pricing") + .map_err(consensus_or_protocol_value_error)? + { + None => ActionFeePricing::default(), + Some(value) => ActionFeePricing::from_schema_value(value).ok_or_else(|| { + structure_error(format!( + "document type \"{name}\": `{ACTION_FEES}.pricing` must be \"{}\" or \"{}\", \ + got \"{value}\"", + pricing_names::FEE_MULTIPLIER, + pricing_names::FIXED, + )) + })?, + }; + + let extract_fee = |action: &str| -> Result, ProtocolError> { + let Some(entry) = action_fees + .get_optional_value(action) + .map_err(consensus_or_protocol_value_error)? + else { + return Ok(None); + }; + if !entry.is_map() { + return Err(structure_error(format!( + "document type \"{name}\": `{ACTION_FEES}.{action}` must be an object" + ))); + } + let part = |key: &str| -> Result { + let amount = entry + .get_optional_integer::(key) + .map_err(consensus_or_protocol_value_error)? + .unwrap_or_default(); + if amount > MAX_CREDITS { + return Err(structure_error(format!( + "document type \"{name}\": `{ACTION_FEES}.{action}.{key}` of {amount} \ + credits is over the maximum of {MAX_CREDITS}" + ))); + } + Ok(amount) + }; + let fee = DocumentActionFee { + owner: part("owner")?, + moderators: part("moderators")?, + }; + if fee.non_zero().is_none() { + return Err(structure_error(format!( + "document type \"{name}\": `{ACTION_FEES}.{action}` charges nothing; leave the \ + action out instead" + ))); + } + if fee.total().is_err() { + return Err(structure_error(format!( + "document type \"{name}\": the two parts of `{ACTION_FEES}.{action}` add up to \ + more than the maximum of {MAX_CREDITS} credits" + ))); + } + Ok(Some(fee)) + }; + + let fees = DocumentActionFeesV0 { + pricing, + create: extract_fee("create")?, + replace: extract_fee("replace")?, + delete: extract_fee("delete")?, + transfer: extract_fee("transfer")?, + update_price: extract_fee("update_price")?, + purchase: extract_fee("purchase")?, + }; + let fees: DocumentActionFees = fees.into(); + if fees.all().next().is_none() { + return Err(structure_error(format!( + "document type \"{name}\": `{ACTION_FEES}` prices no action" + ))); + } + Ok(Some(fees)) + } + + /// The name of the first document type, in name order, whose schema charges a moderators + /// part. A schema whose declaration is malformed is skipped: refusing it is the job of the + /// document type parser. + pub fn first_document_type_charging_moderators<'a>( + document_schemas: impl IntoIterator, + ) -> Option<&'a String> { + document_schemas + .into_iter() + .find(|(name, schema)| { + matches!( + Self::try_from_document_schema(schema, name), + Ok(Some(fees)) if fees.charges_moderators_part() + ) + }) + .map(|(name, _)| name) + } +} + +impl DocumentActionFees { + /// How the declared amounts become the amounts charged + pub fn pricing(&self) -> ActionFeePricing { + match self { + DocumentActionFees::V0(v0) => v0.pricing, + } + } + + /// The fee of creating a document + pub fn document_creation_action_fee(&self) -> Option { + match self { + DocumentActionFees::V0(v0) => v0.create, + } + } + + /// The fee of replacing a document + pub fn document_replacement_action_fee(&self) -> Option { + match self { + DocumentActionFees::V0(v0) => v0.replace, + } + } + + /// The fee of deleting a document + pub fn document_deletion_action_fee(&self) -> Option { + match self { + DocumentActionFees::V0(v0) => v0.delete, + } + } + + /// The fee of transferring a document + pub fn document_transfer_action_fee(&self) -> Option { + match self { + DocumentActionFees::V0(v0) => v0.transfer, + } + } + + /// The fee of updating the price of a document + pub fn document_price_update_action_fee(&self) -> Option { + match self { + DocumentActionFees::V0(v0) => v0.update_price, + } + } + + /// The fee of purchasing a document + pub fn document_purchase_action_fee(&self) -> Option { + match self { + DocumentActionFees::V0(v0) => v0.purchase, + } + } + + /// Every declared fee, in the order of the schema's action keys + pub fn all(&self) -> impl Iterator + '_ { + match self { + DocumentActionFees::V0(v0) => [ + v0.create, + v0.replace, + v0.delete, + v0.transfer, + v0.update_price, + v0.purchase, + ] + .into_iter() + .flatten(), + } + } + + /// Whether any action charges a non-zero moderators part + pub fn charges_moderators_part(&self) -> bool { + self.all().any(|fee| fee.moderators != 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; + use crate::data_contract::document_type::DocumentType; + use platform_value::{platform_value, Identifier, Value}; + use platform_version::version::PlatformVersion; + use std::collections::BTreeMap; + + /// A document type with one string property and the given `actionFees`. + fn parse_with_action_fees( + action_fees: Value, + full_validation: bool, + platform_version: &PlatformVersion, + ) -> Result { + let schema = platform_value!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + }, + "actionFees": action_fees, + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "post", + schema, + None, + &BTreeMap::new(), + &config, + full_validation, + &mut Vec::new(), + platform_version, + ) + } + + #[test] + fn should_parse_action_fees_with_the_fee_multiplier_pricing_by_default() { + let document_type = parse_with_action_fees( + platform_value!({ + "create": {"moderators": 100_000_000_u64, "owner": 10_000_000_u64}, + "delete": {"owner": 5_u64}, + }), + true, + PlatformVersion::latest(), + ) + .expect("expected the action fees to parse"); + + let fees = document_type.action_fees().expect("expected action fees"); + assert_eq!(fees.pricing(), ActionFeePricing::FeeMultiplier); + assert_eq!( + fees.document_creation_action_fee(), + Some(DocumentActionFee { + owner: 10_000_000, + moderators: 100_000_000, + }) + ); + assert_eq!( + fees.document_deletion_action_fee(), + Some(DocumentActionFee { + owner: 5, + moderators: 0, + }) + ); + assert_eq!(fees.document_replacement_action_fee(), None); + assert!(fees.charges_moderators_part()); + } + + #[test] + fn should_parse_the_fixed_pricing() { + let document_type = parse_with_action_fees( + platform_value!({"pricing": "fixed", "create": {"owner": 1_u64}}), + true, + PlatformVersion::latest(), + ) + .expect("expected the action fees to parse"); + let fees = document_type.action_fees().expect("expected action fees"); + assert_eq!(fees.pricing(), ActionFeePricing::Fixed); + assert!(!fees.charges_moderators_part()); + } + + #[test] + fn should_refuse_malformed_action_fees_on_a_contract_entering_the_chain() { + for action_fees in [ + // Prices no action + platform_value!({"pricing": "fixed"}), + // A priced action that charges nothing + platform_value!({"create": {"owner": 0_u64, "moderators": 0_u64}}), + // An unknown pricing + platform_value!({"pricing": "percent", "create": {"owner": 1_u64}}), + // An unknown action + platform_value!({"archive": {"owner": 1_u64}}), + // An unknown part + platform_value!({"create": {"treasury": 1_u64}}), + // Over the maximum credits + platform_value!({"create": {"owner": u64::MAX}}), + ] { + assert!( + parse_with_action_fees(action_fees.clone(), true, PlatformVersion::latest()) + .is_err(), + "expected {action_fees:?} to be refused" + ); + } + } + + #[test] + fn should_refuse_malformed_action_fees_on_both_paths() { + // The shape of a doctype-level keyword is enforced on the stored path as on the + // validating one: every declaration a node reads from state was validated, so a + // malformed one is a fault to surface, never a contract without fees. + for full_validation in [false, true] { + for action_fees in [ + platform_value!("free"), + platform_value!({"pricing": "percent", "create": {"owner": 1_u64}}), + platform_value!({"create": {"owner": 0_u64}}), + ] { + assert!( + parse_with_action_fees( + action_fees.clone(), + full_validation, + PlatformVersion::latest() + ) + .is_err(), + "expected {action_fees:?} to be refused with full_validation {full_validation}" + ); + } + } + } + + #[test] + fn should_parse_a_moderators_fee_without_moderation_on_both_paths() { + // The document type does not know whether its contract declares moderation. It parses + // on both paths, and the contract is what refuses the combination when it enters the + // chain (`DocumentActionFeesWithoutModerationError`, 10902). Moderation is never turned + // off, so no validated contract is in that state. + let action_fees = platform_value!({"create": {"owner": 5_u64, "moderators": 10_u64}}); + for full_validation in [false, true] { + let document_type = parse_with_action_fees( + action_fees.clone(), + full_validation, + PlatformVersion::latest(), + ) + .expect("expected the document type to parse"); + let fees = document_type.action_fees().expect("expected action fees"); + assert!(fees.charges_moderators_part()); + } + } + + #[test] + fn should_read_action_fees_from_protocol_version_14_only() { + let action_fees = platform_value!({"create": {"owner": 1_u64}}); + // The frozen v0 meta-schema (protocol versions 1 to 11) does not forbid unknown + // top-level keys, and the parsers of those versions never look at this one: the key + // is carried and charges nothing. + for protocol_version in 1..=11 { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected the protocol version"); + let document_type = parse_with_action_fees(action_fees.clone(), true, platform_version) + .expect("expected the v0 meta-schema to admit the unknown key"); + assert!( + document_type.action_fees().is_none(), + "expected protocol version {protocol_version} to ignore `actionFees`" + ); + } + // The v1 and v2 meta-schemas forbid unknown top-level keys. + for protocol_version in 12..=13 { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected the protocol version"); + assert!( + parse_with_action_fees(action_fees.clone(), true, platform_version).is_err(), + "expected protocol version {protocol_version} to refuse `actionFees`" + ); + } + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + let document_type = parse_with_action_fees(action_fees, true, platform_version) + .expect("expected protocol version 14 to admit `actionFees`"); + assert!(document_type.action_fees().is_some()); + } + + #[test] + fn should_charge_the_declared_amounts_when_fixed() { + let fee = DocumentActionFee { + owner: 10_000_000, + moderators: 100_000_000, + }; + assert_eq!( + fee.charged(ActionFeePricing::Fixed, 2_500) + .expect("charged"), + fee + ); + } + + #[test] + fn should_scale_the_declared_amounts_by_the_fee_multiplier() { + let fee = DocumentActionFee { + owner: 10_000_000, + moderators: 100_000_001, + }; + assert_eq!( + fee.charged(ActionFeePricing::FeeMultiplier, 1_000) + .expect("charged"), + fee + ); + assert_eq!( + fee.charged(ActionFeePricing::FeeMultiplier, 1_500) + .expect("charged"), + DocumentActionFee { + owner: 15_000_000, + // floor(100_000_001 * 1.5) + moderators: 150_000_001, + } + ); + } + + #[test] + fn should_hold_a_scaled_amount_at_the_maximum_credits() { + let fee = DocumentActionFee { + owner: MAX_CREDITS, + moderators: 7, + }; + let charged = fee + .charged(ActionFeePricing::FeeMultiplier, u64::MAX) + .expect("charged"); + assert_eq!(charged.owner, MAX_CREDITS); + // 7 credits at that multiplier still fit, and are scaled as usual. + assert_eq!(charged.moderators, 129_127_208_515_966_861); + assert_eq!(charged.saturating_total(), MAX_CREDITS); + assert_eq!( + fee.charged(ActionFeePricing::FeeMultiplier, 1_000) + .expect("charged"), + fee + ); + } + + #[test] + fn should_refuse_a_total_over_the_maximum_credits() { + let fee = DocumentActionFee { + owner: MAX_CREDITS, + moderators: 1, + }; + assert!(fee.total().is_err()); + } + + #[test] + fn should_round_trip_the_pricing_schema_values() { + for pricing in [ActionFeePricing::FeeMultiplier, ActionFeePricing::Fixed] { + assert_eq!( + ActionFeePricing::from_schema_value(pricing.as_str()), + Some(pricing) + ); + } + assert_eq!(ActionFeePricing::from_schema_value("percent"), None); + assert_eq!(ActionFeePricing::default(), ActionFeePricing::FeeMultiplier); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/action_fees/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/action_fees/v0/mod.rs new file mode 100644 index 00000000000..d17063731fa --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/action_fees/v0/mod.rs @@ -0,0 +1,26 @@ +use crate::data_contract::document_type::action_fees::{ActionFeePricing, DocumentActionFee}; + +/// The action fees of a document type, version 0 +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub struct DocumentActionFeesV0 { + /// How the declared amounts become the amounts charged + pub pricing: ActionFeePricing, + + /// Fee of creating a document + pub create: Option, + + /// Fee of replacing a document + pub replace: Option, + + /// Fee of deleting a document + pub delete: Option, + + /// Fee of transferring a document + pub transfer: Option, + + /// Fee of updating the price of a document + pub update_price: Option, + + /// Fee of purchasing a document + pub purchase: Option, +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 62158a78546..e479e4d3379 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -19,6 +19,7 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::class_methods::consensus_or_protocol_data_contract_error; // Only the ranked key-length rule below names `Index`, and it is validation-only. +use crate::data_contract::document_type::action_fees::DocumentActionFees; #[cfg(feature = "validation")] use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index::IndexGrammarAdmissions; @@ -250,6 +251,14 @@ fn try_from_schema_generation_3( // consumes `schema`. let aggregates = common::parse_doctype_aggregate_keywords(&schema, name)?; let index_only = common::parse_index_only_keyword(&schema)?; + // Like every doctype-level keyword of this generation, `actionFees` is read wherever it + // appears and its shape is enforced on the validating and the stored path alike: no keyword + // is softened for a stored contract. The frozen v0 meta-schema (protocol versions 1 to 11) + // did not refuse unknown top-level keys, but a census of every contract create and update + // on mainnet and testnet (2026-09-20) found no contract it admitted carrying this key, and + // every create and update since is validated by a meta-schema that refuses it. So every + // declaration a node reads from state was validated. + let action_fees = DocumentActionFees::try_from_document_schema(&schema, name)?; let immutable_fields = common::parse_property_name_list_keyword(&schema, name, property_names::IMMUTABLE)?; let immutable_fields_allow_setting = common::parse_property_name_list_keyword( @@ -320,6 +329,7 @@ fn try_from_schema_generation_3( )?; let mut v2: DocumentTypeV2 = v1.into(); + v2.action_fees = action_fees; common::apply_doctype_aggregates(&mut v2, aggregates, name)?; // After the aggregates: `apply_index_only` rejects the doctype-level // aggregate flags (they describe the primary-key tree, which an diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index a3b2d2fb71b..a6337517eb6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -89,10 +89,48 @@ impl DocumentTypeRef<'_> { return Ok(result); } + // Validate that the action fees are unchanged + let result = self.validate_action_fees_unchanged(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + // Validate schema compatibility self.validate_schema_with_options(new_document_type, platform_version, &options) } + /// The action fees of a document type are fixed when it is published: an + /// update may not add, change or remove them, nor switch their pricing. + /// Those who act on the documents agreed to the amounts the contract + /// showed them, and nobody signs the fee on a transition, so there is + /// nothing a changed amount could be checked against. A document type + /// added by the update is not judged here and may declare its own. + fn validate_action_fees_unchanged( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + if self.action_fees() == new_document_type.action_fees() { + return SimpleConsensusValidationResult::new(); + } + let change = match (self.action_fees(), new_document_type.action_fees()) { + (None, Some(_)) => "add", + (Some(_), None) => "remove", + _ => "change", + }; + SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not {change} its action fees: they are fixed when the \ + document type is published" + ), + ) + .into(), + ) + } + /// Top-level requiredness may only change in one way: a brand-new /// property may be added as required when it is annotated with /// `requiredSince` equal to the contract version this update creates. @@ -441,6 +479,92 @@ mod tests { ); } + /// A document type with one string property and, when given, `actionFees`. + fn doc_type_with_action_fees( + action_fees: Option, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + }, + "additionalProperties": false, + }); + if let Some(action_fees) = action_fees { + schema + .insert("actionFees".to_string(), action_fees) + .expect("expected to set the action fees"); + } + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + // Nobody signs the fee on a transition, so the amounts a contract showed when it was + // published are the only thing its users agreed to. + #[test] + fn should_reject_adding_changing_or_removing_action_fees() { + let platform_version = PlatformVersion::latest(); + let free = doc_type_with_action_fees(None, platform_version); + let priced = doc_type_with_action_fees( + Some(platform_value!({"create": {"owner": 10_u64}})), + platform_version, + ); + let repriced = doc_type_with_action_fees( + Some(platform_value!({"create": {"owner": 11_u64}})), + platform_version, + ); + let fixed = doc_type_with_action_fees( + Some(platform_value!({"pricing": "fixed", "create": {"owner": 10_u64}})), + platform_version, + ); + + for (old, new, change) in [ + (&free, &priced, "add"), + (&priced, &free, "remove"), + (&priced, &repriced, "change"), + (&priced, &fixed, "change"), + ] { + let result = old + .as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .expect("expected the update to be judged"); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.additional_message().contains(&format!("can not {change} its action fees")) + ); + } + } + + #[test] + fn should_accept_unchanged_action_fees() { + let platform_version = PlatformVersion::latest(); + let priced = doc_type_with_action_fees( + Some(platform_value!({"create": {"owner": 10_u64, "moderators": 3_u64}})), + platform_version, + ); + let result = priced + .as_ref() + .validate_update(priced.as_ref(), 2, platform_version) + .expect("expected the update to be judged"); + assert!(result.is_valid(), "{:?}", result.errors); + } + /// Like `doc_type_with_immutable`, with an `immutableAllowSetting` list /// as well. fn doc_type_with_immutable_lists( diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index f9e2f70126e..4dcf3fe71ef 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -1,4 +1,5 @@ pub mod accessors; +pub mod action_fees; mod property; pub use property::*; pub mod class_methods; @@ -75,6 +76,11 @@ pub(crate) mod property_names { /// (protocol version 14). See `apply_immutable_fields` in /// `try_from_schema::common` for the structural rules. pub const IMMUTABLE: &str = "immutable"; + /// Doctype-level object declaring a fixed fee in credits for actions on documents of + /// the type, split between the contract's owner pot and its moderators pot. Meta-schema + /// v3+ (protocol version 14). See `parse_action_fees_keyword` in + /// `try_from_schema::common`. + pub const ACTION_FEES: &str = "actionFees"; /// Doctype-level array naming the [`IMMUTABLE`] properties a replace may /// still set when the stored document has no value for them. Once set /// they are frozen like the rest of the list. Every entry must also be in diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs index a2db7cfb9af..e6784475a74 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs @@ -2,6 +2,7 @@ use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV0MutGetters, DocumentTypeV0Setters, DocumentTypeV1Getters, DocumentTypeV2Getters, DocumentTypeV2Setters, }; +use crate::data_contract::document_type::action_fees::DocumentActionFees; use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index_level::IndexLevel; use crate::data_contract::document_type::property::DocumentProperty; @@ -238,6 +239,10 @@ impl DocumentTypeV2Getters for DocumentTypeV2 { fn immutable_fields_allow_setting(&self) -> &BTreeSet { &self.immutable_fields_allow_setting } + + fn action_fees(&self) -> Option<&DocumentActionFees> { + self.action_fees.as_ref() + } } impl DocumentTypeV2Setters for DocumentTypeV2 { diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index a49c5bb12d9..30378753278 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -6,6 +6,7 @@ use crate::data_contract::document_type::index_level::IndexLevel; use crate::data_contract::document_type::property::DocumentProperty; use crate::data_contract::storage_requirements::keys_for_document_type::StorageKeyRequirements; +use crate::data_contract::document_type::action_fees::DocumentActionFees; use crate::data_contract::document_type::methods::{ DocumentTypeBasicMethods, DocumentTypeV0Methods, }; @@ -120,6 +121,10 @@ pub struct DocumentTypeV2 { /// indexed, `$ownerId` recoverable from at least one index, immutable / /// non-transferable / no history, and per-index terminal typing. pub(in crate::data_contract) index_only: bool, + /// The fixed fees in credits this document type charges for actions on its documents + /// (`actionFees` keyword, protocol version 14), `None` when it declares none. Fixed when + /// the document type is published: a contract update cannot add, change or remove them. + pub(in crate::data_contract) action_fees: Option, } impl DocumentTypeBasicMethods for DocumentTypeV2 {} @@ -190,6 +195,7 @@ impl From for DocumentTypeV2 { documents_summable: None, range_summable: false, index_only: false, + action_fees: None, } } } @@ -232,6 +238,7 @@ impl From for DocumentTypeV2 { documents_summable: None, range_summable: false, index_only: false, + action_fees: None, } } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index b2ccd45784a..ff38bb0995c 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -13,7 +13,7 @@ use crate::consensus::basic::contract_group::{ }; use crate::consensus::basic::contract_moderation::{ ContractModerationReasonTooLongError, ContractModerationSelfTargetError, - InvalidContractModerationConfigError, + DocumentActionFeesWithoutModerationError, InvalidContractModerationConfigError, }; use crate::consensus::basic::data_contract::data_contract_max_depth_exceed_error::DataContractMaxDepthExceedError; use crate::consensus::basic::data_contract::{ @@ -796,6 +796,10 @@ pub enum BasicError { #[error(transparent)] ContractModerationReasonTooLongError(ContractModerationReasonTooLongError), + + // Document action fees (protocol version 14). + #[error(transparent)] + DocumentActionFeesWithoutModerationError(DocumentActionFeesWithoutModerationError), } impl From for ConsensusError { @@ -847,7 +851,7 @@ mod tests { )), 188 ); - // Contract moderation (protocol version 14): the tail of the enum. + // Contract moderation (protocol version 14). assert_eq!( discriminant_of(BasicError::InvalidContractModerationConfigError( InvalidContractModerationConfigError::new("reason".to_string()) @@ -866,5 +870,12 @@ mod tests { )), 191 ); + // Document action fees (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(BasicError::DocumentActionFeesWithoutModerationError( + DocumentActionFeesWithoutModerationError::new("post".to_string()) + )), + 192 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/document_action_fees_without_moderation_error.rs b/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/document_action_fees_without_moderation_error.rs new file mode 100644 index 00000000000..7da891bfa6f --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/document_action_fees_without_moderation_error.rs @@ -0,0 +1,51 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Document type {} charges an action fee for the moderators but the contract declares no moderation", + document_type_name +)] +#[platform_serialize(unversioned)] +pub struct DocumentActionFeesWithoutModerationError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_type_name: String, +} + +impl DocumentActionFeesWithoutModerationError { + pub fn new(document_type_name: String) -> Self { + Self { document_type_name } + } + + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } +} + +impl From for ConsensusError { + fn from(err: DocumentActionFeesWithoutModerationError) -> Self { + Self::BasicError(BasicError::DocumentActionFeesWithoutModerationError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/mod.rs index 7037df0d3df..68274deaa41 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/contract_moderation/mod.rs @@ -1,7 +1,9 @@ mod contract_moderation_reason_too_long_error; mod contract_moderation_self_target_error; +mod document_action_fees_without_moderation_error; mod invalid_contract_moderation_config_error; pub use contract_moderation_reason_too_long_error::*; pub use contract_moderation_self_target_error::*; +pub use document_action_fees_without_moderation_error::*; pub use invalid_contract_moderation_config_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 7e06a451b11..1f41a843cb5 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -267,7 +267,7 @@ impl ErrorWithCode for BasicError { // Contract Moderation Errors: 10900-10949 Self::InvalidContractModerationConfigError(_) => 10900, Self::ContractModerationSelfTargetError(_) => 10901, - // 10902 is reserved for the moderation earnings follow-up. + Self::DocumentActionFeesWithoutModerationError(_) => 10902, Self::ContractModerationReasonTooLongError(_) => 10903, } } @@ -457,7 +457,9 @@ impl ErrorWithCode for StateError { Self::ContractUserSuspendedError(_) => 41108, Self::ContractModerationTargetNotFoundError(_) => 41109, Self::ContractModeratorIdentityNotFoundError(_) => 41110, - // 41111 to 41113 are reserved for the moderation earnings follow-up. + Self::ContractFeesAlreadyClaimedThisEpochError(_) => 41111, + Self::ContractFeesNothingToClaimError(_) => 41112, + Self::ContractFeeClaimNotAllowedError(_) => 41113, Self::ContractModerationCounterpartyBarredError(_) => 41114, } } diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_fee_claim_not_allowed_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_fee_claim_not_allowed_error.rs new file mode 100644 index 00000000000..f71dc8aeabc --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_fee_claim_not_allowed_error.rs @@ -0,0 +1,69 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::data_contract::document_type::action_fees::ContractFeePot; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Identity {} is not a recipient of the {} fee pot of contract {} and can not claim it", + identity_id, + pot, + contract_id +)] +#[platform_serialize(unversioned)] +pub struct ContractFeeClaimNotAllowedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + pot: ContractFeePot, + identity_id: Identifier, +} + +impl ContractFeeClaimNotAllowedError { + pub fn new(contract_id: Identifier, pot: ContractFeePot, identity_id: Identifier) -> Self { + Self { + contract_id, + pot, + identity_id, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn pot(&self) -> ContractFeePot { + self.pot + } + + pub fn identity_id(&self) -> Identifier { + self.identity_id + } +} + +impl From for ConsensusError { + fn from(err: ContractFeeClaimNotAllowedError) -> Self { + Self::StateError(StateError::ContractFeeClaimNotAllowedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_fees_already_claimed_this_epoch_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_fees_already_claimed_this_epoch_error.rs new file mode 100644 index 00000000000..914d1aa0e32 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_fees_already_claimed_this_epoch_error.rs @@ -0,0 +1,69 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::data_contract::document_type::action_fees::ContractFeePot; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "The {} fee pot of contract {} was already claimed in epoch {}; a pot is claimed at most once per epoch", + pot, + contract_id, + epoch_index +)] +#[platform_serialize(unversioned)] +pub struct ContractFeesAlreadyClaimedThisEpochError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + pot: ContractFeePot, + epoch_index: u16, +} + +impl ContractFeesAlreadyClaimedThisEpochError { + pub fn new(contract_id: Identifier, pot: ContractFeePot, epoch_index: u16) -> Self { + Self { + contract_id, + pot, + epoch_index, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn pot(&self) -> ContractFeePot { + self.pot + } + + pub fn epoch_index(&self) -> u16 { + self.epoch_index + } +} + +impl From for ConsensusError { + fn from(err: ContractFeesAlreadyClaimedThisEpochError) -> Self { + Self::StateError(StateError::ContractFeesAlreadyClaimedThisEpochError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_fees_nothing_to_claim_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_fees_nothing_to_claim_error.rs new file mode 100644 index 00000000000..f6c461b7737 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/contract_fees_nothing_to_claim_error.rs @@ -0,0 +1,59 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::data_contract::document_type::action_fees::ContractFeePot; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "The {} fee pot of contract {} holds nothing that can be paid out", + pot, + contract_id +)] +#[platform_serialize(unversioned)] +pub struct ContractFeesNothingToClaimError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + pot: ContractFeePot, +} + +impl ContractFeesNothingToClaimError { + pub fn new(contract_id: Identifier, pot: ContractFeePot) -> Self { + Self { contract_id, pot } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn pot(&self) -> ContractFeePot { + self.pot + } +} + +impl From for ConsensusError { + fn from(err: ContractFeesNothingToClaimError) -> Self { + Self::StateError(StateError::ContractFeesNothingToClaimError(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 5e802088795..f92c00a7e1e 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 @@ -1,3 +1,6 @@ +mod contract_fee_claim_not_allowed_error; +mod contract_fees_already_claimed_this_epoch_error; +mod contract_fees_nothing_to_claim_error; mod contract_moderation_counterparty_barred_error; mod contract_moderation_not_enabled_error; mod contract_moderation_target_not_allowed_error; @@ -11,6 +14,9 @@ mod contract_user_not_suspended_error; mod contract_user_suspended_error; mod identity_not_contract_moderator_error; +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_moderation_counterparty_barred_error::*; pub use contract_moderation_not_enabled_error::*; pub use contract_moderation_target_not_allowed_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 03202b07892..0ff032e5d29 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -13,7 +13,9 @@ use crate::consensus::state::shielded::invalid_shielded_proof_error::InvalidShie use crate::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; use crate::consensus::state::contract_moderation::{ ContractModerationNotEnabledError, ContractModerationTargetNotAllowedError, - ContractModerationCounterpartyBarredError, ContractModerationTargetNotFoundError, + ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, + ContractFeesNothingToClaimError, ContractModerationCounterpartyBarredError, + ContractModerationTargetNotFoundError, ContractModeratorIdentityNotFoundError, ContractSuspensionNotInFutureError, ContractUserAlreadyBannedError, ContractUserBannedError, ContractUserNotBannedError, ContractUserNotSuspendedError, ContractUserSuspendedError, @@ -510,6 +512,16 @@ pub enum StateError { #[error(transparent)] ContractModerationCounterpartyBarredError(ContractModerationCounterpartyBarredError), + + // Contract fee claims (protocol version 14). + #[error(transparent)] + ContractFeesAlreadyClaimedThisEpochError(ContractFeesAlreadyClaimedThisEpochError), + + #[error(transparent)] + ContractFeesNothingToClaimError(ContractFeesNothingToClaimError), + + #[error(transparent)] + ContractFeeClaimNotAllowedError(ContractFeeClaimNotAllowedError), } impl From for ConsensusError { @@ -524,6 +536,7 @@ mod tests { 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::document_type::action_fees::ContractFeePot; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; use platform_value::Identifier; @@ -839,5 +852,28 @@ mod tests { )), 125 ); + // Contract fee claims (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::ContractFeesAlreadyClaimedThisEpochError( + ContractFeesAlreadyClaimedThisEpochError::new( + group_id, + ContractFeePot::Moderators, + 7 + ) + )), + 126 + ); + assert_eq!( + discriminant_of(StateError::ContractFeesNothingToClaimError( + ContractFeesNothingToClaimError::new(group_id, ContractFeePot::Owner) + )), + 127 + ); + assert_eq!( + discriminant_of(StateError::ContractFeeClaimNotAllowedError( + ContractFeeClaimNotAllowedError::new(group_id, ContractFeePot::Owner, identity_id) + )), + 128 + ); } } diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 4979d9bd0d6..dfb9706abfa 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -97,6 +97,9 @@ use crate::state_transition::batch_transition::batched_transition::BatchedTransi #[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::resolvers::v0::BatchTransitionResolversV0; use crate::state_transition::batch_transition::{BatchTransition, BatchTransitionSignable}; +use crate::state_transition::contract_fee_claim_transition::{ + ContractFeeClaimTransition, ContractFeeClaimTransitionSignable, +}; use crate::state_transition::contract_user_moderation_transition::{ ContractUserModerationTransition, ContractUserModerationTransitionSignable, }; @@ -189,6 +192,7 @@ macro_rules! call_method { StateTransition::DataContractCreate(st) => st.$method($args), StateTransition::DataContractUpdate(st) => st.$method($args), StateTransition::ContractUserModeration(st) => st.$method($args), + StateTransition::ContractFeeClaim(st) => st.$method($args), StateTransition::Batch(st) => st.$method($args), StateTransition::IdentityCreate(st) => st.$method($args), StateTransition::IdentityTopUp(st) => st.$method($args), @@ -218,6 +222,7 @@ macro_rules! call_method { StateTransition::DataContractCreate(st) => st.$method(), StateTransition::DataContractUpdate(st) => st.$method(), StateTransition::ContractUserModeration(st) => st.$method(), + StateTransition::ContractFeeClaim(st) => st.$method(), StateTransition::Batch(st) => st.$method(), StateTransition::IdentityCreate(st) => st.$method(), StateTransition::IdentityTopUp(st) => st.$method(), @@ -250,6 +255,7 @@ macro_rules! call_getter_method_identity_signed { StateTransition::DataContractCreate(st) => Some(st.$method($args)), StateTransition::DataContractUpdate(st) => Some(st.$method($args)), StateTransition::ContractUserModeration(st) => Some(st.$method($args)), + StateTransition::ContractFeeClaim(st) => Some(st.$method($args)), StateTransition::Batch(st) => Some(st.$method($args)), StateTransition::IdentityCreate(_) => None, StateTransition::IdentityTopUp(_) => None, @@ -279,6 +285,7 @@ macro_rules! call_getter_method_identity_signed { StateTransition::DataContractCreate(st) => Some(st.$method()), StateTransition::DataContractUpdate(st) => Some(st.$method()), StateTransition::ContractUserModeration(st) => Some(st.$method()), + StateTransition::ContractFeeClaim(st) => Some(st.$method()), StateTransition::Batch(st) => Some(st.$method()), StateTransition::IdentityCreate(_) => None, StateTransition::IdentityTopUp(_) => None, @@ -311,6 +318,7 @@ macro_rules! call_method_identity_signed { StateTransition::DataContractCreate(st) => st.$method($args), StateTransition::DataContractUpdate(st) => st.$method($args), StateTransition::ContractUserModeration(st) => st.$method($args), + StateTransition::ContractFeeClaim(st) => st.$method($args), StateTransition::Batch(st) => st.$method($args), StateTransition::IdentityCreate(_st) => {} StateTransition::IdentityTopUp(_st) => {} @@ -340,6 +348,7 @@ macro_rules! call_method_identity_signed { StateTransition::DataContractCreate(st) => st.$method(), StateTransition::DataContractUpdate(st) => st.$method(), StateTransition::ContractUserModeration(st) => st.$method(), + StateTransition::ContractFeeClaim(st) => st.$method(), StateTransition::Batch(st) => st.$method(), StateTransition::IdentityCreate(st) => {} StateTransition::IdentityTopUp(st) => {} @@ -373,6 +382,7 @@ macro_rules! call_errorable_method_identity_signed { StateTransition::DataContractCreate(st) => st.$method($( $arg ),*), StateTransition::DataContractUpdate(st) => st.$method($( $arg ),*), StateTransition::ContractUserModeration(st) => st.$method($( $arg ),*), + StateTransition::ContractFeeClaim(st) => st.$method($( $arg ),*), StateTransition::Batch(st) => st.$method($( $arg ),*), StateTransition::IdentityCreate(_) => Err(ProtocolError::CorruptedCodeExecution( "identity create can not be called for identity signing".to_string(), @@ -430,6 +440,7 @@ macro_rules! call_errorable_method_identity_signed { StateTransition::DataContractCreate(st) => st.$method(), StateTransition::DataContractUpdate(st) => st.$method(), StateTransition::ContractUserModeration(st) => st.$method(), + StateTransition::ContractFeeClaim(st) => st.$method(), StateTransition::Batch(st) => st.$method(), StateTransition::IdentityCreate(_) => Err(ProtocolError::CorruptedCodeExecution( "identity create can not be called for identity signing".to_string(), @@ -549,6 +560,7 @@ pub enum StateTransition { IdentityTopUpFromShieldedPool(IdentityTopUpFromShieldedPoolTransition), IdentityKeyLimitsUpdate(IdentityKeyLimitsUpdateTransition), ContractUserModeration(ContractUserModerationTransition), + ContractFeeClaim(ContractFeeClaimTransition), } #[cfg(all(feature = "json-conversion", feature = "serde-conversion"))] @@ -739,6 +751,14 @@ mod json_convertible_tests { ); } + #[test] + fn umbrella_round_trip_contract_fee_claim() { + let inner = + crate::state_transition::contract_fee_claim_transition::json_convertible_tests::fixture( + ); + assert_umbrella_round_trip(StateTransition::ContractFeeClaim(inner), "contractFeeClaim"); + } + #[test] fn umbrella_identity_credit_transfer() { let inner = crate::state_transition::identity_credit_transfer_transition::json_convertible_tests::fixture(); @@ -1019,7 +1039,8 @@ impl StateTransition { StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityTopUpFromShieldedPool(_) | StateTransition::IdentityKeyLimitsUpdate(_) - | StateTransition::ContractUserModeration(_) => 14..=LATEST_VERSION, + | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) => 14..=LATEST_VERSION, } } @@ -1075,6 +1096,7 @@ impl StateTransition { Self::DataContractCreate(_) => "DataContractCreate".to_string(), Self::DataContractUpdate(_) => "DataContractUpdate".to_string(), Self::ContractUserModeration(_) => "ContractUserModeration".to_string(), + Self::ContractFeeClaim(_) => "ContractFeeClaim".to_string(), Self::Batch(batch_transition) => { let mut document_transition_types = vec![]; for transition in batch_transition.transitions_iter() { @@ -1156,6 +1178,7 @@ impl StateTransition { StateTransition::DataContractCreate(st) => Some(st.signature()), StateTransition::DataContractUpdate(st) => Some(st.signature()), StateTransition::ContractUserModeration(st) => Some(st.signature()), + StateTransition::ContractFeeClaim(st) => Some(st.signature()), StateTransition::Batch(st) => Some(st.signature()), StateTransition::IdentityCreate(st) => Some(st.signature()), StateTransition::IdentityTopUp(st) => Some(st.signature()), @@ -1205,6 +1228,7 @@ impl StateTransition { StateTransition::DataContractCreate(st) => st.user_fee_increase(), StateTransition::DataContractUpdate(st) => st.user_fee_increase(), StateTransition::ContractUserModeration(st) => st.user_fee_increase(), + StateTransition::ContractFeeClaim(st) => st.user_fee_increase(), StateTransition::Batch(st) => st.user_fee_increase(), StateTransition::IdentityCreate(st) => st.user_fee_increase(), StateTransition::IdentityTopUp(st) => st.user_fee_increase(), @@ -1278,6 +1302,7 @@ impl StateTransition { StateTransition::DataContractCreate(st) => Some(st.owner_id()), StateTransition::DataContractUpdate(st) => Some(st.owner_id()), StateTransition::ContractUserModeration(st) => Some(st.owner_id()), + StateTransition::ContractFeeClaim(st) => Some(st.owner_id()), StateTransition::Batch(st) => Some(st.owner_id()), StateTransition::IdentityCreate(st) => Some(st.owner_id()), StateTransition::IdentityTopUp(st) => Some(st.owner_id()), @@ -1309,6 +1334,7 @@ impl StateTransition { StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::Batch(_) | StateTransition::IdentityCreate(_) | StateTransition::IdentityTopUp(_) @@ -1359,6 +1385,10 @@ impl StateTransition { st.set_signature(signature); true } + StateTransition::ContractFeeClaim(st) => { + st.set_signature(signature); + true + } StateTransition::Batch(st) => { st.set_signature(signature); true @@ -1428,6 +1458,7 @@ impl StateTransition { StateTransition::ContractUserModeration(st) => { st.set_user_fee_increase(user_fee_increase) } + StateTransition::ContractFeeClaim(st) => st.set_user_fee_increase(user_fee_increase), StateTransition::Batch(st) => st.set_user_fee_increase(user_fee_increase), StateTransition::IdentityCreate(st) => st.set_user_fee_increase(user_fee_increase), StateTransition::IdentityTopUp(st) => st.set_user_fee_increase(user_fee_increase), @@ -1519,6 +1550,10 @@ impl StateTransition { st.verify_public_key_level_and_purpose(identity_public_key, options)?; st.verify_public_key_is_enabled(identity_public_key)?; } + StateTransition::ContractFeeClaim(st) => { + st.verify_public_key_level_and_purpose(identity_public_key, options)?; + st.verify_public_key_is_enabled(identity_public_key)?; + } StateTransition::Batch(st) => { let allow_token_transfer_keys = st.transitions_len() == 1 && (st @@ -2164,6 +2199,7 @@ impl StateTransitionStructureValidation for StateTransition { StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::Batch(_) | StateTransition::IdentityCreate(_) | StateTransition::IdentityTopUp(_) diff --git a/packages/rs-dpp/src/state_transition/proof_result.rs b/packages/rs-dpp/src/state_transition/proof_result.rs index 160a239d205..ee680f30f99 100644 --- a/packages/rs-dpp/src/state_transition/proof_result.rs +++ b/packages/rs-dpp/src/state_transition/proof_result.rs @@ -1,7 +1,9 @@ use crate::address_funds::PlatformAddress; use crate::asset_lock::StoredAssetLockInfo; use crate::balances::credits::TokenAmount; +use crate::block::epoch::EpochIndex; use crate::data_contract::config::moderation::ContractModerationListStatuses; +use crate::data_contract::document_type::action_fees::ContractFeePot; use crate::data_contract::group::GroupSumPower; use crate::data_contract::DataContract; use crate::document::Document; @@ -135,6 +137,23 @@ pub enum StateTransitionProofResult { /// suspension; an unban, a suspend and an unsuspend prove the one list they edit, and say /// nothing about the other. VerifiedContractModerationListStatuses(Identifier, Identifier, ContractModerationListStatuses), + /// A contract fee claim's execution proof shows the pot it paid out (contract id, pot, the + /// epoch the pot was last claimed in, the credits left in it) and the balance of every + /// identity it paid, after the claim. The epoch is the claim's own: a pot is paid out at + /// most once per epoch, so within that epoch the proof is of this claim. + VerifiedContractFeeClaim( + Identifier, + ContractFeePot, + EpochIndex, + Credits, + #[cfg_attr( + feature = "json-conversion", + serde( + with = "crate::serialization::json::safe_integer_map::json_safe_identifier_u64_map" + ) + )] + BTreeMap, + ), } /// A verified state-transition proof result, tagged with the guarantee the diff --git a/packages/rs-dpp/src/state_transition/state_transition_types.rs b/packages/rs-dpp/src/state_transition/state_transition_types.rs index 08991fad029..04347f1e667 100644 --- a/packages/rs-dpp/src/state_transition/state_transition_types.rs +++ b/packages/rs-dpp/src/state_transition/state_transition_types.rs @@ -46,6 +46,7 @@ pub enum StateTransitionType { IdentityTopUpFromShieldedPool = 22, IdentityKeyLimitsUpdate = 23, ContractUserModeration = 24, + ContractFeeClaim = 25, } impl std::fmt::Display for StateTransitionType { @@ -144,6 +145,7 @@ mod tests { StateTransitionType::ContractUserModeration, "ContractUserModeration", ), + (StateTransitionType::ContractFeeClaim, "ContractFeeClaim"), ]; for (variant, expected) in cases { assert_eq!( @@ -183,6 +185,7 @@ mod tests { (22, StateTransitionType::IdentityTopUpFromShieldedPool), (23, StateTransitionType::IdentityKeyLimitsUpdate), (24, StateTransitionType::ContractUserModeration), + (25, StateTransitionType::ContractFeeClaim), ]; for (val, expected) in pairs { let result = StateTransitionType::try_from(val).unwrap(); @@ -192,7 +195,7 @@ mod tests { #[test] fn test_try_from_u8_invalid() { - assert!(StateTransitionType::try_from(25u8).is_err()); + assert!(StateTransitionType::try_from(26u8).is_err()); assert!(StateTransitionType::try_from(255u8).is_err()); } @@ -224,6 +227,7 @@ mod tests { StateTransitionType::IdentityTopUpFromShieldedPool, StateTransitionType::IdentityKeyLimitsUpdate, StateTransitionType::ContractUserModeration, + StateTransitionType::ContractFeeClaim, ]; for variant in all_variants { let val: u8 = variant.into(); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/accessors/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/accessors/mod.rs new file mode 100644 index 00000000000..0f1e2ef555a --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/accessors/mod.rs @@ -0,0 +1,53 @@ +mod v0; + +use crate::data_contract::document_type::action_fees::ContractFeePot; +use crate::prelude::IdentityNonce; +use crate::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use platform_value::Identifier; +pub use v0::*; + +impl ContractFeeClaimTransitionAccessorsV0 for ContractFeeClaimTransition { + fn set_owner_id(&mut self, id: Identifier) { + match self { + ContractFeeClaimTransition::V0(transition) => transition.set_owner_id(id), + } + } + + fn set_data_contract_id(&mut self, id: Identifier) { + match self { + ContractFeeClaimTransition::V0(transition) => transition.set_data_contract_id(id), + } + } + + fn data_contract_id(&self) -> Identifier { + match self { + ContractFeeClaimTransition::V0(transition) => transition.data_contract_id(), + } + } + + fn set_identity_contract_nonce(&mut self, nonce: IdentityNonce) { + match self { + ContractFeeClaimTransition::V0(transition) => { + transition.set_identity_contract_nonce(nonce) + } + } + } + + fn identity_contract_nonce(&self) -> IdentityNonce { + match self { + ContractFeeClaimTransition::V0(transition) => transition.identity_contract_nonce(), + } + } + + fn set_pot(&mut self, pot: ContractFeePot) { + match self { + ContractFeeClaimTransition::V0(transition) => transition.set_pot(pot), + } + } + + fn pot(&self) -> ContractFeePot { + match self { + ContractFeeClaimTransition::V0(transition) => transition.pot(), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/accessors/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/accessors/v0/mod.rs new file mode 100644 index 00000000000..3540f3c2b63 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/accessors/v0/mod.rs @@ -0,0 +1,15 @@ +use crate::data_contract::document_type::action_fees::ContractFeePot; +use crate::prelude::{Identifier, IdentityNonce}; + +pub trait ContractFeeClaimTransitionAccessorsV0 { + fn set_owner_id(&mut self, id: Identifier); + fn set_data_contract_id(&mut self, id: Identifier); + /// The contract whose pot is paid out + fn data_contract_id(&self) -> Identifier; + fn set_identity_contract_nonce(&mut self, nonce: IdentityNonce); + /// The signer's nonce for the contract + fn identity_contract_nonce(&self) -> IdentityNonce; + fn set_pot(&mut self, pot: ContractFeePot); + /// The pot that is paid out + fn pot(&self) -> ContractFeePot; +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/fields.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/fields.rs new file mode 100644 index 00000000000..28e3e4563d6 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/fields.rs @@ -0,0 +1,18 @@ +use crate::state_transition::state_transitions; + +pub use state_transitions::common_fields::property_names::{ + SIGNATURE, SIGNATURE_PUBLIC_KEY_ID, STATE_TRANSITION_PROTOCOL_VERSION, TRANSITION_TYPE, +}; + +pub mod property_names { + pub const OWNER_ID: &str = "ownerId"; + pub const DATA_CONTRACT_ID: &str = "dataContractId"; + pub const IDENTITY_CONTRACT_NONCE: &str = "identityContractNonce"; + pub const POT: &str = "pot"; +} + +pub use property_names::{DATA_CONTRACT_ID, OWNER_ID}; + +pub const IDENTIFIER_FIELDS: [&str; 2] = [OWNER_ID, DATA_CONTRACT_ID]; +pub const BINARY_FIELDS: [&str; 1] = [SIGNATURE]; +pub const U32_FIELDS: [&str; 1] = [STATE_TRANSITION_PROTOCOL_VERSION]; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/identity_signed.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/identity_signed.rs new file mode 100644 index 00000000000..8d55ee1b0e0 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/identity_signed.rs @@ -0,0 +1,27 @@ +use crate::identity::{KeyID, Purpose, SecurityLevel}; +use crate::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use crate::state_transition::StateTransitionIdentitySigned; + +impl StateTransitionIdentitySigned for ContractFeeClaimTransition { + fn signature_public_key_id(&self) -> KeyID { + match self { + ContractFeeClaimTransition::V0(transition) => transition.signature_public_key_id(), + } + } + + fn set_signature_public_key_id(&mut self, key_id: KeyID) { + match self { + ContractFeeClaimTransition::V0(transition) => { + transition.set_signature_public_key_id(key_id) + } + } + } + + fn security_level_requirement(&self, purpose: Purpose) -> Vec { + match self { + ContractFeeClaimTransition::V0(transition) => { + transition.security_level_requirement(purpose) + } + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/methods/mod.rs new file mode 100644 index 00000000000..016b2410faf --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/methods/mod.rs @@ -0,0 +1,61 @@ +mod v0; +pub use v0::*; + +#[cfg(feature = "state-transition-signing")] +use crate::data_contract::document_type::action_fees::ContractFeePot; +#[cfg(feature = "state-transition-signing")] +use crate::identity::signer::Signer; +#[cfg(feature = "state-transition-signing")] +use crate::identity::{Identity, IdentityPublicKey, KeyID}; +#[cfg(feature = "state-transition-signing")] +use crate::prelude::{Identifier, IdentityNonce, UserFeeIncrease}; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0; +use crate::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::StateTransition; +#[cfg(feature = "state-transition-signing")] +use crate::version::FeatureVersion; +#[cfg(feature = "state-transition-signing")] +use crate::ProtocolError; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +impl ContractFeeClaimTransitionMethodsV0 for ContractFeeClaimTransition { + #[cfg(feature = "state-transition-signing")] + async fn try_from_identity_with_signer>( + identity: &Identity, + signing_key_id: &KeyID, + data_contract_id: Identifier, + pot: ContractFeePot, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + version: Option, + ) -> Result { + match version.unwrap_or( + platform_version + .dpp + .state_transition_serialization_versions + .contract_fee_claim_state_transition + .default_current_version, + ) { + 0 => Ok(ContractFeeClaimTransitionV0::try_from_identity_with_signer( + identity, + signing_key_id, + data_contract_id, + pot, + identity_contract_nonce, + user_fee_increase, + signer, + platform_version, + version, + ) + .await?), + v => Err(ProtocolError::UnknownVersionError(format!( + "Unknown ContractFeeClaimTransition version for try_from_identity_with_signer {v}" + ))), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/methods/v0/mod.rs new file mode 100644 index 00000000000..cf8225ba1c7 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/methods/v0/mod.rs @@ -0,0 +1,42 @@ +#[cfg(feature = "state-transition-signing")] +use crate::data_contract::document_type::action_fees::ContractFeePot; +#[cfg(feature = "state-transition-signing")] +use crate::identity::signer::Signer; +#[cfg(feature = "state-transition-signing")] +use crate::identity::{Identity, IdentityPublicKey, KeyID}; +#[cfg(feature = "state-transition-signing")] +use crate::prelude::{Identifier, IdentityNonce, UserFeeIncrease}; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::StateTransition; +use crate::state_transition::StateTransitionType; +#[cfg(feature = "state-transition-signing")] +use crate::version::FeatureVersion; +#[cfg(feature = "state-transition-signing")] +use crate::ProtocolError; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +pub trait ContractFeeClaimTransitionMethodsV0 { + /// Builds and signs the transition for `identity`, the claimant: the contract owner for + /// the owner pot, a member of the moderation team for the moderators pot. `signing_key_id` must + /// name a CRITICAL authentication key without contract bounds that `signer` holds the + /// private key for. `identity_contract_nonce` is the signer's next nonce for the contract. + #[cfg(feature = "state-transition-signing")] + #[allow(clippy::too_many_arguments)] + async fn try_from_identity_with_signer>( + identity: &Identity, + signing_key_id: &KeyID, + data_contract_id: Identifier, + pot: ContractFeePot, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + platform_version: &PlatformVersion, + version: Option, + ) -> Result; + + /// Get State Transition Type + fn get_type() -> StateTransitionType { + StateTransitionType::ContractFeeClaim + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/mod.rs new file mode 100644 index 00000000000..b3d9b50cdda --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/mod.rs @@ -0,0 +1,307 @@ +//! Pays out one of the two fee pots of a data contract (protocol version 14). +//! +//! A document type may charge a fixed fee in credits for actions on its documents (the +//! `actionFees` keyword). The `owner` parts collect in the contract's owner pot and the +//! `moderators` parts in its moderators pot. This transition names a pot and pays it out: the +//! owner pot to the contract owner, who alone may claim it, and the moderators pot, which any +//! member of the contract's moderation team may claim, in equal shares to the whole team. What +//! an equal split leaves over stays in the pot. +//! +//! A pot is paid out at most once per epoch; the two pots are independent. Signed with a +//! CRITICAL authentication key, and paid for by the claimant like any other transition. + +pub mod accessors; +pub mod fields; +mod identity_signed; +pub mod methods; +mod state_transition_estimated_fee_validation; +mod state_transition_like; +pub mod v0; +mod version; + +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonConvertible; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +use crate::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0; +use crate::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0Signable; +use crate::state_transition::StateTransitionFieldTypes; +use fields::*; + +use crate::identity::state_transition::OptionallyAssetLockProved; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::From; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, PlatformSignable, +}; +use platform_version::version::PlatformVersion; +use platform_versioning::PlatformVersioned; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +pub use crate::data_contract::document_type::action_fees::ContractFeePot; + +#[cfg_attr( + all(feature = "json-conversion", feature = "serde-conversion"), + derive(JsonConvertible) +)] +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + PlatformSerialize, + PlatformSignable, + PlatformVersioned, + From, + PartialEq, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +#[platform_serialize(unversioned)] //versioned directly, no need to use platform_version +#[platform_version_path_bounds( + "dpp.state_transition_serialization_versions.contract_fee_claim_state_transition" +)] +pub enum ContractFeeClaimTransition { + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(ContractFeeClaimTransitionV0), +} + +impl ContractFeeClaimTransition { + pub fn default_versioned(platform_version: &PlatformVersion) -> Result { + match platform_version + .dpp + .state_transition_serialization_versions + .contract_fee_claim_state_transition + .default_current_version + { + 0 => Ok(ContractFeeClaimTransition::V0( + ContractFeeClaimTransitionV0::default(), + )), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "ContractFeeClaimTransition::default_versioned".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} + +impl OptionallyAssetLockProved for ContractFeeClaimTransition {} + +impl StateTransitionFieldTypes for ContractFeeClaimTransition { + fn binary_property_paths() -> Vec<&'static str> { + vec![SIGNATURE] + } + + fn identifiers_property_paths() -> Vec<&'static str> { + vec![OWNER_ID, DATA_CONTRACT_ID] + } + + fn signature_property_paths() -> Vec<&'static str> { + vec![SIGNATURE, SIGNATURE_PUBLIC_KEY_ID] + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; + use crate::state_transition::contract_fee_claim_transition::accessors::ContractFeeClaimTransitionAccessorsV0; + use crate::state_transition::{ + StateTransitionEstimatedFeeValidation, StateTransitionHasUserFeeIncrease, + StateTransitionIdentityEstimatedFeeValidation, StateTransitionLike, StateTransitionOwned, + StateTransitionSingleSigned, StateTransitionType, + }; + use crate::version::LATEST_PLATFORM_VERSION; + use platform_value::{BinaryData, Identifier}; + + fn make() -> ContractFeeClaimTransition { + ContractFeeClaimTransition::V0(ContractFeeClaimTransitionV0 { + owner_id: Identifier::random(), + data_contract_id: Identifier::random(), + identity_contract_nonce: 10, + pot: ContractFeePot::Moderators, + user_fee_increase: 2, + signature_public_key_id: 0, + signature: [0u8; 65].to_vec().into(), + }) + } + + #[test] + fn should_create_the_default_version() { + let t = ContractFeeClaimTransition::default_versioned(LATEST_PLATFORM_VERSION) + .expect("should create default"); + match t { + ContractFeeClaimTransition::V0(_) => {} + } + } + + #[test] + fn should_round_trip_through_bytes_for_every_pot() { + for pot in [ContractFeePot::Owner, ContractFeePot::Moderators] { + let mut t = make(); + t.set_pot(pot); + let bytes = t.serialize_to_bytes().expect("should serialize"); + let restored = ContractFeeClaimTransition::deserialize_from_bytes_untrusted(&bytes) + .expect("should deserialize"); + assert_eq!(t, restored); + assert_eq!(restored.pot(), pot); + } + } + + #[test] + fn should_report_its_type_and_identifiers() { + let t = make(); + assert_eq!( + t.state_transition_type(), + StateTransitionType::ContractFeeClaim + ); + assert_eq!(t.state_transition_protocol_version(), 0); + assert_eq!(t.modified_data_ids(), vec![t.data_contract_id()]); + assert_eq!(t.unique_identifiers().len(), 1); + let ContractFeeClaimTransition::V0(v0) = &t; + assert_eq!(t.owner_id(), v0.owner_id); + assert_eq!(t.pot(), ContractFeePot::Moderators); + } + + #[test] + fn should_expose_the_user_fee_increase_and_signature() { + let mut t = make(); + assert_eq!(t.user_fee_increase(), 2); + t.set_user_fee_increase(50); + assert_eq!(t.user_fee_increase(), 50); + assert_eq!(t.signature().len(), 65); + t.set_signature(BinaryData::new(vec![1, 2])); + assert_eq!(t.signature().as_slice(), &[1, 2]); + t.set_signature_bytes(vec![3, 4]); + assert_eq!(t.signature().as_slice(), &[3, 4]); + } + + #[test] + fn should_expose_the_accessors() { + let mut t = make(); + assert_eq!(t.identity_contract_nonce(), 10); + t.set_identity_contract_nonce(20); + assert_eq!(t.identity_contract_nonce(), 20); + let contract_id = Identifier::random(); + t.set_data_contract_id(contract_id); + assert_eq!(t.data_contract_id(), contract_id); + let owner_id = Identifier::random(); + t.set_owner_id(owner_id); + assert_eq!(t.owner_id(), owner_id); + } + + #[test] + fn should_list_its_field_types() { + assert_eq!( + ContractFeeClaimTransition::signature_property_paths().len(), + 2 + ); + assert_eq!( + ContractFeeClaimTransition::identifiers_property_paths().len(), + 2 + ); + assert_eq!(ContractFeeClaimTransition::binary_property_paths().len(), 1); + } + + #[test] + fn should_require_the_credit_transfer_minimum_fee() { + let t = make(); + let fee = t + .calculate_min_required_fee(LATEST_PLATFORM_VERSION) + .expect("fee calc should work"); + assert_eq!( + fee, + LATEST_PLATFORM_VERSION + .fee_version + .state_transition_min_fees + .credit_transfer + ); + assert!(t + .validate_estimated_fee(fee, LATEST_PLATFORM_VERSION) + .expect("validation should work") + .is_valid()); + assert!(!t + .validate_estimated_fee(fee - 1, LATEST_PLATFORM_VERSION) + .expect("validation should work") + .is_valid()); + } +} + +#[cfg(all( + test, + feature = "json-conversion", + feature = "value-conversion", + feature = "serde-conversion" +))] +pub(crate) mod json_convertible_tests { + use super::*; + + use platform_value::{platform_value, BinaryData, Identifier}; + use serde_json::json; + + pub(crate) fn fixture() -> ContractFeeClaimTransition { + ContractFeeClaimTransition::V0(ContractFeeClaimTransitionV0 { + owner_id: Identifier::new([0x55; 32]), + data_contract_id: Identifier::new([0x66; 32]), + identity_contract_nonce: 17, + pot: ContractFeePot::Moderators, + user_fee_increase: 4, + signature_public_key_id: 6, + signature: BinaryData::new(vec![0xd4; 65]), + }) + } + + #[test] + fn json_round_trip_with_full_wire_shape() { + use crate::serialization::JsonConvertible; + let original = fixture(); + let json = original.to_json().expect("to_json"); + assert_eq!( + json, + json!({ + "$formatVersion": "0", + "ownerId": Identifier::new([0x55; 32]), + "dataContractId": Identifier::new([0x66; 32]), + "identityContractNonce": 17, + "pot": "moderators", + "userFeeIncrease": 4, + "signaturePublicKeyId": 6, + "signature": "1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NTU1NQ=", + }) + ); + let recovered = ContractFeeClaimTransition::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn value_round_trip_with_full_wire_shape() { + use crate::serialization::ValueConvertible; + let original = fixture(); + let value = original.to_object().expect("to_object"); + assert_eq!( + value, + platform_value!({ + "$formatVersion": "0", + "ownerId": Identifier::new([0x55; 32]), + "dataContractId": Identifier::new([0x66; 32]), + "identityContractNonce": 17u64, + "pot": "moderators", + "userFeeIncrease": 4u16, + "signaturePublicKeyId": 6u32, + "signature": BinaryData::new(vec![0xd4; 65]), + }) + ); + let recovered = ContractFeeClaimTransition::from_object(value).expect("from_object"); + assert_eq!(original, recovered); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/state_transition_estimated_fee_validation.rs new file mode 100644 index 00000000000..8638c2f5c46 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/state_transition_estimated_fee_validation.rs @@ -0,0 +1,47 @@ +use crate::consensus::state::identity::IdentityInsufficientBalanceError; +use crate::fee::Credits; +use crate::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use crate::state_transition::{ + StateTransitionEstimatedFeeValidation, StateTransitionIdentityEstimatedFeeValidation, + StateTransitionOwned, +}; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +impl StateTransitionEstimatedFeeValidation for ContractFeeClaimTransition { + /// A claim credits balances as a credit transfer does, so that floor is reused rather than + /// a new slot added to the shipped fee schedules. + fn calculate_min_required_fee( + &self, + platform_version: &PlatformVersion, + ) -> Result { + Ok(platform_version + .fee_version + .state_transition_min_fees + .credit_transfer) + } +} + +impl StateTransitionIdentityEstimatedFeeValidation for ContractFeeClaimTransition { + fn validate_estimated_fee( + &self, + identity_known_balance: Credits, + platform_version: &PlatformVersion, + ) -> Result { + let required_fee = self.calculate_min_required_fee(platform_version)?; + + if identity_known_balance < required_fee { + return Ok(SimpleConsensusValidationResult::new_with_error( + IdentityInsufficientBalanceError::new( + self.owner_id(), + identity_known_balance, + required_fee, + ) + .into(), + )); + } + + Ok(SimpleConsensusValidationResult::new()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/state_transition_like.rs new file mode 100644 index 00000000000..060411b044f --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/state_transition_like.rs @@ -0,0 +1,82 @@ +use crate::prelude::UserFeeIncrease; +use crate::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use crate::state_transition::StateTransitionHasUserFeeIncrease; +use crate::state_transition::{ + StateTransitionLike, StateTransitionOwned, StateTransitionSingleSigned, StateTransitionType, +}; +use crate::version::FeatureVersion; +use platform_value::{BinaryData, Identifier}; + +impl StateTransitionLike for ContractFeeClaimTransition { + /// Returns the ID of the contract whose pot is paid out + fn modified_data_ids(&self) -> Vec { + match self { + ContractFeeClaimTransition::V0(transition) => transition.modified_data_ids(), + } + } + + fn state_transition_protocol_version(&self) -> FeatureVersion { + match self { + ContractFeeClaimTransition::V0(_) => 0, + } + } + + /// returns the type of State Transition + fn state_transition_type(&self) -> StateTransitionType { + match self { + ContractFeeClaimTransition::V0(transition) => transition.state_transition_type(), + } + } + + fn unique_identifiers(&self) -> Vec { + match self { + ContractFeeClaimTransition::V0(transition) => transition.unique_identifiers(), + } + } +} + +impl StateTransitionHasUserFeeIncrease for ContractFeeClaimTransition { + fn user_fee_increase(&self) -> UserFeeIncrease { + match self { + ContractFeeClaimTransition::V0(transition) => transition.user_fee_increase(), + } + } + + fn set_user_fee_increase(&mut self, user_fee_increase: UserFeeIncrease) { + match self { + ContractFeeClaimTransition::V0(transition) => { + transition.set_user_fee_increase(user_fee_increase) + } + } + } +} + +impl StateTransitionSingleSigned for ContractFeeClaimTransition { + /// returns the signature as a byte-array + fn signature(&self) -> &BinaryData { + match self { + ContractFeeClaimTransition::V0(transition) => transition.signature(), + } + } + + /// set a new signature + fn set_signature(&mut self, signature: BinaryData) { + match self { + ContractFeeClaimTransition::V0(transition) => transition.set_signature(signature), + } + } + + fn set_signature_bytes(&mut self, signature: Vec) { + match self { + ContractFeeClaimTransition::V0(transition) => transition.set_signature_bytes(signature), + } + } +} + +impl StateTransitionOwned for ContractFeeClaimTransition { + fn owner_id(&self) -> Identifier { + match self { + ContractFeeClaimTransition::V0(transition) => transition.owner_id(), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/identity_signed.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/identity_signed.rs new file mode 100644 index 00000000000..d05e6a6409d --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/identity_signed.rs @@ -0,0 +1,19 @@ +use crate::identity::SecurityLevel::CRITICAL; +use crate::identity::{KeyID, Purpose, SecurityLevel}; +use crate::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0; +use crate::state_transition::StateTransitionIdentitySigned; + +impl StateTransitionIdentitySigned for ContractFeeClaimTransitionV0 { + fn signature_public_key_id(&self) -> KeyID { + self.signature_public_key_id + } + + fn set_signature_public_key_id(&mut self, key_id: KeyID) { + self.signature_public_key_id = key_id + } + + /// A claim moves credits out of a contract's pot: a CRITICAL key only, as for moderation. + fn security_level_requirement(&self, _purpose: Purpose) -> Vec { + vec![CRITICAL] + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/mod.rs new file mode 100644 index 00000000000..13ee7a4b5ad --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/mod.rs @@ -0,0 +1,140 @@ +mod identity_signed; +mod state_transition_like; +mod types; +pub(super) mod v0_methods; +mod version; + +#[cfg(feature = "json-conversion")] +use crate::serialization::json_safe_fields; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::PlatformSignable; +use platform_value::BinaryData; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +use crate::data_contract::document_type::action_fees::ContractFeePot; +use crate::identity::KeyID; +use crate::prelude::{Identifier, IdentityNonce, UserFeeIncrease}; +use crate::ProtocolError; + +/// Pays out one of the two fee pots a data contract's document action fees accumulate in. +/// +/// The owner pot is claimed by the contract owner, who receives all of it. The moderators pot +/// is claimed by any member of the contract's moderation team and split equally between the +/// team. A pot is paid out at most once per epoch. Signed with a CRITICAL authentication key, +/// under the signer's contract-scoped nonce. +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[derive(Encode, Decode, PlatformSignable, Debug, Clone, PartialEq, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub struct ContractFeeClaimTransitionV0 { + /// The claimant: the identity that signs. + pub owner_id: Identifier, + + /// The contract whose pot is paid out. + pub data_contract_id: Identifier, + + /// The signer's nonce for this contract, to prevent replay attacks. + pub identity_contract_nonce: IdentityNonce, + + /// The pot that is paid out. + pub pot: ContractFeePot, + + /// The fee multiplier + pub user_fee_increase: UserFeeIncrease, + + /// The ID of the public key used to sign the State Transition + #[platform_signable(exclude_from_sig_hash)] + pub signature_public_key_id: KeyID, + /// Cryptographic signature of the State Transition + #[platform_signable(exclude_from_sig_hash)] + pub signature: BinaryData, +} + +impl Default for ContractFeeClaimTransitionV0 { + fn default() -> Self { + ContractFeeClaimTransitionV0 { + owner_id: Identifier::default(), + data_contract_id: Identifier::default(), + identity_contract_nonce: 0, + pot: ContractFeePot::Owner, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: BinaryData::default(), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::serialization::Signable; + use crate::state_transition::{ + StateTransition, StateTransitionHasUserFeeIncrease, StateTransitionIdentitySigned, + StateTransitionLike, StateTransitionOwned, StateTransitionSingleSigned, + StateTransitionType, + }; + + fn make_v0() -> ContractFeeClaimTransitionV0 { + ContractFeeClaimTransitionV0 { + owner_id: Identifier::random(), + data_contract_id: Identifier::random(), + identity_contract_nonce: 3, + pot: ContractFeePot::Moderators, + user_fee_increase: 1, + signature_public_key_id: 2, + signature: BinaryData::new(vec![9; 65]), + } + } + + #[test] + fn should_exclude_the_signature_from_the_signable_bytes() { + let a = make_v0(); + let mut b = a.clone(); + b.signature = BinaryData::new(vec![1; 65]); + b.signature_public_key_id = 7; + assert_eq!( + a.signable_bytes().expect("signable"), + b.signable_bytes().expect("signable") + ); + } + + #[test] + fn should_sign_the_pot() { + // A signature for one pot must not be replayable as a claim of the other. + let a = make_v0(); + let mut b = a.clone(); + b.pot = ContractFeePot::Owner; + assert_ne!( + a.signable_bytes().expect("signable"), + b.signable_bytes().expect("signable") + ); + } + + #[test] + fn should_describe_itself() { + let mut t = make_v0(); + assert_eq!( + t.state_transition_type(), + StateTransitionType::ContractFeeClaim + ); + assert_eq!(t.modified_data_ids(), vec![t.data_contract_id]); + assert_eq!(t.owner_id(), t.owner_id); + assert_eq!(t.signature_public_key_id(), 2); + t.set_signature_public_key_id(4); + assert_eq!(t.signature_public_key_id(), 4); + assert_eq!(t.user_fee_increase(), 1); + t.set_user_fee_increase(9); + assert_eq!(t.user_fee_increase(), 9); + assert_eq!(t.signature().len(), 65); + assert_eq!( + t.security_level_requirement(crate::identity::Purpose::AUTHENTICATION), + vec![crate::identity::SecurityLevel::CRITICAL] + ); + let outer: StateTransition = t.into(); + assert!(matches!(outer, StateTransition::ContractFeeClaim(_))); + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/state_transition_like.rs new file mode 100644 index 00000000000..6d427b5b8ab --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/state_transition_like.rs @@ -0,0 +1,83 @@ +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use platform_value::BinaryData; + +use crate::prelude::UserFeeIncrease; +use crate::state_transition::StateTransitionHasUserFeeIncrease; +use crate::{ + prelude::Identifier, + state_transition::{StateTransitionLike, StateTransitionOwned, StateTransitionType}, +}; + +use crate::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0; +use crate::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; + +use crate::state_transition::StateTransitionType::ContractFeeClaim; +use crate::state_transition::{StateTransition, StateTransitionSingleSigned}; +use crate::version::FeatureVersion; + +impl From for StateTransition { + fn from(value: ContractFeeClaimTransitionV0) -> Self { + let transition: ContractFeeClaimTransition = value.into(); + transition.into() + } +} + +impl StateTransitionLike for ContractFeeClaimTransitionV0 { + fn state_transition_protocol_version(&self) -> FeatureVersion { + 0 + } + + /// returns the type of State Transition + fn state_transition_type(&self) -> StateTransitionType { + ContractFeeClaim + } + + /// Returns the ID of the contract whose pot is paid out + fn modified_data_ids(&self) -> Vec { + vec![self.data_contract_id] + } + + /// Unique per signer, contract and contract nonce, as a contract update is. + fn unique_identifiers(&self) -> Vec { + vec![format!( + "{}-{}-{:x}", + BASE64_STANDARD.encode(self.owner_id), + BASE64_STANDARD.encode(self.data_contract_id), + self.identity_contract_nonce + )] + } +} + +impl StateTransitionHasUserFeeIncrease for ContractFeeClaimTransitionV0 { + fn user_fee_increase(&self) -> UserFeeIncrease { + self.user_fee_increase + } + + fn set_user_fee_increase(&mut self, user_fee_increase: UserFeeIncrease) { + self.user_fee_increase = user_fee_increase + } +} + +impl StateTransitionSingleSigned for ContractFeeClaimTransitionV0 { + /// returns the signature as a byte-array + fn signature(&self) -> &BinaryData { + &self.signature + } + + /// set a new signature + fn set_signature(&mut self, signature: BinaryData) { + self.signature = signature + } + + fn set_signature_bytes(&mut self, signature: Vec) { + self.signature = BinaryData::new(signature) + } +} + +impl StateTransitionOwned for ContractFeeClaimTransitionV0 { + /// Get owner ID + fn owner_id(&self) -> Identifier { + self.owner_id + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/types.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/types.rs new file mode 100644 index 00000000000..6be83fd2ad9 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/types.rs @@ -0,0 +1,17 @@ +use crate::state_transition::contract_fee_claim_transition::fields::*; +use crate::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0; +use crate::state_transition::StateTransitionFieldTypes; + +impl StateTransitionFieldTypes for ContractFeeClaimTransitionV0 { + fn binary_property_paths() -> Vec<&'static str> { + vec![SIGNATURE] + } + + fn identifiers_property_paths() -> Vec<&'static str> { + vec![OWNER_ID, DATA_CONTRACT_ID] + } + + fn signature_property_paths() -> Vec<&'static str> { + vec![SIGNATURE, SIGNATURE_PUBLIC_KEY_ID] + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/v0_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/v0_methods.rs new file mode 100644 index 00000000000..5d4d6409f2f --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/v0_methods.rs @@ -0,0 +1,99 @@ +#[cfg(feature = "state-transition-signing")] +use crate::consensus::signature::{MissingPublicKeyError, SignatureError}; +#[cfg(feature = "state-transition-signing")] +use crate::consensus::ConsensusError; +use crate::data_contract::document_type::action_fees::ContractFeePot; +#[cfg(feature = "state-transition-signing")] +use crate::identity::accessors::IdentityGettersV0; +#[cfg(feature = "state-transition-signing")] +use crate::identity::signer::Signer; +#[cfg(feature = "state-transition-signing")] +use crate::identity::{Identity, IdentityPublicKey, KeyID}; +#[cfg(feature = "state-transition-signing")] +use crate::prelude::UserFeeIncrease; +use crate::prelude::{Identifier, IdentityNonce}; +use crate::state_transition::contract_fee_claim_transition::accessors::ContractFeeClaimTransitionAccessorsV0; +use crate::state_transition::contract_fee_claim_transition::methods::ContractFeeClaimTransitionMethodsV0; +use crate::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0; +#[cfg(feature = "state-transition-signing")] +use crate::state_transition::{GetDataContractSecurityLevelRequirementFn, StateTransition}; +#[cfg(feature = "state-transition-signing")] +use crate::version::FeatureVersion; +#[cfg(feature = "state-transition-signing")] +use crate::ProtocolError; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +impl ContractFeeClaimTransitionMethodsV0 for ContractFeeClaimTransitionV0 { + #[cfg(feature = "state-transition-signing")] + async fn try_from_identity_with_signer>( + identity: &Identity, + signing_key_id: &KeyID, + data_contract_id: Identifier, + pot: ContractFeePot, + identity_contract_nonce: IdentityNonce, + user_fee_increase: UserFeeIncrease, + signer: &S, + _platform_version: &PlatformVersion, + _version: Option, + ) -> Result { + let signing_key = identity + .public_keys() + .get(signing_key_id) + .ok_or::( + SignatureError::MissingPublicKeyError(MissingPublicKeyError::new(*signing_key_id)) + .into(), + )?; + + let mut state_transition: StateTransition = ContractFeeClaimTransitionV0 { + owner_id: identity.id(), + data_contract_id, + identity_contract_nonce, + pot, + user_fee_increase, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + + state_transition + .sign_external( + signing_key, + signer, + None::, + ) + .await?; + + Ok(state_transition) + } +} + +impl ContractFeeClaimTransitionAccessorsV0 for ContractFeeClaimTransitionV0 { + fn set_owner_id(&mut self, id: Identifier) { + self.owner_id = id; + } + + fn set_data_contract_id(&mut self, id: Identifier) { + self.data_contract_id = id; + } + + fn data_contract_id(&self) -> Identifier { + self.data_contract_id + } + + fn set_identity_contract_nonce(&mut self, nonce: IdentityNonce) { + self.identity_contract_nonce = nonce; + } + + fn identity_contract_nonce(&self) -> IdentityNonce { + self.identity_contract_nonce + } + + fn set_pot(&mut self, pot: ContractFeePot) { + self.pot = pot; + } + + fn pot(&self) -> ContractFeePot { + self.pot + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/version.rs new file mode 100644 index 00000000000..ad93e25469a --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/v0/version.rs @@ -0,0 +1,9 @@ +use crate::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0; +use crate::state_transition::FeatureVersioned; +use crate::version::FeatureVersion; + +impl FeatureVersioned for ContractFeeClaimTransitionV0 { + fn feature_version(&self) -> FeatureVersion { + 0 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/version.rs new file mode 100644 index 00000000000..e78fe6884cc --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/contract_fee_claim_transition/version.rs @@ -0,0 +1,11 @@ +use crate::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use crate::state_transition::FeatureVersioned; +use crate::version::FeatureVersion; + +impl FeatureVersioned for ContractFeeClaimTransition { + fn feature_version(&self) -> FeatureVersion { + match self { + ContractFeeClaimTransition::V0(v0) => v0.feature_version(), + } + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/contract/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/contract/mod.rs index 8b418d8b6b9..faede005f73 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/contract/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/contract/mod.rs @@ -1,5 +1,6 @@ mod common_fields; +pub mod contract_fee_claim_transition; pub mod contract_user_moderation_transition; pub mod data_contract_create_transition; pub mod data_contract_update_transition; diff --git a/packages/rs-dpp/src/state_transition/traits/state_transition_like.rs b/packages/rs-dpp/src/state_transition/traits/state_transition_like.rs index cc9f16c9068..6331f25c5a5 100644 --- a/packages/rs-dpp/src/state_transition/traits/state_transition_like.rs +++ b/packages/rs-dpp/src/state_transition/traits/state_transition_like.rs @@ -24,10 +24,11 @@ pub const IDENTITY_TRANSITION_TYPE: [StateTransitionType; 11] = [ pub const VOTING_TRANSITION_TYPE: [StateTransitionType; 1] = [StateTransitionType::MasternodeVote]; -pub const DATA_CONTRACT_TRANSITION_TYPES: [StateTransitionType; 3] = [ +pub const DATA_CONTRACT_TRANSITION_TYPES: [StateTransitionType; 4] = [ StateTransitionType::DataContractCreate, StateTransitionType::DataContractUpdate, StateTransitionType::ContractUserModeration, + StateTransitionType::ContractFeeClaim, ]; /// The StateTransitionLike represents set of methods that are shared for all types of State Transition. diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index d31d3b93f64..28ad1c40e4f 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -789,6 +789,13 @@ impl Platform { platform_version, )?; + // Contract fee pot trees: the two sum trees, beside the voting balances, that hold what + // every contract's document action fees have collected. Fresh chains call the same + // helper from `create_initial_state_structure` v4, after the voting balances tree + // exists, so both node populations build the same prefunded balances Merk. + self.drive + .insert_contract_fee_pot_trees(Some(transaction), platform_version)?; + Ok(()) } } @@ -1835,6 +1842,75 @@ mod tests { assert_eq!(stored.owner(), &ContractGroupOwner::SingleOwner(owner_id)); } + /// The only way a running network gets the contract fee pot trees is this upgrade hook. + /// They are absent at 13 and present after the transition, and the prefunded balances + /// subtree they sit in is then byte-identical to the one of a chain born at 14: a + /// different insert order would shape that Merk differently on the two node populations + /// and fork them on the first block that writes to a pot. + #[test] + fn test_transition_to_version_14_creates_contract_fee_pot_trees() { + use dpp::data_contract::document_type::action_fees::ContractFeePot; + use drive::drive::contract::paths::contract_fee_pots_key; + use drive::drive::prefunded_specialized_balances::prefunded_specialized_balances_path; + use drive::util::grove_operations::DirectQueryType; + + let platform_version = PlatformVersion::latest(); + let born_at_14 = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); + let upgraded = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = upgraded.drive.grove.start_transaction(); + + let pot_tree_exists = |pot: ContractFeePot, transaction: &Transaction| { + upgraded + .drive + .grove_has_raw( + (&prefunded_specialized_balances_path()).into(), + contract_fee_pots_key(pot), + DirectQueryType::StatefulDirectQuery, + Some(transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected to query the pot tree") + }; + for pot in [ContractFeePot::Owner, ContractFeePot::Moderators] { + assert!( + !pot_tree_exists(pot, &transaction), + "protocol version 13 has no {pot} fee pot tree" + ); + } + + upgraded + .transition_to_version_14(&BlockInfo::default(), &transaction, platform_version) + .expect("expected version 14 transition to succeed"); + + for pot in [ContractFeePot::Owner, ContractFeePot::Moderators] { + assert!( + pot_tree_exists(pot, &transaction), + "the {pot} fee pot tree must exist after the transition" + ); + } + + let diffs = collect_subtree_diffs( + &born_at_14, + &upgraded, + &transaction, + vec![vec![RootTree::PreFundedSpecializedBalances as u8]], + ); + assert!( + diffs.is_empty(), + "the prefunded balances subtree differs between a chain born at version 14 and one \ + upgraded to it:\n{}", + diffs.join("\n"), + ); + } + #[test] fn test_transition_to_version_14_creates_total_credits_history_tree() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs index 9793051345f..c60a1d30e0f 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v1/mod.rs @@ -1,6 +1,7 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::platform_events::state_transition_processing::record_added_balance_outputs::AddedBalanceOutputsOrigin; +use crate::execution::platform_events::state_transition_processing::validate_fees_of_event::v1::gas_sponsor_pays; use crate::execution::types::execution_event::ExecutionEvent; use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::signing_key_limits::SigningKeyLimits; @@ -20,6 +21,7 @@ use dpp::fee::Credits; use dpp::version::PlatformVersion; use drive::drive::identity::update::apply_balance_change_outcome::ApplyBalanceChangeOutcomeV0Methods; use drive::grovedb::Transaction; +use drive::state_transition_action::batch::{action_fee_operations, action_fees_total}; use drive::util::batch::DriveOperation; use std::collections::BTreeMap; @@ -27,9 +29,14 @@ impl Platform where C: CoreRPCLike, { - /// v1 executes a `Paid` event whose signing key carries a budget or whose batch names a gas - /// sponsor, both recorded on the event from protocol version 14. Every other event, including - /// one signed by a key that only expires, is executed by v0. + /// v1 executes a `Paid` event whose signing key carries a budget, whose batch names a gas + /// sponsor, or whose batch owes document action fees, all recorded on the event from protocol + /// version 14. Every other event, including one signed by a key that only expires, is + /// executed by v0. + /// + /// Whoever pays the gas pays the document action fees: they move from the payer's balance to + /// the contract's fee pots with the batch's own operations, and count against a budgeted key + /// when its identity pays them. They are no part of the fee, which goes to the fee pools. /// /// The fee is charged to the gas sponsor when their balance covers the estimated fee, the /// same question fee validation asked, and to the identity otherwise. Storage refunds still @@ -65,10 +72,11 @@ where *gas_sponsor = None; } } - let (signed_by_budgeted_key, has_gas_sponsor) = match &event { + let (signed_by_budgeted_key, has_gas_sponsor, has_action_fees) = match &event { ExecutionEvent::Paid { signing_key_limits, gas_sponsor, + action_fees, .. } => ( matches!( @@ -79,10 +87,11 @@ where }) ), gas_sponsor.is_some(), + !action_fees.is_empty(), ), - _ => (false, false), + _ => (false, false, false), }; - if !signed_by_budgeted_key && !has_gas_sponsor { + if !signed_by_budgeted_key && !has_gas_sponsor && !has_action_fees { return self.execute_event_v0( event, consensus_errors, @@ -113,6 +122,7 @@ where user_fee_increase, signing_key_limits, gas_sponsor, + action_fees, } = event else { return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( @@ -131,12 +141,40 @@ where )))? .total_base_fee() .saturating_add(additional_fixed_fee_cost.unwrap_or_default()); - let paying_sponsor = - gas_sponsor.filter(|gas_sponsor| gas_sponsor.covers(estimated_required_balance)); + let paying_sponsor = match gas_sponsor { + Some(gas_sponsor) + if gas_sponsor_pays( + &gas_sponsor, + estimated_required_balance, + &action_fees, + )? => + { + Some(gas_sponsor) + } + _ => None, + }; let payer_id = paying_sponsor .map(|gas_sponsor| gas_sponsor.identity_id) .unwrap_or(identity.id); + // Whoever pays the gas pays the document action fees: they leave the payer's + // balance for the contract's fee pots in the same batch as the documents. They are + // no part of the fee below, which goes to the fee pools. + // + // They are a price the contract set, like the price of a purchase, and move as that + // principal does: with the operations, before the gas is metered and debited. Fee + // validation admitted the batch only with a balance covering them and the estimated + // gas, so should the metered gas ever exceed its estimate, what falls short is the + // gas, by the rule that already governs a principal, and never the pots against + // credits that were not there. + let mut operations = operations; + operations.extend(action_fee_operations(payer_id, &action_fees)?); + let action_fees_owed_by_identity = if paying_sponsor.is_some() { + 0 + } else { + action_fees_total(&identity.id, &action_fees)? + }; + let credit_mints = DriveOperation::credit_mints(&operations); let mut individual_fee_result = self .drive @@ -181,6 +219,7 @@ where }; let spent_from_key_budget = removed_balance .unwrap_or_default() + .saturating_add(action_fees_owed_by_identity) .saturating_add(fee_owed_by_identity); let outcome = self.drive.apply_balance_change_from_fee_to_identity( diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs index 77454c6aee5..a9d0ee9808c 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v0/mod.rs @@ -391,6 +391,7 @@ mod tests { user_fee_increase: 0, signing_key_limits: None, gas_sponsor: None, + action_fees: vec![], }; let previous_fee_versions = Default::default(); @@ -658,6 +659,7 @@ mod tests { user_fee_increase: 0, signing_key_limits: None, gas_sponsor: None, + action_fees: vec![], }; let result = platform diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v1/mod.rs index 7c24440b1d1..2d7a2edd712 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/v1/mod.rs @@ -15,7 +15,11 @@ use dpp::fee::fee_result::FeeResult; use dpp::fee::Credits; use dpp::prelude::ConsensusValidationResult; use dpp::version::PlatformVersion; +use dpp::ProtocolError; use drive::grovedb::TransactionArg; +use drive::state_transition_action::batch::{ + action_fee_operations, action_fees_total, ResolvedDocumentActionFee, ResolvedGasSponsor, +}; /// What a state transition needs to find in the budget of the key that signed it. /// @@ -41,6 +45,31 @@ pub(in crate::execution::platform_events::state_transition_processing) fn requir .saturating_add(user_fee_increase_amount) } +/// Whether the gas sponsor pays: their balance covers the estimated gas and the document action +/// fees they would owe. Whoever pays the gas pays the action fees, so there is one question and +/// fee validation and execution both ask it here, on the same estimate, and always agree. +pub(in crate::execution::platform_events::state_transition_processing) fn gas_sponsor_pays( + gas_sponsor: &ResolvedGasSponsor, + required_gas: Credits, + action_fees: &[ResolvedDocumentActionFee], +) -> Result { + Ok(gas_sponsor.covers(required_from_gas_sponsor( + gas_sponsor, + required_gas, + action_fees, + )?)) +} + +/// What the gas sponsor's balance has to cover: the estimated gas and the document action fees +/// they would owe. +fn required_from_gas_sponsor( + gas_sponsor: &ResolvedGasSponsor, + required_gas: Credits, + action_fees: &[ResolvedDocumentActionFee], +) -> Result { + Ok(required_gas.saturating_add(action_fees_total(&gas_sponsor.identity_id, action_fees)?)) +} + impl Platform where C: CoreRPCLike, @@ -57,11 +86,20 @@ where /// unpaid, and hands a batch that merely prefers them back to the identity's balance. When /// the sponsor pays, the key's budget only has to cover `removed_balance`. /// + /// * the document action fees a batch owes (the `actionFees` keyword): whoever pays the gas + /// pays them. The sponsor's balance has to cover the gas and the fees the sponsor would + /// owe, and failing that the identity's balance has to cover the gas, its own principal + /// and the fees the identity would owe, which also count against a budgeted key. The gas + /// is estimated once, with the operations that charge the identity: they are those of + /// the sponsor plus, at most, one addition to the owner pot, which a sponsor, being the + /// contract owner, never makes, so the estimate covers either payer. + /// /// Every failure leaves the state transition unpaid, like an insufficient balance: nobody /// was allowed to be charged. This stage runs in check tx with the last committed block time /// and at execution with the block's own time. /// - /// Every event with neither signing key limits nor a gas sponsor is validated by v0. + /// Every event with no signing key limits, no gas sponsor and no action fees is validated + /// by v0. pub(super) fn validate_fees_of_event_v1( &self, event: &ExecutionEvent, @@ -79,6 +117,7 @@ where user_fee_increase, signing_key_limits, gas_sponsor, + action_fees, .. } = event else { @@ -91,7 +130,7 @@ where ); }; - if signing_key_limits.is_none() && gas_sponsor.is_none() { + if signing_key_limits.is_none() && gas_sponsor.is_none() && action_fees.is_empty() { return self.validate_fees_of_event_v0( event, block_info, @@ -126,10 +165,13 @@ where "partial identity info with no balance in paid execution event", )))?; let principal = removed_balance.unwrap_or_default(); + let identity_action_fees = action_fees_total(&identity.id, action_fees)?; + let mut operations = operations.clone(); + operations.extend(action_fee_operations(identity.id, action_fees)?); let mut estimated_fee_result = self .drive .apply_drive_operations( - operations.clone(), + operations, false, block_info, transaction, @@ -169,7 +211,7 @@ where .into()], )); } - if gas_sponsor.covers(required_balance) { + if gas_sponsor_pays(gas_sponsor, required_balance, action_fees)? { true } else if gas_sponsor.strict { return Ok(ConsensusValidationResult::new_with_data_and_errors( @@ -178,7 +220,11 @@ where GasSponsorInsufficientBalanceError::new( gas_sponsor.identity_id, gas_sponsor.balance, - required_balance, + required_from_gas_sponsor( + gas_sponsor, + required_balance, + action_fees, + )?, ), ) .into()], @@ -191,6 +237,8 @@ where }; if !sponsor_pays { + // The identity pays the gas, and with it the action fees it owes. + let required_balance = required_balance.saturating_add(identity_action_fees); let balance_after_principal_operation = balance.saturating_sub(principal); if balance_after_principal_operation < required_balance { let total_required = required_balance.saturating_add(principal); @@ -209,8 +257,9 @@ where let required_budget = if sponsor_pays { principal } else { + // The action fees leave the identity like a principal does. required_from_key_budget( - *removed_balance, + Some(principal.saturating_add(identity_action_fees)), estimated_fee_result.storage_fee, *additional_fixed_fee_cost, user_fee_increase_amount, diff --git a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs index 589712e2b31..fe4e31846be 100644 --- a/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs +++ b/packages/rs-drive-abci/src/execution/types/execution_event/mod.rs @@ -21,7 +21,7 @@ use crate::execution::types::state_transition_execution_context::{ }; use drive::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; -use drive::state_transition_action::batch::ResolvedGasSponsor; +use drive::state_transition_action::batch::{ResolvedDocumentActionFee, ResolvedGasSponsor}; use drive::state_transition_action::system::bump_address_input_nonces_action::BumpAddressInputNonceActionAccessorsV0; use drive::state_transition_action::system::partially_use_asset_lock_action::PartiallyUseAssetLockActionAccessorsV0; use drive::util::batch::DriveOperation; @@ -55,6 +55,11 @@ pub(in crate::execution) enum ExecutionEvent<'a> { /// execution v1 charges the sponsor instead of the identity when it covers the fee, both /// from protocol version 14; the identity always funds `removed_balance` itself. gas_sponsor: Option, + /// The action fees the batch's document transitions owe (the `actionFees` keyword, + /// protocol version 14), priced by the batch transformer. Whoever pays the gas pays + /// them: fee validation and execution turn them into operations for that payer. Empty + /// for every other transition. + action_fees: Vec, }, /// A drive event that is paid by address inputs, this one can also be used by asset lock to address PaidFromAddressInputs { @@ -259,6 +264,7 @@ impl ExecutionEvent<'_> { user_fee_increase, signing_key_limits, gas_sponsor: None, + action_fees: vec![], }) } else { Err(Error::Execution(ExecutionError::CorruptedCodeExecution( @@ -282,6 +288,7 @@ impl ExecutionEvent<'_> { user_fee_increase, signing_key_limits, gas_sponsor: None, + action_fees: vec![], }) } else { Err(Error::Execution(ExecutionError::CorruptedCodeExecution( @@ -303,6 +310,9 @@ impl ExecutionEvent<'_> { ) }) }); + // Read off the transitions as state validation left them: one it replaced with + // a nonce bump owes no action fee. + let action_fees = batch_action.resolved_action_fees()?; let operations = action.into_high_level_drive_operations(epoch, platform_version)?; if let Some(identity) = identity { @@ -316,6 +326,7 @@ impl ExecutionEvent<'_> { user_fee_increase, signing_key_limits, gas_sponsor, + action_fees, }) } else { Err(Error::Execution(ExecutionError::CorruptedCodeExecution( @@ -353,6 +364,7 @@ impl ExecutionEvent<'_> { user_fee_increase, signing_key_limits, gas_sponsor: None, + action_fees: vec![], }) } else { Err(Error::Execution(ExecutionError::CorruptedCodeExecution( @@ -380,6 +392,7 @@ impl ExecutionEvent<'_> { user_fee_increase, signing_key_limits, gas_sponsor: None, + action_fees: vec![], }) } else { Err(Error::Execution(ExecutionError::CorruptedCodeExecution( @@ -553,6 +566,7 @@ impl ExecutionEvent<'_> { user_fee_increase, signing_key_limits, gas_sponsor: None, + action_fees: vec![], }) } else { Err(Error::Execution(ExecutionError::CorruptedCodeExecution( @@ -629,6 +643,7 @@ impl ExecutionEvent<'_> { user_fee_increase, signing_key_limits, gas_sponsor: None, + action_fees: vec![], }) } else { Err(Error::Execution(ExecutionError::CorruptedCodeExecution( @@ -788,6 +803,7 @@ impl ExecutionEvent<'_> { user_fee_increase, signing_key_limits, gas_sponsor: None, + action_fees: vec![], }) } else { Err(Error::Execution(ExecutionError::CorruptedCodeExecution( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs index dd0a0174769..7d1a1086cea 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_balances_and_nonces.rs @@ -176,6 +176,7 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::IdentityTopUp(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) @@ -250,6 +251,7 @@ impl StateTransitionAddressBalancesAndNoncesValidation for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) | StateTransition::ShieldFromIdentity(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs index fa184d1b002..1cb6b0f09c0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/address_witnesses.rs @@ -79,6 +79,7 @@ impl StateTransitionAddressWitnessValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) | StateTransition::ShieldFromIdentity(_) @@ -201,6 +202,7 @@ impl StateTransitionHasAddressWitnessValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) | StateTransition::ShieldFromIdentity(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs index 5e7407dd788..b4c93ff2739 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/addresses_minimum_balance.rs @@ -68,6 +68,7 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::IdentityCreditWithdrawal(_) | StateTransition::ShieldFromIdentity(_) @@ -104,6 +105,7 @@ impl StateTransitionAddressesMinimumBalanceValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::IdentityCreditWithdrawal(_) | StateTransition::ShieldFromIdentity(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs index ccf71cd7e37..0a86983222e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/basic_structure.rs @@ -70,6 +70,9 @@ impl StateTransitionBasicStructureValidationV0 for StateTransition { StateTransition::ContractUserModeration(st) => { st.validate_basic_structure(network_type, platform_version) } + StateTransition::ContractFeeClaim(st) => { + st.validate_basic_structure(network_type, platform_version) + } StateTransition::IdentityTopUp(st) => { st.validate_basic_structure(network_type, platform_version) } @@ -498,6 +501,7 @@ impl StateTransitionBasicStructureValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::AddressFundsTransfer(_) | StateTransition::IdentityCreditTransferToAddresses(_) @@ -914,6 +918,7 @@ mod tests { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) | StateTransition::ShieldFromIdentity(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs index a8f3c4bd5db..dabc8c51b2d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_balance.rs @@ -113,6 +113,9 @@ impl StateTransitionIdentityBalanceValidationV0 for StateTransition { StateTransition::ContractUserModeration(st) => st .validate_estimated_fee(balance, platform_version) .map_err(Error::Protocol), + StateTransition::ContractFeeClaim(st) => st + .validate_estimated_fee(balance, platform_version) + .map_err(Error::Protocol), StateTransition::MasternodeVote(_) | StateTransition::IdentityCreate(_) | StateTransition::IdentityTopUp(_) @@ -176,6 +179,7 @@ impl StateTransitionIdentityBalanceValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index 018e90a84f3..0d78c6b3825 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -67,7 +67,8 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::IdentityKeyLimitsUpdate(_) - | StateTransition::ContractUserModeration(_) => { + | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) => { //Basic signature verification Ok(self.validate_state_transition_identity_signed( drive, @@ -197,7 +198,8 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::IdentityKeyLimitsUpdate(_) - | StateTransition::ContractUserModeration(_) => true, + | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) => true, } } @@ -228,7 +230,8 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::IdentityKeyLimitsUpdate(_) - | StateTransition::ContractUserModeration(_) => true, + | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) => true, } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs index 1cd36962de5..0130140638b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_nonces.rs @@ -94,6 +94,13 @@ impl StateTransitionIdentityNonceValidationV0 for StateTransition { execution_context, platform_version, ), + StateTransition::ContractFeeClaim(st) => st.validate_identity_nonces( + platform, + block_info, + tx, + execution_context, + platform_version, + ), StateTransition::IdentityCreditTransfer(st) => st.validate_identity_nonces( platform, block_info, @@ -182,6 +189,7 @@ impl StateTransitionHasIdentityNonceValidationV0 for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::IdentityCreditWithdrawal(_) | StateTransition::MasternodeVote(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs index 75aba8b2521..3377772d2e0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/is_allowed.rs @@ -6,7 +6,8 @@ use dpp::consensus::basic::state_transition::StateTransitionNotActiveError; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::StateTransition; use dpp::version::feature_initial_protocol_versions::{ - ADDRESS_FUNDS_INITIAL_PROTOCOL_VERSION, CONTRACT_USER_MODERATION_INITIAL_PROTOCOL_VERSION, + ADDRESS_FUNDS_INITIAL_PROTOCOL_VERSION, CONTRACT_FEE_CLAIM_INITIAL_PROTOCOL_VERSION, + CONTRACT_USER_MODERATION_INITIAL_PROTOCOL_VERSION, IDENTITY_KEY_LIMITS_UPDATE_INITIAL_PROTOCOL_VERSION, IDENTITY_TOP_UP_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION, @@ -44,7 +45,8 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { | StateTransition::IdentityCreateFromShieldedPool(_) | StateTransition::ShieldFromIdentity(_) | StateTransition::IdentityKeyLimitsUpdate(_) - | StateTransition::ContractUserModeration(_) => Ok(true), + | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) => Ok(true), StateTransition::DataContractCreate(_) | StateTransition::DataContractUpdate(_) | StateTransition::IdentityCreate(_) @@ -165,6 +167,21 @@ impl StateTransitionIsAllowedValidationV0 for StateTransition { ])) } } + StateTransition::ContractFeeClaim(_) => { + if platform_version.protocol_version >= CONTRACT_FEE_CLAIM_INITIAL_PROTOCOL_VERSION + { + Ok(ConsensusValidationResult::new()) + } else { + Ok(ConsensusValidationResult::new_with_errors(vec![ + StateTransitionNotActiveError::new( + self.state_transition_type().to_string(), + platform_version.protocol_version, + CONTRACT_FEE_CLAIM_INITIAL_PROTOCOL_VERSION, + ) + .into(), + ])) + } + } _ => Err(Error::Execution(ExecutionError::CorruptedCodeExecution( "validate_is_allowed is not implemented for this state transition", ))), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index 2754494582a..771ad56e64a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -126,6 +126,14 @@ impl StateTransitionStateValidation for StateTransition { execution_context, tx, ), + StateTransition::ContractFeeClaim(st) => st.validate_state( + action, + platform, + validation_mode, + block_info, + execution_context, + tx, + ), StateTransition::IdentityTopUp(_) => { Err(Error::Execution(ExecutionError::CorruptedCodeExecution( "identity top up should not have state validation", @@ -294,6 +302,7 @@ impl StateTransitionStateValidation for StateTransition { | StateTransition::IdentityUpdate(_) | StateTransition::IdentityKeyLimitsUpdate(_) | StateTransition::ContractUserModeration(_) + | StateTransition::ContractFeeClaim(_) | StateTransition::IdentityCreditTransfer(_) | StateTransition::MasternodeVote(_) => true, StateTransition::AddressFundsTransfer(_) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index c30b541664b..f7bb13051c1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -253,6 +253,7 @@ mod tests { token_cost: None, gas_fees_paid_by: GasFeesPaidBy::default(), contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, }), block_info: BlockInfo::default(), data, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs index 94106a64758..2f4a5072eb6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v0/mod.rs @@ -180,6 +180,7 @@ mod tests { token_cost: None, gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, } .into(); @@ -327,6 +328,7 @@ mod tests { token_cost: None, gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, }), }), ); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v2/mod.rs index 7ed3e46f05f..a1eabc33db5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/withdrawals/v2/mod.rs @@ -230,6 +230,7 @@ mod tests { token_cost: None, gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, }), }), ); 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 3614884e302..cba296f7cdd 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 @@ -1,4 +1,5 @@ use dpp::block::block_info::BlockInfo; +use dpp::data_contract::document_type::action_fees::ActionFeePricing; use dpp::identifier::Identifier; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{PartialIdentity, Purpose}; @@ -150,6 +151,28 @@ impl DocumentsBatchStateTransitionStateValidationV2 for BatchTransition { } } + // Document action fees (the `actionFees` keyword): a fee priced by the fee multiplier + // follows the multiplier of the epoch the batch executes in. It is read here, where + // state is read, once, only when some transition declares such a fee, and billed to the + // batch. The fees themselves are settled when the batch executes, off the transitions + // state validation left standing: one it replaced with a nonce bump owes nothing. + if let Some(action) = validation_result.data.as_mut() { + let priced_by_the_fee_multiplier = action + .declared_action_fees() + .iter() + .any(|(_, _, pricing, _)| *pricing == ActionFeePricing::FeeMultiplier); + if priced_by_the_fee_multiplier { + let platform_version = platform.state.current_platform_version()?; + let (fee, multiplier) = platform.drive.fetch_action_fee_multiplier_with_fee( + &block_info.epoch, + tx, + platform_version, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + action.set_action_fee_multiplier_permille(Some(multiplier)); + } + } + Ok(validation_result.map(Into::into)) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/action_fees.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/action_fees.rs new file mode 100644 index 00000000000..d3fea2e1629 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/action_fees.rs @@ -0,0 +1,688 @@ +//! End-to-end coverage of document action fees (the `actionFees` keyword, protocol version +//! 14): a document type charges a fixed fee in credits for an action, split between the +//! contract's owner pot and its moderators pot. Whoever pays the gas pays the fee: the signer, +//! or the contract owner when they sponsor the gas. The contract owner never pays into their +//! own owner pot, a transition that fails pays no fee, and the credits only ever move, so the +//! sum of all credits changes by the gas alone. + +use super::gas_sponsorship::gas_sponsorship_tests::{ + total_fee, Sponsorship, GAS_SPONSOR_INSUFFICIENT_BALANCE, IDENTITY_INSUFFICIENT_BALANCE, +}; +use super::*; + +mod action_fee_tests { + use super::*; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::{ + PaidConsensusError, SuccessfulExecution, UnpaidConsensusError, + }; + use dpp::block::epoch::Epoch; + use dpp::consensus::codes::ErrorWithCode; + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::data_contract::accessors::v1::DataContractV1Getters; + use dpp::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; + use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; + use dpp::data_contract::document_type::action_fees::ContractFeePot; + use dpp::data_contract::document_type::DocumentType; + use dpp::data_contract::DataContract; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::{platform_value, Value}; + use dpp::state_transition::StateTransition; + use dpp::tests::json_document::json_document_to_contract; + use dpp::tokens::calculate_token_id; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use drive::drive::contract::fee_pots::types::ContractFeePotState; + use drive::drive::credit_pools::epochs::operations_factory::EpochOperations; + use drive::grovedb::Transaction; + use drive::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; + use drive::util::batch::GroveDbOpBatch; + use drive::util::storage_flags::StorageFlags; + use simple_signer::signer::SimpleSigner; + + const IDENTITY_PUBLIC_KEY_BUDGET_EXCEEDED: u32 = 40218; + + /// What creating a card 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; + + fn card_action_fees(pricing: &str) -> Value { + platform_value!({ + "pricing": pricing, + "create": {"owner": OWNER_PART, "moderators": MODERATORS_PART}, + }) + } + + /// Declares `action_fees` on the `card` document type, on a contract that keeps a banlist + /// when `moderated`, and parses the document type again so that it carries them. + fn declare_action_fees(contract: &mut DataContract, action_fees: Value, moderated: bool) { + let platform_version = PlatformVersion::latest(); + if moderated { + contract.set_config(contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: true, + suspensions: false, + moderators: ContractModerators::ContractOwner, + }, + ))); + } + let contract_id = contract.id(); + let config = contract.config().clone(); + let tokens = contract.tokens().clone(); + let card = contract + .document_types_mut() + .get_mut("card") + .expect("expected the card document type"); + let mut schema = card.schema().clone(); + schema + .insert("actionFees".to_string(), action_fees) + .expect("expected to declare the action fees"); + *card = DocumentType::try_from_schema( + contract_id, + 1, + config.version(), + "card", + schema, + None, + &tokens, + &config, + true, + &mut vec![], + platform_version, + ) + .expect("expected the card document type to parse with its action fees"); + assert!(card.action_fees().is_some()); + } + + /// The card game of the sponsorship tests, whose card creation also charges an action fee + fn game( + offered: GasFeesPaidBy, + pricing: &'static str, + owner_credits: Credits, + user_credits: Credits, + user_key_budget: Option, + ) -> Sponsorship { + game_with_gold( + offered, + pricing, + owner_credits, + user_credits, + 15, + user_key_budget, + ) + } + + /// `game` with a user holding `user_gold` gold; a card costs 10 + fn game_with_gold( + offered: GasFeesPaidBy, + pricing: &'static str, + owner_credits: Credits, + user_credits: Credits, + user_gold: u64, + user_key_budget: Option, + ) -> Sponsorship { + Sponsorship::build_customized( + PlatformVersion::latest(), + offered, + false, + owner_credits, + user_credits, + user_gold, + user_key_budget, + move |contract| declare_action_fees(contract, card_action_fees(pricing), true), + ) + } + + fn pot(setup: &Sponsorship, pot: ContractFeePot, tx: &Transaction) -> ContractFeePotState { + setup + .platform + .drive + .fetch_contract_fee_pot(setup.contract.id(), pot, Some(tx), setup.platform_version) + .expect("expected to fetch the pot") + } + + fn pots(setup: &Sponsorship, tx: &Transaction) -> (Credits, Credits) { + ( + pot(setup, ContractFeePot::Owner, tx).credits, + pot(setup, ContractFeePot::Moderators, tx).credits, + ) + } + + /// Every credit held in a tree: identity balances, pools, and the fee pots among the + /// prefunded balances. + fn credits_in_trees(setup: &Sponsorship, tx: &Transaction) -> Credits { + setup + .platform + .drive + .calculate_total_credits_balance(Some(tx), &setup.platform_version.drive) + .expect("expected to sum the credits") + .total_in_trees() + .expect("expected the credits to add up") + } + + fn unpaid_codes(result: &StateTransitionExecutionResult) -> Vec { + match result { + UnpaidConsensusError(error) => vec![error.code()], + other => panic!("expected an unpaid rejection, got {other:?}"), + } + } + + #[tokio::test] + async fn should_charge_the_signer_the_gas_and_the_fee_and_fill_both_pots() { + let setup = game( + GasFeesPaidBy::DocumentOwner, + "fixed", + dash_to_credits!(0.1), + dash_to_credits!(0.1), + None, + ); + let transition = setup.card_creation(GasFeesPaidBy::DocumentOwner).await; + let tx = setup.platform.drive.grove.start_transaction(); + let credits_before = credits_in_trees(&setup, &tx); + + let result = setup.process(&transition, &tx); + + assert_matches!(result, SuccessfulExecution { .. }); + let gas = total_fee(&result); + assert_eq!( + setup.credits(&setup.user, &tx), + dash_to_credits!(0.1) - gas - OWNER_PART - MODERATORS_PART + ); + assert_eq!( + setup.credits(&setup.contract_owner, &tx), + dash_to_credits!(0.1), + "the owner part waits in the owner pot until it is claimed" + ); + assert_eq!(pots(&setup, &tx), (OWNER_PART, MODERATORS_PART)); + // The fee only moved, from a balance into the pots: what left the trees is the gas, + // which reaches the fee pools at the end of the block, and the fee is no part of it. + assert_eq!(credits_before - credits_in_trees(&setup, &tx), gas); + } + + #[tokio::test] + async fn should_scale_a_fee_priced_by_the_fee_multiplier_with_the_epoch_multiplier() { + for (pricing, scaled) in [("feeMultiplier", true), ("fixed", false)] { + let setup = game( + GasFeesPaidBy::DocumentOwner, + pricing, + dash_to_credits!(0.1), + dash_to_credits!(0.1), + None, + ); + 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, &setup.platform_version.drive) + .expect("expected to set the multiplier of epoch 0"); + + let transition = setup.card_creation(GasFeesPaidBy::DocumentOwner).await; + // The mempool prices the fee as a block does: a fee priced by the multiplier must + // reach the execution event with the multiplier read, on this route too. + assert_eq!(setup.check_tx(&transition), Vec::::new(), "{pricing}"); + let tx = setup.platform.drive.grove.start_transaction(); + let result = setup.process(&transition, &tx); + + assert_matches!(result, SuccessfulExecution { .. }); + let expected = if scaled { + (OWNER_PART * 3 / 2, MODERATORS_PART * 3 / 2) + } else { + (OWNER_PART, MODERATORS_PART) + }; + assert_eq!(pots(&setup, &tx), expected, "pricing {pricing}"); + } + } + + #[tokio::test] + async fn should_not_charge_the_contract_owner_their_own_owner_part() { + let setup = game( + GasFeesPaidBy::DocumentOwner, + "fixed", + dash_to_credits!(0.1), + dash_to_credits!(0.1), + None, + ); + // The contract owner holds no gold; give them some to pay for the card. + add_tokens_to_identity_in(&setup, 15); + let transition = setup + .card_creation_by_the_contract_owner(GasFeesPaidBy::DocumentOwner) + .await; + let tx = setup.platform.drive.grove.start_transaction(); + + let result = setup.process(&transition, &tx); + + assert_matches!(result, SuccessfulExecution { .. }); + let gas = total_fee(&result); + assert_eq!( + setup.credits(&setup.contract_owner, &tx), + dash_to_credits!(0.1) - gas - MODERATORS_PART + ); + assert_eq!(pots(&setup, &tx), (0, MODERATORS_PART)); + } + + /// Gold for the contract owner, who holds none and pays for a card like anybody else + fn add_tokens_to_identity_in(setup: &Sponsorship, amount: u64) { + let token_id = calculate_token_id(setup.contract.id().as_bytes(), 0); + add_tokens_to_identity( + &setup.platform, + token_id.into(), + setup.contract_owner.id(), + amount, + ); + } + + #[tokio::test] + async fn should_charge_a_paying_sponsor_the_fee_too_and_let_an_unfunded_user_act() { + let setup = game( + GasFeesPaidBy::ContractOwner, + "fixed", + dash_to_credits!(0.1), + 0, + None, + ); + let transition = setup.card_creation(GasFeesPaidBy::ContractOwner).await; + assert_eq!(setup.check_tx(&transition), Vec::::new()); + let tx = setup.platform.drive.grove.start_transaction(); + + let result = setup.process(&transition, &tx); + + assert_matches!(result, SuccessfulExecution { .. }); + let gas = total_fee(&result); + assert_eq!(setup.credits(&setup.user, &tx), 0); + // The sponsor is the contract owner: they pay the gas and the moderators part, and + // nothing into their own owner pot. + assert_eq!( + setup.credits(&setup.contract_owner, &tx), + dash_to_credits!(0.1) - gas - MODERATORS_PART + ); + assert_eq!(pots(&setup, &tx), (0, MODERATORS_PART)); + } + + #[tokio::test] + async fn should_refuse_unpaid_when_an_insisted_sponsor_covers_the_gas_but_not_the_fee() { + // Half the moderators part: plenty for the gas, not for the gas and the fee. + let setup = game( + GasFeesPaidBy::ContractOwner, + "fixed", + MODERATORS_PART / 2, + dash_to_credits!(0.1), + None, + ); + let transition = setup.card_creation(GasFeesPaidBy::ContractOwner).await; + let tx = setup.platform.drive.grove.start_transaction(); + + let result = setup.process(&transition, &tx); + + assert_eq!( + unpaid_codes(&result), + vec![GAS_SPONSOR_INSUFFICIENT_BALANCE] + ); + assert_eq!(pots(&setup, &tx), (0, 0)); + assert_eq!(setup.credits(&setup.user, &tx), dash_to_credits!(0.1)); + assert_eq!( + setup.credits(&setup.contract_owner, &tx), + MODERATORS_PART / 2 + ); + } + + #[tokio::test] + async fn should_fall_back_to_the_signer_for_the_gas_and_the_whole_fee_when_a_preferred_sponsor_falls_short( + ) { + let setup = game( + GasFeesPaidBy::PreferContractOwner, + "fixed", + MODERATORS_PART / 2, + dash_to_credits!(0.1), + None, + ); + let transition = setup + .card_creation(GasFeesPaidBy::PreferContractOwner) + .await; + let tx = setup.platform.drive.grove.start_transaction(); + + let result = setup.process(&transition, &tx); + + assert_matches!(result, SuccessfulExecution { .. }); + let gas = total_fee(&result); + // The signer is not the contract owner, so they owe both parts. + assert_eq!( + setup.credits(&setup.user, &tx), + dash_to_credits!(0.1) - gas - OWNER_PART - MODERATORS_PART + ); + assert_eq!( + setup.credits(&setup.contract_owner, &tx), + MODERATORS_PART / 2 + ); + assert_eq!(pots(&setup, &tx), (OWNER_PART, MODERATORS_PART)); + } + + #[tokio::test] + async fn should_refuse_unpaid_a_signer_who_covers_the_gas_but_not_the_fee() { + let setup = game( + GasFeesPaidBy::DocumentOwner, + "fixed", + dash_to_credits!(0.1), + MODERATORS_PART, + None, + ); + let transition = setup.card_creation(GasFeesPaidBy::DocumentOwner).await; + let tx = setup.platform.drive.grove.start_transaction(); + + let result = setup.process(&transition, &tx); + + assert_eq!(unpaid_codes(&result), vec![IDENTITY_INSUFFICIENT_BALANCE]); + assert_eq!(pots(&setup, &tx), (0, 0)); + assert_eq!(setup.credits(&setup.user, &tx), MODERATORS_PART); + } + + #[tokio::test] + async fn should_charge_no_fee_for_a_transition_that_fails() { + // The user holds 5 gold and a card costs 10: the creation fails on the token balance, + // a paid rejection that bumps the nonce instead of creating the card. + let setup = game_with_gold( + GasFeesPaidBy::DocumentOwner, + "fixed", + dash_to_credits!(0.1), + dash_to_credits!(0.1), + 5, + None, + ); + let transition = setup.card_creation(GasFeesPaidBy::DocumentOwner).await; + let tx = setup.platform.drive.grove.start_transaction(); + + let result = setup.process(&transition, &tx); + + assert_matches!(result, PaidConsensusError { .. }); + let gas = total_fee(&result); + assert_eq!( + setup.credits(&setup.user, &tx), + dash_to_credits!(0.1) - gas, + "a failed transition pays its gas and nothing else" + ); + assert_eq!(pots(&setup, &tx), (0, 0)); + } + + #[tokio::test] + async fn should_count_the_fee_against_the_budget_of_the_signing_key() { + // A budget that covers the storage of a card but not the fee on top of it. + let setup = game( + GasFeesPaidBy::DocumentOwner, + "fixed", + dash_to_credits!(0.1), + dash_to_credits!(0.1), + Some(MODERATORS_PART), + ); + let transition = setup.card_creation(GasFeesPaidBy::DocumentOwner).await; + let tx = setup.platform.drive.grove.start_transaction(); + + let result = setup.process(&transition, &tx); + + assert_eq!( + unpaid_codes(&result), + vec![IDENTITY_PUBLIC_KEY_BUDGET_EXCEEDED] + ); + assert_eq!(pots(&setup, &tx), (0, 0)); + } + #[tokio::test] + async fn should_price_by_the_fee_schedule_in_the_first_block_of_an_epoch() { + // State transitions execute before the end of the block, where an epoch is initialized, + // so in the first block of an epoch its tree holds no fee multiplier yet. The fee is + // then priced by the multiplier the epoch is about to be initialized with. + let setup = game( + GasFeesPaidBy::DocumentOwner, + "feeMultiplier", + dash_to_credits!(0.1), + dash_to_credits!(0.1), + None, + ); + let schedule_multiplier = setup + .platform_version + .fee_version + .uses_version_fee_multiplier_permille + .expect("expected the fee schedule to set a multiplier"); + let transition = setup.card_creation(GasFeesPaidBy::DocumentOwner).await; + let tx = setup.platform.drive.grove.start_transaction(); + + let result = setup.process_in( + &transition, + &BlockInfo { + epoch: Epoch::new(7).expect("expected epoch 7"), + ..Default::default() + }, + &tx, + ); + + assert_matches!(result, SuccessfulExecution { .. }); + assert_eq!( + pots(&setup, &tx), + ( + OWNER_PART * schedule_multiplier / 1_000, + MODERATORS_PART * schedule_multiplier / 1_000 + ) + ); + } + + /// What each action on a card costs on top of the gas, all to the contract owner: every + /// amount is different, so a fee charged for the wrong action shows. + const CREATE_FEE: Credits = 1_000; + const REPLACE_FEE: Credits = 2_000; + const DELETE_FEE: Credits = 3_000; + const TRANSFER_FEE: Credits = 4_000; + const UPDATE_PRICE_FEE: Credits = 5_000; + const PURCHASE_FEE: Credits = 6_000; + + #[tokio::test] + async fn should_charge_each_action_its_own_fee() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + // A card that can be replaced, priced, purchased, transferred and deleted. + let mut contract = json_document_to_contract( + "tests/supporting_files/contract/crypto-card-game/crypto-card-game-direct-purchase-documents-mutable.json", + true, + platform_version, + ) + .expect("expected the card game contract"); + declare_action_fees( + &mut contract, + platform_value!({ + "pricing": "fixed", + "create": {"owner": CREATE_FEE}, + "replace": {"owner": REPLACE_FEE}, + "delete": {"owner": DELETE_FEE}, + "transfer": {"owner": TRANSFER_FEE}, + "update_price": {"owner": UPDATE_PRICE_FEE}, + "purchase": {"owner": PURCHASE_FEE}, + }), + false, + ); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + let card = contract + .document_type_for_name("card") + .expect("expected the card document type"); + + let (seller, seller_signer, seller_key) = + setup_identity(&mut platform, 958, dash_to_credits!(1)); + let (buyer, buyer_signer, buyer_key) = + setup_identity(&mut platform, 450, dash_to_credits!(1)); + + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = card + .random_document_with_identifier_and_entropy( + &mut rng, + seller.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document.set("attack", 4.into()); + document.set("defense", 7.into()); + + let tx = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let owner_pot = |tx: &Transaction| { + platform + .drive + .fetch_contract_fee_pot( + contract.id(), + ContractFeePot::Owner, + Some(tx), + platform_version, + ) + .expect("expected to fetch the owner pot") + .credits + }; + // Processes `transition` and returns what it added to the owner pot. + let charged = |transition: StateTransition, action: &str| { + let before = owner_pot(&tx); + let result = platform + .platform + .process_raw_state_transitions( + &vec![transition + .serialize_to_bytes() + .expect("expected to serialize")], + &state, + &BlockInfo::default(), + &tx, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + assert_matches!( + result.execution_results().as_slice(), + [SuccessfulExecution { .. }], + "{action}" + ); + owner_pot(&tx) - before + }; + let by = |identity: &Identity| identity.id(); + let signed_by_the_seller: (&IdentityPublicKey, &SimpleSigner) = + (&seller_key, &seller_signer); + let signed_by_the_buyer: (&IdentityPublicKey, &SimpleSigner) = (&buyer_key, &buyer_signer); + + let creation = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + card, + entropy.0, + signed_by_the_seller.0, + 1, + 0, + None, + signed_by_the_seller.1, + platform_version, + None, + ) + .await + .expect("expected the creation"); + assert_eq!(charged(creation, "create"), CREATE_FEE); + + document.set("attack", 5.into()); + document.bump_revision(); + let replacement = BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + card, + signed_by_the_seller.0, + 2, + 0, + None, + signed_by_the_seller.1, + platform_version, + None, + ) + .await + .expect("expected the replacement"); + assert_eq!(charged(replacement, "replace"), REPLACE_FEE); + + document.bump_revision(); + let price = dash_to_credits!(0.1); + let price_update = BatchTransition::new_document_update_price_transition_from_document( + document.clone(), + card, + price, + signed_by_the_seller.0, + 3, + 0, + None, + signed_by_the_seller.1, + platform_version, + None, + ) + .await + .expect("expected the price update"); + assert_eq!(charged(price_update, "update_price"), UPDATE_PRICE_FEE); + + document.bump_revision(); + let purchase = BatchTransition::new_document_purchase_transition_from_document( + document.clone(), + card, + by(&buyer), + price, + signed_by_the_buyer.0, + 1, + 0, + None, + signed_by_the_buyer.1, + platform_version, + None, + ) + .await + .expect("expected the purchase"); + assert_eq!(charged(purchase, "purchase"), PURCHASE_FEE); + + document.set_owner_id(by(&buyer)); + document.bump_revision(); + let transfer = BatchTransition::new_document_transfer_transition_from_document( + document.clone(), + card, + by(&seller), + signed_by_the_buyer.0, + 2, + 0, + None, + signed_by_the_buyer.1, + platform_version, + None, + ) + .await + .expect("expected the transfer"); + assert_eq!(charged(transfer, "transfer"), TRANSFER_FEE); + + document.set_owner_id(by(&seller)); + document.bump_revision(); + let deletion = BatchTransition::new_document_deletion_transition_from_document( + document, + card, + signed_by_the_seller.0, + 4, + 0, + None, + signed_by_the_seller.1, + platform_version, + None, + ) + .await + .expect("expected the deletion"); + assert_eq!(charged(deletion, "delete"), DELETE_FEE); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/gas_sponsorship.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/gas_sponsorship.rs index fcca447f4b7..9fc382a190c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/gas_sponsorship.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/gas_sponsorship.rs @@ -8,7 +8,7 @@ use super::*; -mod gas_sponsorship_tests { +pub(crate) mod gas_sponsorship_tests { use super::*; use crate::execution::check_tx::CheckTxLevel; use crate::execution::check_tx::CheckTxLevel::{FirstTimeCheck, Recheck}; @@ -44,8 +44,8 @@ mod gas_sponsorship_tests { use simple_signer::signer::SimpleSigner; const GAS_FEES_PAID_BY_NOT_ALLOWED: u32 = 40129; - const GAS_SPONSOR_INSUFFICIENT_BALANCE: u32 = 40222; - const IDENTITY_INSUFFICIENT_BALANCE: u32 = 40210; + pub(crate) const GAS_SPONSOR_INSUFFICIENT_BALANCE: u32 = 40222; + pub(crate) const IDENTITY_INSUFFICIENT_BALANCE: u32 = 40210; const IDENTITY_DOES_NOT_HAVE_ENOUGH_TOKEN_BALANCE: u32 = 40700; const REQUIRED_TOKEN_PAYMENT_INFO_NOT_SET: u32 = 40115; const INVALID_DOCUMENT_REVISION: u32 = 40106; @@ -53,17 +53,17 @@ mod gas_sponsorship_tests { /// Creating a card costs 10 gold, transferred to the contract owner. const CARD_COST: TokenAmount = 10; - struct Sponsorship { - platform: TempPlatform, - platform_version: &'static PlatformVersion, - contract: DataContract, + pub(crate) struct Sponsorship { + pub(crate) platform: TempPlatform, + pub(crate) platform_version: &'static PlatformVersion, + pub(crate) contract: DataContract, gold_token_id: Identifier, - contract_owner: Identity, + pub(crate) contract_owner: Identity, contract_owner_signer: SimpleSigner, contract_owner_key: IdentityPublicKey, - user: Identity, - user_signer: SimpleSigner, - user_key: IdentityPublicKey, + pub(crate) user: Identity, + pub(crate) user_signer: SimpleSigner, + pub(crate) user_key: IdentityPublicKey, } impl Sponsorship { @@ -116,6 +116,31 @@ mod gas_sponsorship_tests { user_credits: Credits, user_gold: TokenAmount, user_key_budget: Option, + ) -> Self { + Self::build_customized( + platform_version, + offered, + optional, + owner_credits, + user_credits, + user_gold, + user_key_budget, + |_| {}, + ) + } + + /// Like `build`, with `customize` changing the contract after everything else was set + /// on it and before it is stored. + #[allow(clippy::too_many_arguments)] + pub(crate) fn build_customized( + platform_version: &'static PlatformVersion, + offered: GasFeesPaidBy, + optional: bool, + owner_credits: Credits, + user_credits: Credits, + user_gold: TokenAmount, + user_key_budget: Option, + customize: impl FnOnce(&mut DataContract), ) -> Self { let mut platform = TestPlatformBuilder::new() .with_initial_protocol_version(platform_version.protocol_version) @@ -214,6 +239,7 @@ mod gas_sponsorship_tests { .expect("expected the replacement token cost to be set") .set_value("gasFeesPaidBy", offered_int.into()) .expect("expected to set who pays the gas of a replacement"); + customize(data_contract); }), None, Some(platform_version), @@ -239,7 +265,7 @@ mod gas_sponsorship_tests { } /// The user's card creation, asking `requested` for the gas - async fn card_creation(&self, requested: GasFeesPaidBy) -> StateTransition { + pub(crate) async fn card_creation(&self, requested: GasFeesPaidBy) -> StateTransition { self.card_creation_by( &self.user, &self.user_key, @@ -276,7 +302,7 @@ mod gas_sponsorship_tests { } /// The contract owner's own card creation, asking `requested` for the gas - async fn card_creation_by_the_contract_owner( + pub(crate) async fn card_creation_by_the_contract_owner( &self, requested: GasFeesPaidBy, ) -> StateTransition { @@ -320,7 +346,7 @@ mod gas_sponsorship_tests { } /// The card `creator` creates, always the same one, with the entropy of its id - fn card_of(&self, creator: &Identity) -> (Document, Bytes32) { + pub(crate) fn card_of(&self, creator: &Identity) -> (Document, Bytes32) { let mut rng = StdRng::seed_from_u64(433); let card_document_type = self .contract @@ -342,7 +368,7 @@ mod gas_sponsorship_tests { (document, entropy) } - async fn card_creation_by( + pub(crate) async fn card_creation_by( &self, creator: &Identity, key: &IdentityPublicKey, @@ -377,9 +403,19 @@ mod gas_sponsorship_tests { .expect("expected a batch transition") } - fn process( + pub(crate) fn process( + &self, + transition: &StateTransition, + tx: &drive::grovedb::Transaction, + ) -> StateTransitionExecutionResult { + self.process_in(transition, &BlockInfo::default(), tx) + } + + /// `process` in the block `block_info` describes + pub(crate) fn process_in( &self, transition: &StateTransition, + block_info: &BlockInfo, tx: &drive::grovedb::Transaction, ) -> StateTransitionExecutionResult { let platform_version = self.platform_version; @@ -392,7 +428,7 @@ mod gas_sponsorship_tests { .serialize_to_bytes() .expect("expected to serialize")], &state, - &BlockInfo::default(), + block_info, tx, platform_version, false, @@ -402,7 +438,7 @@ mod gas_sponsorship_tests { result.execution_results()[0].clone() } - fn check_tx(&self, transition: &StateTransition) -> Vec { + pub(crate) fn check_tx(&self, transition: &StateTransition) -> Vec { self.check_tx_at(transition, FirstTimeCheck) } @@ -427,7 +463,11 @@ mod gas_sponsorship_tests { .collect() } - fn credits(&self, identity: &Identity, tx: &drive::grovedb::Transaction) -> Credits { + pub(crate) fn credits( + &self, + identity: &Identity, + tx: &drive::grovedb::Transaction, + ) -> Credits { self.platform .drive .fetch_identity_balance(identity.id().to_buffer(), Some(tx), self.platform_version) @@ -449,7 +489,7 @@ mod gas_sponsorship_tests { } } - fn total_fee(result: &StateTransitionExecutionResult) -> Credits { + pub(crate) fn total_fee(result: &StateTransitionExecutionResult) -> Credits { match result { SuccessfulExecution { fee_result, .. } => fee_result.total_base_fee(), PaidConsensusError { actual_fees, .. } => actual_fees.total_base_fee(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 919366e46b1..cced05dfcb4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -1,3 +1,4 @@ +mod action_fees; mod creation; mod deletion; mod dpns; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/basic_structure/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/basic_structure/mod.rs new file mode 100644 index 00000000000..9a1925de7fc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/basic_structure/mod.rs @@ -0,0 +1 @@ +pub(crate) mod v0; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/basic_structure/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/basic_structure/v0/mod.rs new file mode 100644 index 00000000000..bed86ffdc0c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/basic_structure/v0/mod.rs @@ -0,0 +1,25 @@ +use crate::error::Error; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; + +pub(in crate::execution::validation::state_transition::state_transitions::contract_fee_claim) trait ContractFeeClaimStateTransitionStructureValidationV0 +{ + fn validate_basic_structure_v0( + &self, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl ContractFeeClaimStateTransitionStructureValidationV0 for ContractFeeClaimTransition { + /// A claim names a contract and one of two pots, and a transition naming anything else does + /// not decode, so there is nothing about its structure left to refuse. Who may claim the + /// pot, whether it was already claimed this epoch and whether it holds anything all need + /// state, so state validation decides those. + fn validate_basic_structure_v0( + &self, + _platform_version: &PlatformVersion, + ) -> Result { + Ok(SimpleConsensusValidationResult::new()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/identity_contract_nonce/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/identity_contract_nonce/mod.rs new file mode 100644 index 00000000000..9fc3c01935c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/identity_contract_nonce/mod.rs @@ -0,0 +1,51 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::contract_fee_claim::identity_contract_nonce::v0::ContractFeeClaimStateTransitionIdentityContractNonceV0; +use crate::execution::validation::state_transition::processor::identity_nonces::StateTransitionIdentityNonceValidationV0; +use crate::platform_types::platform::PlatformStateRef; +use dpp::block::block_info::BlockInfo; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +pub(crate) mod v0; + +impl StateTransitionIdentityNonceValidationV0 for ContractFeeClaimTransition { + fn validate_identity_nonces( + &self, + platform: &PlatformStateRef, + block_info: &BlockInfo, + tx: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .contract_fee_claim_state_transition + .nonce + { + Some(0) => self.validate_identity_contract_nonce_v0( + platform, + block_info, + tx, + execution_context, + platform_version, + ), + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "contract fee claim transition: validate_identity_contract_nonce" + .to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "contract fee claim transition: validate_identity_contract_nonce" + .to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/identity_contract_nonce/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/identity_contract_nonce/v0/mod.rs new file mode 100644 index 00000000000..7018293cc69 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/identity_contract_nonce/v0/mod.rs @@ -0,0 +1,75 @@ +use crate::error::Error; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::document::NonceOutOfBoundsError; +use dpp::consensus::basic::BasicError; +use dpp::identity::identity_nonce::{ + validate_identity_nonce_update, validate_new_identity_nonce, MISSING_IDENTITY_REVISIONS_FILTER, +}; +use dpp::state_transition::contract_fee_claim_transition::accessors::ContractFeeClaimTransitionAccessorsV0; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use dpp::state_transition::StateTransitionOwned; + +use dpp::validation::SimpleConsensusValidationResult; + +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::platform_types::platform::PlatformStateRef; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +pub(in crate::execution::validation::state_transition::state_transitions) trait ContractFeeClaimStateTransitionIdentityContractNonceV0 +{ + fn validate_identity_contract_nonce_v0( + &self, + platform: &PlatformStateRef, + block_info: &BlockInfo, + tx: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl ContractFeeClaimStateTransitionIdentityContractNonceV0 for ContractFeeClaimTransition { + /// The claimant's nonce for the contract, as a contract update's: read with its fee and + /// checked against the transition's. + fn validate_identity_contract_nonce_v0( + &self, + platform: &PlatformStateRef, + block_info: &BlockInfo, + tx: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result { + let revision_nonce = self.identity_contract_nonce(); + + if revision_nonce & MISSING_IDENTITY_REVISIONS_FILTER > 0 { + return Ok(SimpleConsensusValidationResult::new_with_error( + BasicError::NonceOutOfBoundsError(NonceOutOfBoundsError::new(revision_nonce)) + .into(), + )); + } + + let identity_id = self.owner_id(); + let contract_id = self.data_contract_id(); + let (existing_nonce, fee) = platform.drive.fetch_identity_contract_nonce_with_fees( + identity_id.to_buffer(), + contract_id.to_buffer(), + block_info, + true, + tx, + platform_version, + )?; + + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + let result = if let Some(existing_nonce) = existing_nonce { + validate_identity_nonce_update(existing_nonce, revision_nonce, identity_id) + } else { + validate_new_identity_nonce(revision_nonce, identity_id) + }; + + Ok(result) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/mod.rs new file mode 100644 index 00000000000..3a6a288c0d9 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/mod.rs @@ -0,0 +1,133 @@ +mod basic_structure; +mod identity_contract_nonce; +mod state; +#[cfg(test)] +mod tests; + +use dpp::address_funds::PlatformAddress; +use dpp::block::block_info::BlockInfo; +use dpp::dashcore::Network; +use dpp::fee::Credits; +use dpp::prelude::AddressNonce; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use dpp::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; +use dpp::version::PlatformVersion; +use drive::state_transition_action::StateTransitionAction; +use std::collections::BTreeMap; + +use drive::grovedb::TransactionArg; + +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; + +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; + +use crate::execution::validation::state_transition::contract_fee_claim::basic_structure::v0::ContractFeeClaimStateTransitionStructureValidationV0; +use crate::execution::validation::state_transition::contract_fee_claim::state::v0::ContractFeeClaimStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; +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 crate::platform_types::platform_state::PlatformStateV0Methods; + +impl StateTransitionActionTransformer for ContractFeeClaimTransition { + fn transform_into_action( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + _remaining_address_input_balances: &Option< + BTreeMap, + >, + _validation_mode: ValidationMode, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .contract_fee_claim_state_transition + .transform_into_action + { + 0 => self.transform_into_action_v0( + platform, + block_info, + execution_context, + tx, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "contract fee claim transition: transform_into_action".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} + +impl StateTransitionBasicStructureValidationV0 for ContractFeeClaimTransition { + fn validate_basic_structure( + &self, + _network_type: Network, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .contract_fee_claim_state_transition + .basic_structure + { + Some(0) => self.validate_basic_structure_v0(platform_version), + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "contract fee claim transition: validate_basic_structure".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "contract fee claim transition: validate_basic_structure".to_string(), + known_versions: vec![0], + })), + } + } +} + +impl StateTransitionStateValidation for ContractFeeClaimTransition { + fn validate_state( + &self, + _action: Option, + platform: &PlatformRef, + _validation_mode: ValidationMode, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + ) -> Result, Error> { + let platform_version = platform.state.current_platform_version()?; + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .contract_fee_claim_state_transition + .state + { + // The state validation is the transformation: the contract and the pot are read + // and checked while the action that carries the payouts is built. + 0 => self.transform_into_action_v0( + platform, + block_info, + execution_context, + tx, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "contract fee claim transition: validate_state".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/state/mod.rs new file mode 100644 index 00000000000..9a1925de7fc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/state/mod.rs @@ -0,0 +1 @@ +pub(crate) mod v0; 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 new file mode 100644 index 00000000000..dadcf440409 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/state/v0/mod.rs @@ -0,0 +1,192 @@ +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::document::DataContractNotPresentError; +use dpp::consensus::state::contract_moderation::{ + ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, + ContractFeesNothingToClaimError, +}; +use dpp::consensus::ConsensusError; +use dpp::fee::Credits; +use dpp::prelude::{ConsensusValidationResult, Identifier}; +use dpp::state_transition::contract_fee_claim_transition::accessors::ContractFeeClaimTransitionAccessorsV0; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use dpp::state_transition::StateTransitionOwned; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::contract::contract_fee_claim::ContractFeeClaimTransitionAction; +use drive::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; +use drive::state_transition_action::StateTransitionAction; +use std::collections::{BTreeMap, BTreeSet}; + +pub(in crate::execution::validation::state_transition::state_transitions::contract_fee_claim) trait ContractFeeClaimStateTransitionStateValidationV0 +{ + fn transform_into_action_v0( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl ContractFeeClaimStateTransitionStateValidationV0 for ContractFeeClaimTransition { + /// Reads the contract and the pot and settles the payout: the signer is a recipient of the + /// pot, the pot was not claimed in this epoch yet, and it holds enough to pay every + /// recipient something. Every refusal after the contract is found is paid for by bumping + /// the signer's contract nonce, and a refused claim leaves the pot's last claim epoch + /// alone. + /// + /// The action carries what each recipient is paid, so Drive pays the pot out without + /// reading it again, and the mempool, which transforms without a state validation stage, + /// refuses with the same consensus codes as a block. + fn transform_into_action_v0( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let contract_id = self.data_contract_id(); + let claimant_id = self.owner_id(); + let pot = self.pot(); + + let Some(contract_fetch_info) = platform + .drive + .get_contract_with_fetch_info_and_fee( + contract_id.to_buffer(), + Some(&block_info.epoch), + false, + tx, + platform_version, + )? + .1 + else { + return Ok(ConsensusValidationResult::new_with_error( + DataContractNotPresentError::new(contract_id).into(), + )); + }; + if let Some(fee) = contract_fetch_info.fee.clone() { + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + } + + let bump_action = || { + StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_contract_fee_claim_transition( + self, + ), + ) + }; + let refuse = |error: ConsensusError| { + Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action(), + vec![error], + )) + }; + + // Only who a payout of the pot goes to may claim it: the contract owner for the owner + // pot, a member of the moderation team for the moderators pot. A contract that + // declares no moderation has no team, so nobody claims its moderators pot. + let recipients = pot.recipients(&contract_fetch_info.contract); + if !recipients.contains(&claimant_id) { + return refuse( + ContractFeeClaimNotAllowedError::new(contract_id, pot, claimant_id).into(), + ); + } + + let (fee, fee_pot) = platform.drive.fetch_contract_fee_pot_with_fee( + contract_id, + pot, + &block_info.epoch, + tx, + platform_version, + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + let epoch_index = block_info.epoch.index; + if fee_pot.last_claim_epoch == Some(epoch_index) { + return refuse( + ContractFeesAlreadyClaimedThisEpochError::new(contract_id, pot, epoch_index).into(), + ); + } + + let Some(payouts) = split_between(&recipients, fee_pot.credits) else { + return refuse(ContractFeesNothingToClaimError::new(contract_id, pot).into()); + }; + + Ok(ConsensusValidationResult::new_with_data( + ContractFeeClaimTransitionAction::from_borrowed_transition_with_payouts( + self, + epoch_index, + payouts, + ) + .into(), + )) + } +} + +/// What each of `recipients` is paid out of `credits`: an equal share each, `None` when there +/// is nobody to pay or the share rounds down to nothing. What the equal split leaves over, less +/// than one credit per recipient, stays in the pot for the next claim, so no recipient is +/// favoured by the order of their identity ids. +/// +/// For the owner pot the only recipient is the contract owner, whose share is the whole pot. +fn split_between( + recipients: &BTreeSet, + credits: Credits, +) -> Option> { + let share = credits.checked_div(recipients.len() as Credits)?; + if share == 0 { + return None; + } + Some( + recipients + .iter() + .map(|recipient| (*recipient, share)) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(seeds: &[u8]) -> BTreeSet { + seeds + .iter() + .map(|seed| Identifier::from([*seed; 32])) + .collect() + } + + #[test] + fn should_pay_a_single_recipient_the_whole_pot() { + let payouts = split_between(&ids(&[1]), 1_001).expect("expected a payout"); + assert_eq!( + payouts, + BTreeMap::from([(Identifier::from([1; 32]), 1_001)]) + ); + } + + #[test] + fn should_pay_equal_shares_and_leave_the_remainder_in_the_pot() { + let payouts = split_between(&ids(&[1, 2, 3]), 1_001).expect("expected a payout"); + assert_eq!(payouts.len(), 3); + assert!(payouts.values().all(|share| *share == 333)); + let paid_out: Credits = payouts.values().sum(); + assert_eq!(1_001 - paid_out, 2); + } + + #[test] + fn should_pay_nothing_when_there_is_nobody_to_pay_or_less_than_a_credit_each() { + assert_eq!(split_between(&ids(&[]), 1_000), None); + assert_eq!(split_between(&ids(&[1, 2, 3]), 2), None); + assert_eq!(split_between(&ids(&[1]), 0), None); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs new file mode 100644 index 00000000000..15659690c55 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs @@ -0,0 +1,826 @@ +//! The contract fee claim through the whole processing pipeline: who may claim which pot, how a +//! pot is paid out, the once-per-epoch rule of each pot, and the proof of a claim's execution. + +use crate::execution::check_tx::CheckTxLevel::FirstTimeCheck; +use crate::execution::validation::state_transition::state_transitions::contract_user_moderation::tests::{ + assert_paid_with_code, assert_success, assert_unpaid_with_code, Actor, CRITICAL_KEY_ID, +}; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use dpp::block::block_info::BlockInfo; +use dpp::block::epoch::{Epoch, EpochIndex}; +use dpp::consensus::codes::ErrorWithCode; +use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; +use dpp::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, +}; +use dpp::data_contract::document_type::DocumentType; +use dpp::data_contract::DataContract; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::platform_value::{platform_value, Bytes32, Identifier, Value}; +use dpp::serialization::PlatformSerializable; +use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::contract_fee_claim_transition::methods::ContractFeeClaimTransitionMethodsV0; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use dpp::state_transition::data_contract_create_transition::methods::DataContractCreateTransitionMethodsV0; +use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; +use dpp::state_transition::data_contract_update_transition::methods::DataContractUpdateTransitionMethodsV0; +use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; +use dpp::state_transition::proof_result::{ + StateTransitionProofOutcome, StateTransitionProofResult, +}; +use dpp::state_transition::StateTransition; +use dpp::tests::fixtures::get_data_contract_fixture; +use dpp::version::PlatformVersion; +use drive::drive::contract::fee_pots::types::ContractFeePotState; +use drive::drive::Drive; +use drive::grovedb::Transaction; +use drive::util::batch::drive_op_batch::ContractFeePotOperationType; +use drive::util::batch::DriveOperation; +use rand::rngs::StdRng; +use rand::SeedableRng; +use std::collections::BTreeMap; + +const DATA_CONTRACT_NOT_PRESENT: u32 = 10400; +const CONTRACT_FEES_ALREADY_CLAIMED_THIS_EPOCH: u32 = 41111; +const CONTRACT_FEES_NOTHING_TO_CLAIM: u32 = 41112; +const CONTRACT_FEE_CLAIM_NOT_ALLOWED: u32 = 41113; +const DOCUMENT_ACTION_FEES_WITHOUT_MODERATION: u32 = 10902; + +/// The document type a contract update adds in these tests +const PAID_NOTE: &str = "paidNote"; + +/// What the shared `Actor` is funded with: 10 Dash +const STARTING_CREDITS: Credits = 1_000_000_000_000; + +/// Which identities the contract appoints as its moderators +#[derive(Clone, Copy)] +enum Team { + /// The contract declares no moderation at all + NoModeration, + /// Moderation with nobody appointed: the owner alone + OwnerAlone, + /// The two moderators, not the owner + TwoModerators, + /// The two moderators and the owner + TwoModeratorsAndTheOwner, +} + +/// A platform with a contract owner, two moderators and a stranger, and one contract the owner +/// created with the given team. +struct Setup { + platform: TempPlatform, + owner: Actor, + moderator_a: Actor, + moderator_b: Actor, + stranger: Actor, + contract: DataContract, +} + +impl Setup { + async fn new(team: Team) -> Self { + Self::new_at(team, PlatformVersion::latest()).await + } + + async fn new_at(team: Team, platform_version: &PlatformVersion) -> Self { + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(platform_version.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + let owner = Actor::new(&mut platform, 21); + let moderator_a = Actor::new(&mut platform, 22); + let moderator_b = Actor::new(&mut platform, 23); + let stranger = Actor::new(&mut platform, 24); + + let identity_nonce = 1; + let mut contract = get_data_contract_fixture( + Some(owner.id()), + identity_nonce, + platform_version.protocol_version, + ) + .data_contract_owned(); + let moderators = match team { + Team::NoModeration => None, + Team::OwnerAlone => Some(ContractModerators::ContractOwner), + Team::TwoModerators => Some(ContractModerators::AppointedModerators( + [moderator_a.id(), moderator_b.id()].into(), + )), + Team::TwoModeratorsAndTheOwner => Some(ContractModerators::AppointedModerators( + [moderator_a.id(), moderator_b.id(), owner.id()].into(), + )), + }; + contract.set_config(contract.config().clone().with_moderation(moderators.map( + |moderators| ContractModerationConfig { + banlist: true, + suspensions: false, + moderators, + }, + ))); + + let setup = Self { + platform, + owner, + moderator_a, + moderator_b, + stranger, + contract, + }; + + let create = DataContractCreateTransition::new_from_data_contract( + setup.contract.clone(), + identity_nonce, + &setup.owner.identity.clone().into_partial_identity_info(), + CRITICAL_KEY_ID, + &setup.owner.signer, + platform_version, + None, + ) + .await + .expect("expected to build the contract create"); + let transaction = setup.platform.drive.grove.start_transaction(); + assert_success(&setup.process(&create, 0, &transaction)); + setup.commit(transaction); + // The create sets the owner's nonce for the contract to the identity nonce it used. + setup.owner.next_contract_nonce.set(identity_nonce + 1); + setup + } + + /// Puts `credits` in `pot`, as document action fees would + fn fill(&self, pot: ContractFeePot, credits: Credits) { + self.platform + .drive + .apply_drive_operations( + vec![DriveOperation::ContractFeePotOperation( + ContractFeePotOperationType::AddToPot { + contract_id: self.contract.id(), + pot, + amount: credits, + }, + )], + true, + &BlockInfo::default(), + None, + PlatformVersion::latest(), + None, + ) + .expect("expected to fill the pot"); + } + + /// The contract at version 2, with a `paidNote` document type that charges `action_fees`, + /// and the update that carries it, signed by the owner + async fn add_paid_note(&self, action_fees: Value) -> (DataContract, StateTransition) { + let platform_version = PlatformVersion::latest(); + let mut updated = self.contract.clone(); + updated.set_version(2); + let paid_note = DocumentType::try_from_schema( + updated.id(), + 1, + updated.config().version(), + PAID_NOTE, + platform_value!({ + "type": "object", + "properties": { + "text": {"type": "string", "position": 0, "maxLength": 60_u32}, + }, + "required": ["text"], + "actionFees": action_fees, + "additionalProperties": false, + }), + None, + &BTreeMap::new(), + updated.config(), + true, + &mut vec![], + platform_version, + ) + .expect("expected the paid note document type to parse"); + updated + .document_types_mut() + .insert(PAID_NOTE.to_string(), paid_note); + + let update = DataContractUpdateTransition::new_from_data_contract( + updated.clone(), + &self.owner.identity.clone().into_partial_identity_info(), + CRITICAL_KEY_ID, + self.owner.contract_nonce(), + 0, + &self.owner.signer, + platform_version, + None, + ) + .await + .expect("expected to build the contract update"); + (updated, update) + } + + /// A claim of `pot` by `actor`, signed with its CRITICAL key + async fn claim(&self, actor: &Actor, pot: ContractFeePot) -> StateTransition { + claim_of(actor, self.contract.id(), pot).await + } + + fn process( + &self, + transition: &StateTransition, + epoch_index: EpochIndex, + transaction: &Transaction, + ) -> StateTransitionExecutionResult { + let state = self.platform.state.load(); + let platform_version = state.current_platform_version().expect("version"); + let result = self + .platform + .platform + .process_raw_state_transitions( + &vec![transition + .serialize_to_bytes() + .expect("expected to serialize")], + &state, + &BlockInfo { + epoch: Epoch::new(epoch_index).expect("expected an epoch"), + ..Default::default() + }, + transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + result.execution_results()[0].clone() + } + + fn commit(&self, transaction: Transaction) { + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + } + + fn check_tx_codes(&self, transition: &StateTransition) -> Vec { + let version = PlatformVersion::latest(); + let state = self.platform.state.load(); + let platform_ref = PlatformRef { + drive: &self.platform.drive, + state: &state, + config: &self.platform.config, + core_rpc: &self.platform.core_rpc, + }; + let raw = transition + .serialize_to_bytes() + .expect("expected to serialize"); + self.platform + .check_tx(&raw, FirstTimeCheck, &platform_ref, version) + .expect("expected to check tx") + .errors + .iter() + .map(|error| error.code()) + .collect() + } + + fn pot(&self, pot: ContractFeePot, transaction: Option<&Transaction>) -> ContractFeePotState { + self.platform + .drive + .fetch_contract_fee_pot( + self.contract.id(), + pot, + transaction, + PlatformVersion::latest(), + ) + .expect("expected to fetch the pot") + } + + fn credits(&self, actor: &Actor, transaction: Option<&Transaction>) -> Credits { + self.platform + .drive + .fetch_identity_balance( + actor.id().to_buffer(), + transaction, + PlatformVersion::latest(), + ) + .expect("expected to fetch the balance") + .expect("expected a balance") + } + + fn credits_in_trees(&self, transaction: &Transaction) -> Credits { + self.platform + .drive + .calculate_total_credits_balance(Some(transaction), &PlatformVersion::latest().drive) + .expect("expected to sum the credits") + .total_in_trees() + .expect("expected the credits to add up") + } +} + +async fn claim_of(actor: &Actor, contract_id: Identifier, pot: ContractFeePot) -> StateTransition { + ContractFeeClaimTransition::try_from_identity_with_signer( + &actor.identity, + &CRITICAL_KEY_ID, + contract_id, + pot, + actor.contract_nonce(), + 0, + &actor.signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("expected to build the claim") +} + +fn gas(execution: &StateTransitionExecutionResult) -> Credits { + match execution { + StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. } => { + fee_result.total_base_fee() + } + StateTransitionExecutionResult::PaidConsensusError { actual_fees, .. } => { + actual_fees.total_base_fee() + } + other => panic!("expected a paid result, got {other:?}"), + } +} + +#[tokio::test] +async fn should_split_the_moderators_pot_equally_and_leave_the_remainder() { + let setup = Setup::new(Team::TwoModerators).await; + setup.fill(ContractFeePot::Moderators, 1_001); + let claim = setup + .claim(&setup.moderator_a, ContractFeePot::Moderators) + .await; + assert_eq!(setup.check_tx_codes(&claim), Vec::::new()); + let transaction = setup.platform.drive.grove.start_transaction(); + let credits_before = setup.credits_in_trees(&transaction); + + let result = setup.process(&claim, 3, &transaction); + + assert_success(&result); + let gas = gas(&result); + // The claimant pays for the claim and is paid a share like the other moderator. + assert_eq!( + setup.credits(&setup.moderator_a, Some(&transaction)), + STARTING_CREDITS - gas + 500 + ); + assert_eq!( + setup.credits(&setup.moderator_b, Some(&transaction)), + STARTING_CREDITS + 500 + ); + // The owner is not appointed: they are not on the team. + assert_eq!( + setup.credits(&setup.owner, Some(&transaction)), + setup.credits(&setup.owner, None) + ); + assert_eq!( + setup.pot(ContractFeePot::Moderators, Some(&transaction)), + ContractFeePotState { + credits: 1, + last_claim_epoch: Some(3), + } + ); + // The pot only moved into balances: what left the trees is the gas of the claim. + assert_eq!(credits_before - setup.credits_in_trees(&transaction), gas); +} + +#[tokio::test] +async fn should_pay_an_appointed_owner_a_share_of_the_moderators_pot() { + let setup = Setup::new(Team::TwoModeratorsAndTheOwner).await; + setup.fill(ContractFeePot::Moderators, 900); + let owner_credits = setup.credits(&setup.owner, None); + let claim = setup.claim(&setup.owner, ContractFeePot::Moderators).await; + let transaction = setup.platform.drive.grove.start_transaction(); + + let result = setup.process(&claim, 0, &transaction); + + assert_success(&result); + assert_eq!( + setup.credits(&setup.owner, Some(&transaction)), + owner_credits - gas(&result) + 300 + ); + for moderator in [&setup.moderator_a, &setup.moderator_b] { + assert_eq!( + setup.credits(moderator, Some(&transaction)), + STARTING_CREDITS + 300 + ); + } + assert_eq!( + setup + .pot(ContractFeePot::Moderators, Some(&transaction)) + .credits, + 0 + ); +} + +#[tokio::test] +async fn should_let_only_a_member_of_the_team_claim_the_moderators_pot() { + let setup = Setup::new(Team::TwoModerators).await; + setup.fill(ContractFeePot::Moderators, 1_000); + + // Neither a stranger nor the owner, who is not appointed, is on the team. + for outsider in [&setup.stranger, &setup.owner] { + let claim = setup.claim(outsider, ContractFeePot::Moderators).await; + assert_eq!( + setup.check_tx_codes(&claim), + vec![CONTRACT_FEE_CLAIM_NOT_ALLOWED] + ); + let transaction = setup.platform.drive.grove.start_transaction(); + let result = setup.process(&claim, 0, &transaction); + assert_paid_with_code(&result, CONTRACT_FEE_CLAIM_NOT_ALLOWED); + assert_eq!( + setup.pot(ContractFeePot::Moderators, Some(&transaction)), + ContractFeePotState { + credits: 1_000, + last_claim_epoch: None, + }, + "a refused claim pays nothing out and does not use up the epoch" + ); + } +} + +#[tokio::test] +async fn should_let_nobody_but_the_owner_claim_when_nobody_is_appointed() { + let setup = Setup::new(Team::OwnerAlone).await; + setup.fill(ContractFeePot::Moderators, 700); + let owner_credits = setup.credits(&setup.owner, None); + + let claim = setup + .claim(&setup.moderator_a, ContractFeePot::Moderators) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + assert_paid_with_code( + &setup.process(&claim, 0, &transaction), + CONTRACT_FEE_CLAIM_NOT_ALLOWED, + ); + + let claim = setup.claim(&setup.owner, ContractFeePot::Moderators).await; + let result = setup.process(&claim, 0, &transaction); + assert_success(&result); + assert_eq!( + setup.credits(&setup.owner, Some(&transaction)), + owner_credits - gas(&result) + 700 + ); +} + +#[tokio::test] +async fn should_pay_out_a_pot_at_most_once_per_epoch() { + let setup = Setup::new(Team::TwoModerators).await; + setup.fill(ContractFeePot::Moderators, 1_000); + let transaction = setup.platform.drive.grove.start_transaction(); + + let first = setup + .claim(&setup.moderator_a, ContractFeePot::Moderators) + .await; + assert_success(&setup.process(&first, 5, &transaction)); + + // More fees come in during the epoch; the other moderator cannot pay them out yet, and the + // refusal is paid for. + setup.commit(transaction); + setup.fill(ContractFeePot::Moderators, 400); + let transaction = setup.platform.drive.grove.start_transaction(); + let second = setup + .claim(&setup.moderator_b, ContractFeePot::Moderators) + .await; + let credits_before = setup.credits(&setup.moderator_b, Some(&transaction)); + let result = setup.process(&second, 5, &transaction); + assert_paid_with_code(&result, CONTRACT_FEES_ALREADY_CLAIMED_THIS_EPOCH); + assert_eq!( + setup.credits(&setup.moderator_b, Some(&transaction)), + credits_before - gas(&result) + ); + assert_eq!( + setup + .pot(ContractFeePot::Moderators, Some(&transaction)) + .credits, + 400 + ); + + // The next epoch it is paid out again. + let third = setup + .claim(&setup.moderator_b, ContractFeePot::Moderators) + .await; + assert_success(&setup.process(&third, 6, &transaction)); + assert_eq!( + setup.pot(ContractFeePot::Moderators, Some(&transaction)), + ContractFeePotState { + credits: 0, + last_claim_epoch: Some(6), + } + ); +} + +#[tokio::test] +async fn should_refuse_a_pot_that_holds_less_than_a_credit_for_each_recipient() { + let setup = Setup::new(Team::TwoModerators).await; + let claim = setup + .claim(&setup.moderator_a, ContractFeePot::Moderators) + .await; + assert_eq!( + setup.check_tx_codes(&claim), + vec![CONTRACT_FEES_NOTHING_TO_CLAIM] + ); + + // One credit between two moderators rounds down to nothing each. + setup.fill(ContractFeePot::Moderators, 1); + let transaction = setup.platform.drive.grove.start_transaction(); + assert_paid_with_code( + &setup.process(&claim, 2, &transaction), + CONTRACT_FEES_NOTHING_TO_CLAIM, + ); + assert_eq!( + setup.pot(ContractFeePot::Moderators, Some(&transaction)), + ContractFeePotState { + credits: 1, + last_claim_epoch: None, + } + ); +} + +#[tokio::test] +async fn should_pay_the_owner_pot_to_the_owner_alone_and_on_its_own_epoch_clock() { + let setup = Setup::new(Team::TwoModerators).await; + setup.fill(ContractFeePot::Owner, 5_000); + setup.fill(ContractFeePot::Moderators, 1_000); + let owner_credits = setup.credits(&setup.owner, None); + let transaction = setup.platform.drive.grove.start_transaction(); + + // A moderator is no recipient of the owner pot. + let by_a_moderator = setup.claim(&setup.moderator_a, ContractFeePot::Owner).await; + assert_paid_with_code( + &setup.process(&by_a_moderator, 4, &transaction), + CONTRACT_FEE_CLAIM_NOT_ALLOWED, + ); + + let by_the_owner = setup.claim(&setup.owner, ContractFeePot::Owner).await; + let result = setup.process(&by_the_owner, 4, &transaction); + assert_success(&result); + assert_eq!( + setup.credits(&setup.owner, Some(&transaction)), + owner_credits - gas(&result) + 5_000 + ); + assert_eq!( + setup.pot(ContractFeePot::Owner, Some(&transaction)), + ContractFeePotState { + credits: 0, + last_claim_epoch: Some(4), + } + ); + + // The owner's claim did not use up the team's claim of this epoch. + assert_eq!( + setup + .pot(ContractFeePot::Moderators, Some(&transaction)) + .last_claim_epoch, + None + ); + let by_the_team = setup + .claim(&setup.moderator_a, ContractFeePot::Moderators) + .await; + assert_success(&setup.process(&by_the_team, 4, &transaction)); + + // And the owner pot is paid out once per epoch like the other. + setup.commit(transaction); + setup.fill(ContractFeePot::Owner, 10); + let transaction = setup.platform.drive.grove.start_transaction(); + let again = setup.claim(&setup.owner, ContractFeePot::Owner).await; + assert_paid_with_code( + &setup.process(&again, 4, &transaction), + CONTRACT_FEES_ALREADY_CLAIMED_THIS_EPOCH, + ); +} + +#[tokio::test] +async fn should_leave_the_moderators_pot_of_an_unmoderated_contract_to_nobody() { + let setup = Setup::new(Team::NoModeration).await; + setup.fill(ContractFeePot::Owner, 100); + let transaction = setup.platform.drive.grove.start_transaction(); + + // No moderation, no team: not even the owner is a recipient of the moderators pot. + let claim = setup.claim(&setup.owner, ContractFeePot::Moderators).await; + assert_paid_with_code( + &setup.process(&claim, 0, &transaction), + CONTRACT_FEE_CLAIM_NOT_ALLOWED, + ); + + let claim = setup.claim(&setup.owner, ContractFeePot::Owner).await; + assert_success(&setup.process(&claim, 0, &transaction)); +} + +#[tokio::test] +async fn should_refuse_a_claim_on_an_unknown_contract_unpaid() { + let setup = Setup::new(Team::TwoModerators).await; + let claim = claim_of( + &setup.moderator_a, + Identifier::from([0x55; 32]), + ContractFeePot::Moderators, + ) + .await; + assert_eq!( + setup.check_tx_codes(&claim), + vec![DATA_CONTRACT_NOT_PRESENT] + ); + let transaction = setup.platform.drive.grove.start_transaction(); + assert_unpaid_with_code( + &setup.process(&claim, 0, &transaction), + DATA_CONTRACT_NOT_PRESENT, + ); +} + +#[tokio::test] +async fn should_prove_the_pot_and_the_balances_of_everyone_it_paid() { + let setup = Setup::new(Team::TwoModerators).await; + setup.fill(ContractFeePot::Moderators, 1_001); + let claim = setup + .claim(&setup.moderator_a, ContractFeePot::Moderators) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + assert_success(&setup.process(&claim, 9, &transaction)); + setup.commit(transaction); + + let platform_version = PlatformVersion::latest(); + let proof = setup + .platform + .drive + .prove_state_transition(&claim, None, platform_version) + .expect("expected to prove the state transition") + .into_data() + .expect("expected proof bytes"); + // The recipients are read from the contract, as a client holding the contract does. + let known_contracts: BTreeMap = + BTreeMap::from([(setup.contract.id(), setup.contract.clone())]); + let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( + &claim, + &BlockInfo::default(), + &proof, + &|id| Ok(known_contracts.get(id).cloned().map(std::sync::Arc::new)), + platform_version, + ) + .expect("expected the proof to verify"); + + let StateTransitionProofOutcome::AffectedState( + StateTransitionProofResult::VerifiedContractFeeClaim( + contract_id, + pot, + last_claim_epoch, + remaining, + balances, + ), + ) = outcome + else { + panic!("expected a contract fee claim result, got {outcome:?}"); + }; + assert_eq!(contract_id, setup.contract.id()); + assert_eq!(pot, ContractFeePot::Moderators); + assert_eq!(last_claim_epoch, 9); + assert_eq!(remaining, 1); + assert_eq!( + balances, + BTreeMap::from([ + ( + setup.moderator_a.id(), + setup.credits(&setup.moderator_a, None) + ), + (setup.moderator_b.id(), STARTING_CREDITS + 500), + ]) + ); +} + +#[tokio::test] +async fn should_not_verify_a_claim_that_never_executed() { + let setup = Setup::new(Team::TwoModerators).await; + setup.fill(ContractFeePot::Moderators, 1_001); + let claim = setup + .claim(&setup.moderator_a, ContractFeePot::Moderators) + .await; + + let platform_version = PlatformVersion::latest(); + let proof = setup + .platform + .drive + .prove_state_transition(&claim, None, platform_version) + .expect("expected to prove the state transition") + .into_data() + .expect("expected proof bytes"); + let known_contracts: BTreeMap = + BTreeMap::from([(setup.contract.id(), setup.contract.clone())]); + let result = Drive::verify_state_transition_was_executed_with_proof( + &claim, + &BlockInfo::default(), + &proof, + &|id| Ok(known_contracts.get(id).cloned().map(std::sync::Arc::new)), + platform_version, + ); + assert!( + result.is_err(), + "a pot that was never claimed proves no claim" + ); +} + +#[tokio::test] +async fn should_not_be_active_before_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let setup = Setup::new_at(Team::NoModeration, platform_version).await; + let transaction = setup.platform.drive.grove.start_transaction(); + let claim = setup.claim(&setup.owner, ContractFeePot::Owner).await; + let execution = setup.process(&claim, 0, &transaction); + // The transition type does not exist before protocol version 14, so the node cannot even + // decode it as active: the same refusal every transition added since the fork gets. + assert!( + matches!( + &execution, + StateTransitionExecutionResult::InternalError(message) + if message.contains("ContractFeeClaim") && message.contains("not active") + ), + "expected the transition to be inactive before protocol version 14, got {execution:?}" + ); +} + +#[tokio::test] +async fn should_let_a_live_contract_gain_fees_through_a_new_document_type() { + // The fees of a document type never change, so this is how a contract that is already + // published starts to charge: an update adds a document type that declares them. + let setup = Setup::new(Team::TwoModerators).await; + let (updated, update) = setup + .add_paid_note(platform_value!({ + "pricing": "fixed", + "create": {"owner": 700_u64, "moderators": 300_u64}, + })) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + assert_success(&setup.process(&update, 0, &transaction)); + + // A stranger writes a paid note and fills both pots. + let platform_version = PlatformVersion::latest(); + let paid_note = updated + .document_type_for_name(PAID_NOTE) + .expect("expected the paid note document type"); + let mut rng = StdRng::seed_from_u64(7); + let entropy = Bytes32::random_with_rng(&mut rng); + let document = paid_note + .random_document_with_identifier_and_entropy( + &mut rng, + setup.stranger.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::MinDocumentFillSize, + platform_version, + ) + .expect("expected a random paid note"); + let creation = BatchTransition::new_document_creation_transition_from_document( + document, + paid_note, + entropy.0, + &setup.stranger.key, + setup.stranger.contract_nonce(), + 0, + None, + &setup.stranger.signer, + platform_version, + None, + ) + .await + .expect("expected to build the paid note creation"); + assert_success(&setup.process(&creation, 0, &transaction)); + assert_eq!( + setup.pot(ContractFeePot::Owner, Some(&transaction)).credits, + 700 + ); + assert_eq!( + setup + .pot(ContractFeePot::Moderators, Some(&transaction)) + .credits, + 300 + ); + + // And the team is paid out of what the new document type collected. + let claim = setup + .claim(&setup.moderator_b, ContractFeePot::Moderators) + .await; + assert_success(&setup.process(&claim, 0, &transaction)); + assert_eq!( + setup.credits(&setup.moderator_a, Some(&transaction)), + STARTING_CREDITS + 150 + ); +} + +#[tokio::test] +async fn should_refuse_an_update_adding_a_moderators_fee_to_an_unmoderated_contract() { + let setup = Setup::new(Team::NoModeration).await; + let (_, update) = setup + .add_paid_note(platform_value!({"create": {"owner": 700_u64, "moderators": 300_u64}})) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + assert_unpaid_with_code( + &setup.process(&update, 0, &transaction), + DOCUMENT_ACTION_FEES_WITHOUT_MODERATION, + ); + + // An owner fee needs no team, and the same update without the moderators part goes through. + let (_, update) = setup + .add_paid_note(platform_value!({"create": {"owner": 700_u64}})) + .await; + assert_success(&setup.process(&update, 0, &transaction)); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/mod.rs index b0ba919a2a2..3a10bc33919 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/mod.rs @@ -2,7 +2,7 @@ mod basic_structure; mod identity_contract_nonce; mod state; #[cfg(test)] -mod tests; +pub(crate) mod tests; use dpp::address_funds::PlatformAddress; use dpp::block::block_info::BlockInfo; 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 e71715cf7b5..7602286d11a 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 @@ -74,7 +74,7 @@ const CONTRACT_MODERATION_TARGET_NOT_FOUND: u32 = 41109; const CONTRACT_MODERATOR_IDENTITY_NOT_FOUND: u32 = 41110; const CONTRACT_MODERATION_COUNTERPARTY_BARRED: u32 = 41114; -const CRITICAL_KEY_ID: KeyID = 1; +pub(crate) const CRITICAL_KEY_ID: KeyID = 1; const DOCUMENT_TYPE: &str = "niceDocument"; const BLOCK_TIME_MS: TimestampMillis = 1_000_000; @@ -85,16 +85,16 @@ const BOTH: [ContractModerationList; 2] = [ /// A registered identity with its signer and its CRITICAL key, plus the nonces the tests hand /// out: gaps are allowed, reuse is not. -struct Actor { - identity: Identity, - signer: SimpleSigner, - key: IdentityPublicKey, +pub(crate) struct Actor { + pub(crate) identity: Identity, + pub(crate) signer: SimpleSigner, + pub(crate) key: IdentityPublicKey, next_identity_nonce: Cell, - next_contract_nonce: Cell, + pub(crate) next_contract_nonce: Cell, } impl Actor { - fn new(platform: &mut TempPlatform, seed: u64) -> Self { + pub(crate) fn new(platform: &mut TempPlatform, seed: u64) -> Self { let (identity, signer, key) = setup_identity(platform, seed, dash_to_credits!(10)); Self { identity, @@ -105,17 +105,17 @@ impl Actor { } } - fn id(&self) -> Identifier { + pub(crate) fn id(&self) -> Identifier { self.identity.id() } - fn identity_nonce(&self) -> IdentityNonce { + pub(crate) fn identity_nonce(&self) -> IdentityNonce { let nonce = self.next_identity_nonce.get(); self.next_identity_nonce.set(nonce + 1); nonce } - fn contract_nonce(&self) -> IdentityNonce { + pub(crate) fn contract_nonce(&self) -> IdentityNonce { let nonce = self.next_contract_nonce.get(); self.next_contract_nonce.set(nonce + 1); nonce @@ -443,7 +443,7 @@ impl Setup { } } -fn assert_success(execution: &StateTransitionExecutionResult) { +pub(crate) fn assert_success(execution: &StateTransitionExecutionResult) { assert!( matches!( execution, @@ -453,14 +453,14 @@ fn assert_success(execution: &StateTransitionExecutionResult) { ); } -fn assert_paid_with_code(execution: &StateTransitionExecutionResult, code: u32) { +pub(crate) fn assert_paid_with_code(execution: &StateTransitionExecutionResult, code: u32) { assert!( matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == code), "expected a paid error {code}, got {execution:?}" ); } -fn assert_unpaid_with_code(execution: &StateTransitionExecutionResult, code: u32) { +pub(crate) fn assert_unpaid_with_code(execution: &StateTransitionExecutionResult, code: u32) { assert!( matches!(execution, StateTransitionExecutionResult::UnpaidConsensusError(error) if error.code() == code), "expected an unpaid error {code}, got {execution:?}" diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs index c1bf7760d80..56d47fbd808 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs @@ -5,6 +5,7 @@ use dpp::consensus::basic::contract_group::{ InvalidContractGroupDescriptionLengthError, InvalidContractGroupNameLengthError, RedundantContractGroupMembershipError, }; +use dpp::consensus::basic::contract_moderation::DocumentActionFeesWithoutModerationError; use dpp::consensus::basic::data_contract::DataContractInvalidRequiredFieldsUpdateError; use dpp::consensus::ConsensusError; use dpp::contract_group::ContractGroupMember; @@ -12,6 +13,7 @@ use dpp::dashcore::Network; use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dpp::data_contract::config::v2::DataContractConfigGettersV2; +use dpp::data_contract::document_type::action_fees::DocumentActionFees; use dpp::identifier::Identifier; use dpp::state_transition::data_contract_create_transition::accessors::{ DataContractCreateTransitionAccessorsV0, DataContractCreateTransitionAccessorsV1, @@ -135,6 +137,21 @@ impl DataContractCreateStateTransitionBasicStructureValidationV2 for DataContrac } } + // Document action fees: a document type may only charge for the moderators when the + // contract declares moderation, since the moderation team is who that pot is for. + if self.data_contract().config().moderation().is_none() { + if let Some(document_type_name) = + DocumentActionFees::first_document_type_charging_moderators( + self.data_contract().document_schemas(), + ) + { + return Ok(SimpleConsensusValidationResult::new_with_error( + DocumentActionFeesWithoutModerationError::new(document_type_name.clone()) + .into(), + )); + } + } + Ok(SimpleConsensusValidationResult::new()) } } @@ -270,6 +287,7 @@ mod tests { use dpp::balances::credits::TokenAmount; use dpp::consensus::basic::BasicError; use dpp::consensus::ConsensusError; + use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; use dpp::data_contract::accessors::v1::DataContractV1Setters; use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; @@ -279,7 +297,9 @@ mod tests { use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Setters; use dpp::data_contract::associated_token::token_pre_programmed_distribution::v0::TokenPreProgrammedDistributionV0; use dpp::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution; - use dpp::platform_value::platform_value; + use dpp::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; + use dpp::data_contract::serialized_version::DataContractInSerializationFormat; + use dpp::platform_value::{platform_value, Value}; use dpp::prelude::IdentityNonce; use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV0; use dpp::tests::fixtures::get_data_contract_fixture; @@ -415,6 +435,89 @@ mod tests { .into() } + /// A create transition whose `niceDocument` document type charges `action_fees`, on a + /// contract that declares moderation or not + fn create_transition_with_action_fees( + action_fees: Value, + moderated: bool, + platform_version: &PlatformVersion, + ) -> DataContractCreateTransition { + let identity_nonce = IdentityNonce::default(); + let mut data_contract = + get_data_contract_fixture(None, identity_nonce, platform_version.protocol_version) + .data_contract_owned(); + if moderated { + data_contract.set_config(data_contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: true, + suspensions: false, + moderators: ContractModerators::ContractOwner, + }, + ))); + } + let mut serialized: DataContractInSerializationFormat = data_contract + .try_into_platform_versioned(platform_version) + .expect("failed to convert data contract"); + serialized + .document_schemas_mut() + .get_mut("niceDocument") + .expect("expected the niceDocument schema") + .insert("actionFees".to_string(), action_fees) + .expect("expected to declare the action fees"); + + DataContractCreateTransitionV0 { + data_contract: serialized, + identity_nonce, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into() + } + + // The moderators pot is for the moderation team, and a contract that declares no + // moderation has none: the credits could never be claimed. + #[test] + fn should_reject_a_moderators_action_fee_on_a_contract_without_moderation() { + let platform_version = PlatformVersion::latest(); + let transition = create_transition_with_action_fees( + platform_value!({"create": {"owner": 5_u64, "moderators": 10_u64}}), + false, + platform_version, + ); + + let result = transition + .validate_basic_structure(Network::Testnet, platform_version) + .expect("failed to validate basic structure"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DocumentActionFeesWithoutModerationError(e) + )] if e.document_type_name() == "niceDocument" + ); + } + + #[test] + fn should_accept_action_fees_the_contract_can_pay_out() { + let platform_version = PlatformVersion::latest(); + // An owner fee needs no moderation, and a moderators fee is fine once it is declared. + for (action_fees, moderated) in [ + (platform_value!({"create": {"owner": 5_u64}}), false), + ( + platform_value!({"create": {"owner": 5_u64, "moderators": 10_u64}}), + true, + ), + ] { + let transition = + create_transition_with_action_fees(action_fees, moderated, platform_version); + let result = transition + .validate_basic_structure(Network::Testnet, platform_version) + .expect("failed to validate basic structure"); + assert!(result.is_valid(), "{:?}", result.errors); + } + } + #[test] fn should_reject_a_pre_programmed_release_totalling_over_the_limit() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs index c681cdc87ab..6a7c3c308ab 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/basic_structure/v2/mod.rs @@ -1,6 +1,8 @@ use crate::error::Error; +use dpp::consensus::basic::contract_moderation::DocumentActionFeesWithoutModerationError; use dpp::dashcore::Network; use dpp::data_contract::config::v2::DataContractConfigGettersV2; +use dpp::data_contract::document_type::action_fees::DocumentActionFees; use dpp::state_transition::data_contract_update_transition::accessors::DataContractUpdateTransitionAccessorsV0; use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; use dpp::validation::SimpleConsensusValidationResult; @@ -40,6 +42,21 @@ impl DataContractUpdateStateTransitionBasicStructureValidationV2 for DataContrac } } + // Document action fees: a document type may only charge for the moderators when the + // contract declares moderation, since the moderation team is who that pot is for. + if self.data_contract().config().moderation().is_none() { + if let Some(document_type_name) = + DocumentActionFees::first_document_type_charging_moderators( + self.data_contract().document_schemas(), + ) + { + return Ok(SimpleConsensusValidationResult::new_with_error( + DocumentActionFeesWithoutModerationError::new(document_type_name.clone()) + .into(), + )); + } + } + Ok(SimpleConsensusValidationResult::new()) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 5b192c2798d..33fc4d57282 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -31,6 +31,9 @@ pub mod data_contract_update; /// Module for banning and suspending identities on a moderated data contract. pub mod contract_user_moderation; +/// Module for paying out the fee pots a data contract's document action fees collect in. +pub mod contract_fee_claim; + /// Module for voting from a masternode. pub mod masternode_vote; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs index 4ba390a7c9f..ec238cd6da3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs @@ -195,6 +195,14 @@ impl StateTransitionActionTransformer for StateTransition { execution_context, tx, ), + StateTransition::ContractFeeClaim(st) => st.transform_into_action( + platform, + block_info, + remaining_address_input_balances, + validation_mode, + execution_context, + tx, + ), StateTransition::IdentityTopUp(st) => { let signable_bytes = self.signable_bytes()?; st.transform_into_action_for_identity_top_up_transition( diff --git a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs index ed6b1141afd..5cda177a101 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs @@ -1316,6 +1316,7 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( | StateTransitionAction::ShieldFromIdentityAction(_) | StateTransitionAction::IdentityKeyLimitsUpdateAction(_) | StateTransitionAction::ContractUserModerationAction(_) + | StateTransitionAction::ContractFeeClaimAction(_) | StateTransitionAction::IdentityTopUpFromShieldedPoolAction(_) => { // The strategy harness does not generate shielded transitions (no shielded // `OperationType`), so their proof-verification roundtrip isn't exercised here. diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 8a5beea92fd..1fadc8c6cd6 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -1773,6 +1773,47 @@ "source": "packages/rs-drive/src/drive/mod.rs", "description": "Credits put aside in advance to fund specific state transitions, such as masternode votes on a contested resource.", "children": [ + { + "id": "prefunded_balances.owner_fee_pots", + "key": { + "type": "fixed", + "hex": "40", + "label": "ContractOwnerFees", + "constant": "PREFUNDED_BALANCES_FOR_CONTRACT_OWNER_FEES" + }, + "kinds": [ + "SumTree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/contract/paths.rs", + "book": "fees/overview.md", + "description": "The owner parts of every contract's document action fees, until the contract owner claims them.", + "children": [ + { + "id": "prefunded_balances.owner_fee_pots.pot", + "key": { + "type": "dynamic", + "name": "contract_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The data contract id" + }, + "kinds": [ + "SumItem" + ], + "value": "credits", + "since": 14, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/contract/paths.rs", + "description": "What the contract's owner can claim, at most once per epoch.", + "children": [] + } + ] + }, { "id": "prefunded_balances.for_voting", "key": { @@ -1812,6 +1853,47 @@ "children": [] } ] + }, + { + "id": "prefunded_balances.moderators_fee_pots", + "key": { + "type": "fixed", + "hex": "c0", + "label": "ContractModeratorFees", + "constant": "PREFUNDED_BALANCES_FOR_CONTRACT_MODERATOR_FEES" + }, + "kinds": [ + "SumTree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/contract/paths.rs", + "book": "fees/overview.md", + "description": "The moderators parts of every contract's document action fees, until a member of the moderation team claims them for the team.", + "children": [ + { + "id": "prefunded_balances.moderators_fee_pots.pot", + "key": { + "type": "dynamic", + "name": "contract_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The data contract id" + }, + "kinds": [ + "SumItem" + ], + "value": "credits", + "since": 14, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/contract/paths.rs", + "description": "What the contract's moderation team shares equally, at most once per epoch.", + "children": [] + } + ] } ] }, @@ -2872,6 +2954,24 @@ "book": "data-model/contract-moderation.md", "description": "Everything a contract keeps beside itself and its documents. One key, so that the contract's layer holds three and its Merk keeps the documents on top. Inside, the keys are spread like the root layer's, with the most read one, the banlist, in the middle.", "children": [ + { + "id": "contracts.contract.other.last_owner_fee_claim_epoch", + "key": { + "type": "fixed", + "hex": "20", + "label": "LastOwnerFeeClaimEpoch", + "constant": "CONTRACT_LAST_OWNER_FEE_CLAIM_EPOCH_KEY" + }, + "kinds": [ + "Item" + ], + "value": "epoch index, u16 big endian", + "since": 14, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/contract/paths.rs", + "description": "The epoch the contract's owner fee pot was last claimed in. Written by the first claim; a pot is claimed at most once per epoch.", + "children": [] + }, { "id": "contracts.contract.other.version", "key": { @@ -2895,6 +2995,24 @@ "description": "The contract's version, readable without deserializing the contract. Rewritten on every update.", "children": [] }, + { + "id": "contracts.contract.other.last_moderators_fee_claim_epoch", + "key": { + "type": "fixed", + "hex": "60", + "label": "LastModeratorsFeeClaimEpoch", + "constant": "CONTRACT_LAST_MODERATORS_FEE_CLAIM_EPOCH_KEY" + }, + "kinds": [ + "Item" + ], + "value": "epoch index, u16 big endian", + "since": 14, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/contract/paths.rs", + "description": "The epoch the contract's moderators fee pot was last claimed in. Written by the first claim; a pot is claimed at most once per epoch.", + "children": [] + }, { "id": "contracts.contract.other.banlist", "key": { @@ -4865,7 +4983,13 @@ "tree": { "hex": "80", "left": { - "hex": "40" + "hex": "40", + "left": { + "hex": "20" + }, + "right": { + "hex": "60" + } }, "right": { "hex": "c0" @@ -5125,6 +5249,18 @@ } ] }, + "prefunded_balances": { + "origin": "genesis@14", + "tree": { + "hex": "80", + "left": { + "hex": "40" + }, + "right": { + "hex": "c0" + } + } + }, "root": { "origin": "genesis@14", "tree": { diff --git a/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/mod.rs new file mode 100644 index 00000000000..11e0447ed7e --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/mod.rs @@ -0,0 +1,68 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::balances::credits::Credits; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// The operations that add `amount` credits to one of a contract's fee pots. The pot is + /// created by the first credits it receives. + /// + /// The caller removes the same credits from an identity's balance in the same batch, so + /// the sum of all credits is unchanged. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the pot belongs to. + /// * `pot`: The pot. + /// * `amount`: The credits to add. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the read and the write. + /// * `Err(Error)` when the version is unknown, a read fails, or the pot would overflow. + pub fn add_to_contract_fee_pot_operations( + &self, + contract_id: Identifier, + pot: ContractFeePot, + amount: Credits, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .contract + .fee_pots + .add_to_contract_fee_pot + { + 0 => self.add_to_contract_fee_pot_operations_v0( + contract_id, + pot, + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_to_contract_fee_pot_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/v0/mod.rs new file mode 100644 index 00000000000..9f6ce45ace9 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/add_to_contract_fee_pot/v0/mod.rs @@ -0,0 +1,104 @@ +use crate::drive::contract::paths::{ + contract_fee_pots_key, contract_fee_pots_path, contract_fee_pots_path_vec, +}; +use crate::drive::prefunded_specialized_balances::{ + prefunded_specialized_balances_path, prefunded_specialized_balances_path_vec, +}; +use crate::drive::Drive; +use crate::error::fee::FeeError; +use crate::error::identity::IdentityError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::grove_operations::DirectQueryType; +use dpp::balances::credits::{Credits, MAX_CREDITS}; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + pub(super) fn add_to_contract_fee_pot_operations_v0( + &self, + contract_id: Identifier, + pot: ContractFeePot, + amount: Credits, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations = vec![]; + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_contract_fee_pot_update( + pot, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + let path = contract_fee_pots_path(pot); + let previous_credits = self.grove_get_raw_value_u64_from_encoded_var_vec( + (&path).into(), + contract_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + let new_total = previous_credits + .unwrap_or_default() + .checked_add(amount) + .ok_or(Error::Fee(FeeError::Overflow( + "adding to a contract fee pot would overflow credits", + )))?; + // As for every balance, i64::MAX itself is avoided. + if new_total >= MAX_CREDITS { + return Err(Error::Identity(IdentityError::CriticalBalanceOverflow( + "trying to set a contract fee pot to over max credits amount (i64::MAX)", + ))); + } + // A chain that reached protocol version 14 on a build from before the fee pots never + // ran the upgrade step that creates their trees, and was not born with them either. + // The first fee a pot receives therefore checks that its tree is there, a billed read, + // and creates it in the same batch when it is not. A pot that already holds credits + // proves its tree, so no later fee pays for the check. + if previous_credits.is_none() { + let prefunded_path = prefunded_specialized_balances_path(); + let pots_tree_exists = self.grove_has_raw( + (&prefunded_path).into(), + contract_fee_pots_key(pot), + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + if !pots_tree_exists { + drive_operations.push(GroveOperation(QualifiedGroveDbOp::insert_or_replace_op( + prefunded_specialized_balances_path_vec(), + contract_fee_pots_key(pot).to_vec(), + Element::empty_sum_tree(), + ))); + } + } + let op = if previous_credits.is_some() { + QualifiedGroveDbOp::replace_op( + contract_fee_pots_path_vec(pot), + contract_id.to_vec(), + Element::new_sum_item(new_total as i64), + ) + } else { + QualifiedGroveDbOp::insert_or_replace_op( + contract_fee_pots_path_vec(pot), + contract_id.to_vec(), + Element::new_sum_item(new_total as i64), + ) + } + .dont_check_for_backwards_references(); + drive_operations.push(GroveOperation(op)); + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/mod.rs new file mode 100644 index 00000000000..d1098036e2d --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/mod.rs @@ -0,0 +1,68 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::balances::credits::Credits; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// The operations that take `amount` credits out of one of a contract's fee pots. + /// + /// The caller adds the same credits to identities' balances in the same batch, so the sum + /// of all credits is unchanged. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the pot belongs to. + /// * `pot`: The pot. + /// * `amount`: The credits to take out. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the read and the write. + /// * `Err(Error)` when the version is unknown, a read fails, or the pot holds less than + /// `amount`. + pub fn deduct_from_contract_fee_pot_operations( + &self, + contract_id: Identifier, + pot: ContractFeePot, + amount: Credits, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .contract + .fee_pots + .deduct_from_contract_fee_pot + { + 0 => self.deduct_from_contract_fee_pot_operations_v0( + contract_id, + pot, + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "deduct_from_contract_fee_pot_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/v0/mod.rs new file mode 100644 index 00000000000..e4266138df1 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/deduct_from_contract_fee_pot/v0/mod.rs @@ -0,0 +1,62 @@ +use crate::drive::contract::paths::{contract_fee_pots_path, contract_fee_pots_path_vec}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::grove_operations::DirectQueryType; +use dpp::balances::credits::Credits; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + pub(super) fn deduct_from_contract_fee_pot_operations_v0( + &self, + contract_id: Identifier, + pot: ContractFeePot, + amount: Credits, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations = vec![]; + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_contract_fee_pot_update( + pot, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + let path = contract_fee_pots_path(pot); + let previous_credits = self + .grove_get_raw_value_u64_from_encoded_var_vec( + (&path).into(), + contract_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )? + .unwrap_or_default(); + let new_total = previous_credits.checked_sub(amount).ok_or_else(|| { + Error::Drive(DriveError::CorruptedCodeExecution( + "deducting more from a contract fee pot than it holds", + )) + })?; + let op = QualifiedGroveDbOp::replace_op( + contract_fee_pots_path_vec(pot), + contract_id.to_vec(), + Element::new_sum_item(new_total as i64), + ) + .dont_check_for_backwards_references(); + drive_operations.push(GroveOperation(op)); + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/mod.rs new file mode 100644 index 00000000000..8fdfb894853 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/mod.rs @@ -0,0 +1,50 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::version::drive_versions::DriveVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerInformation; +use std::collections::HashMap; + +impl Drive { + /// Adds the layer information of a write to one of a contract's fee pots. + /// + /// # Parameters + /// + /// * `pot`: The pot written to. + /// * `estimated_costs_only_with_layer_info`: The estimation map. + /// * `drive_version`: The drive version. + /// + /// # Returns + /// + /// * `Ok(())` once the layers are described. + /// * `Err(Error)` when the version is unknown. + pub(crate) fn add_estimation_costs_for_contract_fee_pot_update( + pot: ContractFeePot, + estimated_costs_only_with_layer_info: &mut HashMap, + drive_version: &DriveVersion, + ) -> Result<(), Error> { + match drive_version + .methods + .contract + .fee_pots + .add_estimation_costs_for_contract_fee_pot_update + { + 0 => { + Self::add_estimation_costs_for_contract_fee_pot_update_v0( + pot, + estimated_costs_only_with_layer_info, + ); + Ok(()) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_estimation_costs_for_contract_fee_pot_update".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v0/mod.rs new file mode 100644 index 00000000000..e04968f913e --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/estimated_costs/v0/mod.rs @@ -0,0 +1,65 @@ +use crate::drive::constants::AVERAGE_BALANCE_SIZE; +use crate::drive::contract::paths::contract_fee_pots_path_vec; +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_path; +use crate::drive::Drive; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::{EstimatedLevel, PotentiallyAtMaxElements}; +use grovedb::EstimatedLayerInformation; +use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; +use grovedb::EstimatedSumTrees::{AllSumTrees, SomeSumTrees}; +use grovedb::TreeType; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + pub(super) fn add_estimation_costs_for_contract_fee_pot_update_v0( + pot: ContractFeePot, + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + // The root layer is left alone when the batch already describes it: a document + // operation of the same batch describes it as well as this does. + estimated_costs_only_with_layer_info + .entry(KeyInfoPath::from_known_path([])) + .or_insert(EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + // The prefunded specialized balances tree is on the 3rd level + estimated_layer_count: EstimatedLevel(3, false), + estimated_layer_sizes: AllSubtrees( + 1, + SomeSumTrees { + sum_trees_weight: 1, + big_sum_trees_weight: 0, + count_trees_weight: 0, + count_sum_trees_weight: 0, + non_sum_trees_weight: 1, + provable_sum_trees_weight: 0, + provable_count_trees_weight: 0, + provable_count_sum_trees_weight: 0, + provable_count_provable_sum_trees_weight: 0, + }, + None, + ), + }); + + // Three sum trees: the voting balances on top, the two kinds of fee pots below. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(prefunded_specialized_balances_path()), + EstimatedLayerInformation { + tree_type: TreeType::SumTree, + estimated_layer_count: EstimatedLevel(1, false), + estimated_layer_sizes: AllSubtrees(1, AllSumTrees, None), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(contract_fee_pots_path_vec(pot)), + EstimatedLayerInformation { + tree_type: TreeType::SumTree, + estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_sizes: AllItems(DEFAULT_HASH_SIZE_U8, AVERAGE_BALANCE_SIZE, None), + }, + ); + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/fetch_action_fee_multiplier/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/fetch_action_fee_multiplier/mod.rs new file mode 100644 index 00000000000..65388858bc1 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/fetch_action_fee_multiplier/mod.rs @@ -0,0 +1,54 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::epoch::Epoch; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::FeeMultiplier; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Reads the fee multiplier, in permille, that document action fees priced by it are + /// scaled with in `epoch`, and the fee of the read so that consensus validation can bill + /// it. + /// + /// It is the multiplier recorded in the epoch's tree. In the first block of an epoch that + /// item is not there yet: state transitions execute before the end of the block, where the + /// epoch is initialized, so the multiplier the epoch is about to be initialized with is + /// used, the one of the fee schedule. Whatever later sets an epoch's multiplier has to + /// feed both. + /// + /// # Parameters + /// + /// * `epoch`: The epoch the action executes in. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((FeeResult, FeeMultiplier))` with the fee of the read and the multiplier. + /// * `Err(Error)` when the version is unknown, the read fails, or the item is malformed. + pub fn fetch_action_fee_multiplier_with_fee( + &self, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(FeeResult, FeeMultiplier), Error> { + match platform_version + .drive + .methods + .contract + .fee_pots + .fetch_action_fee_multiplier + { + 0 => self.fetch_action_fee_multiplier_with_fee_v0(epoch, transaction, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_action_fee_multiplier_with_fee".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/fetch_action_fee_multiplier/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/fetch_action_fee_multiplier/v0/mod.rs new file mode 100644 index 00000000000..f686d74cf6d --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/fetch_action_fee_multiplier/v0/mod.rs @@ -0,0 +1,59 @@ +use crate::drive::credit_pools::epochs::epoch_key_constants::KEY_FEE_MULTIPLIER; +use crate::drive::credit_pools::epochs::paths::EpochProposers; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::DirectQueryType; +use dpp::block::epoch::Epoch; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::FeeMultiplier; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + #[inline(always)] + pub(super) fn fetch_action_fee_multiplier_with_fee_v0( + &self, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(FeeResult, FeeMultiplier), Error> { + let mut drive_operations: Vec = vec![]; + let path = epoch.get_path(); + let stored = self.grove_get_raw_optional_item( + (&path).into(), + KEY_FEE_MULTIPLIER.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + let multiplier = match stored { + Some(encoded) => { + FeeMultiplier::from_be_bytes(encoded.as_slice().try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedSerialization(String::from( + "epochs multiplier must be a u64", + ))) + })?) + } + // The first block of the epoch: the epoch is initialized at the end of the block, + // with this multiplier. + None => platform_version + .fee_version + .uses_version_fee_multiplier_permille + .ok_or(Error::Drive(DriveError::NotSupported( + "the fee_multiplier_permille must be set in fees to price document action fees", + )))?, + }; + let fee = Drive::calculate_fee( + None, + Some(drive_operations), + epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + Ok((fee, multiplier)) + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/fetch_contract_fee_pot/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/fetch_contract_fee_pot/mod.rs new file mode 100644 index 00000000000..a77328e4a33 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/fetch_contract_fee_pot/mod.rs @@ -0,0 +1,116 @@ +mod v0; + +use crate::drive::contract::fee_pots::types::ContractFeePotState; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::epoch::Epoch; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Reads one fee pot of a contract: the credits it holds and the epoch it was last claimed + /// in. A pot that never received credits holds zero, and one that was never claimed has no + /// last claim epoch. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the pot belongs to. + /// * `pot`: The pot. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(ContractFeePotState)` with the pot. + /// * `Err(Error)` when the version is unknown or a read fails. + pub fn fetch_contract_fee_pot( + &self, + contract_id: Identifier, + pot: ContractFeePot, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .contract + .fee_pots + .fetch_contract_fee_pot + { + 0 => self.fetch_contract_fee_pot_add_to_operations_v0( + contract_id, + pot, + transaction, + &mut vec![], + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_contract_fee_pot".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// [`Drive::fetch_contract_fee_pot`] with the fee of the reads, so that consensus + /// validation can bill them. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the pot belongs to. + /// * `pot`: The pot. + /// * `epoch`: The epoch the fee is priced for. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((FeeResult, ContractFeePotState))` with the fee and the pot. + /// * `Err(Error)` when the version is unknown or a read fails. + pub fn fetch_contract_fee_pot_with_fee( + &self, + contract_id: Identifier, + pot: ContractFeePot, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(FeeResult, ContractFeePotState), Error> { + match platform_version + .drive + .methods + .contract + .fee_pots + .fetch_contract_fee_pot + { + 0 => { + let mut drive_operations: Vec = vec![]; + let state = self.fetch_contract_fee_pot_add_to_operations_v0( + contract_id, + pot, + transaction, + &mut drive_operations, + platform_version, + )?; + let fee = Drive::calculate_fee( + None, + Some(drive_operations), + epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + Ok((fee, state)) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_contract_fee_pot_with_fee".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/fetch_contract_fee_pot/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/fetch_contract_fee_pot/v0/mod.rs new file mode 100644 index 00000000000..43bd3854b84 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/fetch_contract_fee_pot/v0/mod.rs @@ -0,0 +1,62 @@ +use crate::drive::contract::fee_pots::types::{decode_epoch_index, ContractFeePotState}; +use crate::drive::contract::paths::{ + contract_fee_pots_path, contract_last_fee_claim_epoch_key, contract_other_path, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::DirectQueryType; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + #[inline(always)] + pub(super) fn fetch_contract_fee_pot_add_to_operations_v0( + &self, + contract_id: Identifier, + pot: ContractFeePot, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let pots_path = contract_fee_pots_path(pot); + let credits = self + .grove_get_raw_value_u64_from_encoded_var_vec( + (&pots_path).into(), + contract_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + )? + .unwrap_or_default(); + + let other_path = contract_other_path(contract_id.as_slice()); + let last_claim_epoch = self + .grove_get_raw_optional_item( + (&other_path).into(), + contract_last_fee_claim_epoch_key(pot), + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + )? + .map(|value| { + decode_epoch_index(&value).map_err(|description| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "last claim epoch of the {} fee pot of contract {} is malformed: {}", + pot, contract_id, description + ))) + }) + }) + .transpose()?; + + Ok(ContractFeePotState { + credits, + last_claim_epoch, + }) + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/insert_contract_fee_pot_trees/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/insert_contract_fee_pot_trees/mod.rs new file mode 100644 index 00000000000..71cae649f8b --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/insert_contract_fee_pot_trees/mod.rs @@ -0,0 +1,46 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Creates the two sum trees that hold every contract's fee pots, `[40, 64]` for the owner + /// pots and `[40, 192]` for the moderators pots, when they are not there yet. + /// + /// Both the genesis state structure (version 4) and the upgrade to protocol version 14 + /// call this, so a chain born at version 14 and a chain upgraded to it hold the same + /// subtrees. + /// + /// # Parameters + /// + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(())` once both trees exist. + /// * `Err(Error)` when the version is unknown or a write fails. + pub fn insert_contract_fee_pot_trees( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .contract + .fee_pots + .insert_contract_fee_pot_trees + { + 0 => self.insert_contract_fee_pot_trees_v0(transaction, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "insert_contract_fee_pot_trees".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/insert_contract_fee_pot_trees/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/insert_contract_fee_pot_trees/v0/mod.rs new file mode 100644 index 00000000000..70b13110c97 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/insert_contract_fee_pot_trees/v0/mod.rs @@ -0,0 +1,29 @@ +use crate::drive::contract::paths::contract_fee_pots_key; +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_path; +use crate::drive::Drive; +use crate::error::Error; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::version::PlatformVersion; +use grovedb::{Element, TransactionArg}; + +impl Drive { + #[inline(always)] + pub(super) fn insert_contract_fee_pot_trees_v0( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let path = prefunded_specialized_balances_path(); + for pot in [ContractFeePot::Owner, ContractFeePot::Moderators] { + self.grove_insert_if_not_exists( + (&path).into(), + contract_fee_pots_key(pot), + Element::empty_sum_tree(), + transaction, + None, + &platform_version.drive, + )?; + } + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/mod.rs new file mode 100644 index 00000000000..b74e8dc706f --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/mod.rs @@ -0,0 +1,44 @@ +//! The two fee pots a data contract's document action fees accumulate in, and the epoch each +//! pot was last claimed in (protocol version 14). +//! +//! ```text +//! [40] PreFundedSpecializedBalances (sum tree) +//! ├── [64] owner fee pots (sum tree) -> -> SumItem(credits) +//! ├── [128] voting balances +//! └── [192] moderators fee pots (sum tree) -> -> SumItem(credits) +//! +//! [64] DataContractDocuments -> -> [2] the contract's other tree +//! ├── [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 sit under a root sum tree on purpose: the credits they hold left an identity's +//! balance and have not reached another one yet, so they must stay inside the sum of all +//! credits that `calculate_total_credits_balance` checks every block. + +#[cfg(feature = "server")] +mod add_to_contract_fee_pot; +#[cfg(feature = "server")] +mod deduct_from_contract_fee_pot; +#[cfg(feature = "server")] +mod estimated_costs; +#[cfg(feature = "server")] +mod fetch_action_fee_multiplier; +#[cfg(feature = "server")] +mod fetch_contract_fee_pot; +#[cfg(feature = "server")] +mod insert_contract_fee_pot_trees; +#[cfg(feature = "server")] +mod prove_contract_fee_pots; +mod queries; +#[cfg(feature = "server")] +mod set_contract_last_fee_claim_epoch; +/// Result types shared by the fetch and verify sides. +pub mod types; + +#[cfg(test)] +#[cfg(feature = "server")] +mod tests; + +/// The stored size of a last claim epoch item: the epoch index as a u16. +pub const CONTRACT_LAST_FEE_CLAIM_EPOCH_VALUE_SIZE: u32 = 2; diff --git a/packages/rs-drive/src/drive/contract/fee_pots/prove_contract_fee_pots/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/prove_contract_fee_pots/mod.rs new file mode 100644 index 00000000000..3d171aea166 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/prove_contract_fee_pots/mod.rs @@ -0,0 +1,47 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Proves the given fee pots of a contract and the epochs they were last claimed in. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the pots belong to. + /// * `pots`: The pots to prove, at least one. + /// * `transaction`: The GroveDB transaction. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the proof. + /// * `Err(Error)` when the version is unknown, `pots` is empty, or proving fails. + pub fn prove_contract_fee_pots( + &self, + contract_id: Identifier, + pots: &[ContractFeePot], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .contract + .fee_pots + .prove_contract_fee_pots + { + 0 => self.prove_contract_fee_pots_v0(contract_id, pots, transaction, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "prove_contract_fee_pots".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/prove_contract_fee_pots/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/prove_contract_fee_pots/v0/mod.rs new file mode 100644 index 00000000000..62279510219 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/prove_contract_fee_pots/v0/mod.rs @@ -0,0 +1,35 @@ +use crate::drive::Drive; +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + #[inline(always)] + pub(super) fn prove_contract_fee_pots_v0( + &self, + contract_id: Identifier, + pots: &[ContractFeePot], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if pots.is_empty() { + return Err(Error::Query(QuerySyntaxError::InvalidParameter( + "a contract fee pots proof needs at least one pot".to_string(), + ))); + } + let path_query = Self::contract_fee_pots_query( + contract_id.to_buffer(), + pots, + &platform_version.drive.grove_version, + )?; + self.grove_get_proved_path_query( + &path_query, + transaction, + &mut vec![], + &platform_version.drive, + ) + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/queries.rs b/packages/rs-drive/src/drive/contract/fee_pots/queries.rs new file mode 100644 index 00000000000..596645550c7 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/queries.rs @@ -0,0 +1,54 @@ +use crate::drive::contract::paths::{ + contract_fee_pots_path_vec, contract_last_fee_claim_epoch_key, contract_other_path_vec, +}; +use crate::drive::Drive; +use crate::error::Error; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use grovedb::{PathQuery, Query, SizedQuery}; +use grovedb_version::version::GroveVersion; + +impl Drive { + /// The query for one fee pot of a contract: `[40, 64 | 192] -> contract id`. + pub fn contract_fee_pot_query(contract_id: [u8; 32], pot: ContractFeePot) -> PathQuery { + let mut query = Query::new(); + query.insert_key(contract_id.to_vec()); + PathQuery::new( + contract_fee_pots_path_vec(pot), + SizedQuery::new(query, None, None), + ) + } + + /// The query for the epochs the given pots of a contract were last claimed in: + /// `[64, contract id, 2] -> 32 | 96`. + pub fn contract_last_fee_claim_epochs_query( + contract_id: [u8; 32], + pots: &[ContractFeePot], + ) -> PathQuery { + let mut query = Query::new(); + for pot in pots { + query.insert_key(contract_last_fee_claim_epoch_key(*pot).to_vec()); + } + PathQuery::new( + contract_other_path_vec(&contract_id), + SizedQuery::new(query, None, None), + ) + } + + /// The query for the given pots of a contract and the epochs they were last claimed in, + /// merged into one proof. + pub fn contract_fee_pots_query( + contract_id: [u8; 32], + pots: &[ContractFeePot], + grove_version: &GroveVersion, + ) -> Result { + let mut queries: Vec = pots + .iter() + .map(|pot| Self::contract_fee_pot_query(contract_id, *pot)) + .collect(); + queries.push(Self::contract_last_fee_claim_epochs_query( + contract_id, + pots, + )); + Ok(PathQuery::merge(queries.iter().collect(), grove_version)?) + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim_epoch/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim_epoch/mod.rs new file mode 100644 index 00000000000..bf530c56fcd --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim_epoch/mod.rs @@ -0,0 +1,63 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::epoch::EpochIndex; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerInformation; +use std::collections::HashMap; + +impl Drive { + /// The operations that record the epoch one of a contract's fee pots was claimed in. A pot + /// is claimed at most once per epoch, and this item is what the next claim is judged + /// against. + /// + /// # Parameters + /// + /// * `contract_id`: The contract the pot belongs to. + /// * `pot`: The pot. + /// * `epoch_index`: The epoch of the claim. + /// * `estimated_costs_only_with_layer_info`: The estimation map, when only estimating. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok(Vec)` with the write. + /// * `Err(Error)` when the version is unknown. + pub fn set_contract_last_fee_claim_epoch_operations( + &self, + contract_id: Identifier, + pot: ContractFeePot, + epoch_index: EpochIndex, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .contract + .fee_pots + .set_contract_last_fee_claim_epoch + { + 0 => self.set_contract_last_fee_claim_epoch_operations_v0( + contract_id, + pot, + epoch_index, + estimated_costs_only_with_layer_info, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "set_contract_last_fee_claim_epoch_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim_epoch/v0/mod.rs b/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim_epoch/v0/mod.rs new file mode 100644 index 00000000000..0f5f99f4da5 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/set_contract_last_fee_claim_epoch/v0/mod.rs @@ -0,0 +1,49 @@ +use crate::drive::contract::fee_pots::types::encode_epoch_index; +use crate::drive::contract::paths::{contract_last_fee_claim_epoch_key, contract_other_path_vec}; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use dpp::block::epoch::EpochIndex; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{Element, EstimatedLayerInformation}; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + pub(super) fn set_contract_last_fee_claim_epoch_operations_v0( + &self, + contract_id: Identifier, + pot: ContractFeePot, + epoch_index: EpochIndex, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + // A claim carries no document or contract operation, so nothing else in its batch + // describes the layers above the contract. + Self::add_estimation_costs_for_levels_up_to_contract( + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + Self::add_estimation_costs_for_contract_other_tree( + contract_id.to_buffer(), + estimated_costs_only_with_layer_info, + ); + } + // The item carries no storage flags: it is never deleted, so there is no refund to + // attribute, and its two bytes are the same whoever claims. + let op = QualifiedGroveDbOp::insert_or_replace_op( + contract_other_path_vec(contract_id.as_slice()), + contract_last_fee_claim_epoch_key(pot).to_vec(), + Element::new_item(encode_epoch_index(epoch_index)), + ) + .dont_check_for_backwards_references(); + Ok(vec![GroveOperation(op)]) + } +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/tests.rs b/packages/rs-drive/src/drive/contract/fee_pots/tests.rs new file mode 100644 index 00000000000..93bda096baa --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/tests.rs @@ -0,0 +1,431 @@ +//! Drive-level tests of the contract fee pots. + +use crate::drive::contract::fee_pots::types::{ContractFeePotState, ContractFeePots}; +use crate::drive::contract::paths::contract_fee_pots_key; +use crate::drive::credit_pools::epochs::operations_factory::EpochOperations; +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_path; +use crate::drive::Drive; +use crate::util::batch::drive_op_batch::ContractFeePotOperationType; +use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; +use crate::util::batch::{DriveOperation, GroveDbOpBatch}; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use dpp::block::block_info::BlockInfo; +use dpp::block::epoch::Epoch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::data_contract::DataContract; +use dpp::fee::fee_result::FeeResult; +use dpp::identifier::Identifier; +use dpp::tests::fixtures::get_data_contract_fixture; +use dpp::version::PlatformVersion; + +const BOTH: [ContractFeePot; 2] = [ContractFeePot::Owner, ContractFeePot::Moderators]; + +/// A drive holding one contract, whose other tree the last claim epochs live in. +fn drive_with_contract() -> (Drive, DataContract) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = + get_data_contract_fixture(None, 0, platform_version.protocol_version).data_contract_owned(); + drive + .insert_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert the contract"); + (drive, contract) +} + +fn apply(drive: &Drive, operations: Vec, apply: bool) -> FeeResult { + drive + .apply_drive_operations( + operations + .into_iter() + .map(DriveOperation::ContractFeePotOperation) + .collect(), + apply, + &BlockInfo::default(), + None, + PlatformVersion::latest(), + None, + ) + .expect("expected to apply the fee pot operations") +} + +fn add(drive: &Drive, contract_id: Identifier, pot: ContractFeePot, amount: u64) { + apply( + drive, + vec![ContractFeePotOperationType::AddToPot { + contract_id, + pot, + amount, + }], + true, + ); +} + +fn fetch(drive: &Drive, contract_id: Identifier, pot: ContractFeePot) -> ContractFeePotState { + drive + .fetch_contract_fee_pot(contract_id, pot, None, PlatformVersion::latest()) + .expect("expected to fetch the pot") +} + +/// Proves and verifies `pots` and checks the result against a fetch of each. +fn assert_proved(drive: &Drive, contract_id: Identifier, pots: &[ContractFeePot]) { + let platform_version = PlatformVersion::latest(); + let proof = drive + .prove_contract_fee_pots(contract_id, pots, None, platform_version) + .expect("expected to prove the pots"); + let (root_hash, proved) = + Drive::verify_contract_fee_pots(&proof, contract_id, pots, false, platform_version) + .expect("expected to verify the pots"); + assert_eq!( + root_hash, + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected a root hash") + ); + for pot in pots { + assert_eq!(proved.pot(*pot), &fetch(drive, contract_id, *pot)); + } +} + +#[test] +fn should_hold_nothing_and_no_claim_before_the_first_fee() { + let (drive, contract) = drive_with_contract(); + for pot in BOTH { + assert_eq!( + fetch(&drive, contract.id(), pot), + ContractFeePotState::default() + ); + } + assert_proved(&drive, contract.id(), &BOTH); +} + +#[test] +fn should_accumulate_each_pot_of_each_contract_on_its_own() { + let (drive, contract) = drive_with_contract(); + let other_contract_id = Identifier::from([7; 32]); + + add(&drive, contract.id(), ContractFeePot::Owner, 10); + add(&drive, contract.id(), ContractFeePot::Owner, 5); + add(&drive, contract.id(), ContractFeePot::Moderators, 100); + add(&drive, other_contract_id, ContractFeePot::Moderators, 3); + + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Owner).credits, + 15 + ); + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Moderators).credits, + 100 + ); + assert_eq!( + fetch(&drive, other_contract_id, ContractFeePot::Owner).credits, + 0 + ); + assert_eq!( + fetch(&drive, other_contract_id, ContractFeePot::Moderators).credits, + 3 + ); +} + +#[test] +fn should_deduct_from_a_pot_and_refuse_more_than_it_holds() { + let (drive, contract) = drive_with_contract(); + add(&drive, contract.id(), ContractFeePot::Moderators, 100); + + apply( + &drive, + vec![ContractFeePotOperationType::DeductFromPot { + contract_id: contract.id(), + pot: ContractFeePot::Moderators, + amount: 99, + }], + true, + ); + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Moderators).credits, + 1 + ); + + let result = drive.apply_drive_operations( + vec![DriveOperation::ContractFeePotOperation( + ContractFeePotOperationType::DeductFromPot { + contract_id: contract.id(), + pot: ContractFeePot::Moderators, + amount: 2, + }, + )], + true, + &BlockInfo::default(), + None, + PlatformVersion::latest(), + None, + ); + assert!(result.is_err(), "a pot can not go below zero"); + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Moderators).credits, + 1 + ); +} + +#[test] +fn should_record_the_last_claim_epoch_of_each_pot_on_its_own() { + let (drive, contract) = drive_with_contract(); + let set = |pot, epoch_index| { + apply( + &drive, + vec![ContractFeePotOperationType::SetLastClaimEpoch { + contract_id: contract.id(), + pot, + epoch_index, + }], + true, + ); + }; + + set(ContractFeePot::Moderators, 7); + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Moderators).last_claim_epoch, + Some(7) + ); + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Owner).last_claim_epoch, + None + ); + + // A later claim replaces the epoch; epoch 0 is an epoch like any other. + set(ContractFeePot::Moderators, 300); + set(ContractFeePot::Owner, 0); + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Moderators).last_claim_epoch, + Some(300) + ); + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Owner).last_claim_epoch, + Some(0) + ); +} + +#[test] +fn should_prove_the_pots_asked_for_and_nothing_about_the_other() { + let (drive, contract) = drive_with_contract(); + add(&drive, contract.id(), ContractFeePot::Owner, 10); + add(&drive, contract.id(), ContractFeePot::Moderators, 100); + apply( + &drive, + vec![ContractFeePotOperationType::SetLastClaimEpoch { + contract_id: contract.id(), + pot: ContractFeePot::Moderators, + epoch_index: 4, + }], + true, + ); + + assert_proved(&drive, contract.id(), &BOTH); + assert_proved(&drive, contract.id(), &[ContractFeePot::Owner]); + assert_proved(&drive, contract.id(), &[ContractFeePot::Moderators]); + + // A proof of one pot leaves the other at its default rather than reading it. + let platform_version = PlatformVersion::latest(); + let proof = drive + .prove_contract_fee_pots( + contract.id(), + &[ContractFeePot::Owner], + None, + platform_version, + ) + .expect("expected to prove the owner pot"); + let (_, proved) = Drive::verify_contract_fee_pots( + &proof, + contract.id(), + &[ContractFeePot::Owner], + false, + platform_version, + ) + .expect("expected to verify the owner pot"); + assert_eq!( + proved, + ContractFeePots { + owner: ContractFeePotState { + credits: 10, + last_claim_epoch: None, + }, + moderators: ContractFeePotState::default(), + } + ); + + // A proof of one pot does not verify as a proof of the other. + assert!(Drive::verify_contract_fee_pots( + &proof, + contract.id(), + &[ContractFeePot::Moderators], + false, + platform_version, + ) + .is_err()); +} + +#[test] +fn should_count_the_pots_in_the_total_credits_of_the_platform() { + let (drive, contract) = drive_with_contract(); + let platform_version = PlatformVersion::latest(); + let balanced = |drive: &Drive| { + drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("expected to sum the credits") + .ok() + .expect("expected the sum to be judged") + }; + assert!(balanced(&drive)); + + // Credits that appear in a pot out of nowhere unbalance the platform: the pots are inside + // the sum every block is checked against. + add(&drive, contract.id(), ContractFeePot::Owner, 40); + add(&drive, contract.id(), ContractFeePot::Moderators, 60); + assert!(!balanced(&drive)); + + // Once the platform accounts for them, it balances again. + drive + .add_to_system_credits(100, None, platform_version) + .expect("expected to add to the system credits"); + assert!(balanced(&drive)); +} + +#[test] +fn should_estimate_a_pot_write_without_writing() { + let (drive, contract) = drive_with_contract(); + let estimated = apply( + &drive, + vec![ + ContractFeePotOperationType::AddToPot { + contract_id: contract.id(), + pot: ContractFeePot::Moderators, + amount: 100, + }, + ContractFeePotOperationType::SetLastClaimEpoch { + contract_id: contract.id(), + pot: ContractFeePot::Moderators, + epoch_index: 1, + }, + ], + false, + ); + assert!(estimated.processing_fee > 0); + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Moderators), + ContractFeePotState::default() + ); + + // The estimate covers what the write then costs. + let actual = apply( + &drive, + vec![ + ContractFeePotOperationType::AddToPot { + contract_id: contract.id(), + pot: ContractFeePot::Moderators, + amount: 100, + }, + ContractFeePotOperationType::SetLastClaimEpoch { + contract_id: contract.id(), + pot: ContractFeePot::Moderators, + epoch_index: 1, + }, + ], + true, + ); + assert!( + estimated.total_base_fee() >= actual.total_base_fee(), + "estimated {} < actual {}", + estimated.total_base_fee(), + actual.total_base_fee() + ); +} + +#[test] +fn should_read_the_epoch_fee_multiplier_and_fall_back_in_the_first_block_of_an_epoch() { + let (drive, _) = drive_with_contract(); + let platform_version = PlatformVersion::latest(); + let epoch = Epoch::new(5).expect("expected an epoch"); + let schedule_multiplier = platform_version + .fee_version + .uses_version_fee_multiplier_permille + .expect("expected the fee schedule to set a multiplier"); + + // The epoch's tree is there but the epoch was not initialized yet: state transitions of + // the first block of an epoch execute before the block end initializes it. + let (fee, multiplier) = drive + .fetch_action_fee_multiplier_with_fee(&epoch, None, platform_version) + .expect("expected to read the multiplier"); + assert_eq!(multiplier, schedule_multiplier); + assert!(fee.processing_fee > 0, "the read is billed"); + + let mut batch = GroveDbOpBatch::new(); + batch.push(epoch.update_fee_multiplier_operation(1_500)); + drive + .grove_apply_batch(batch, false, None, &platform_version.drive) + .expect("expected to set the epoch's multiplier"); + + let (_, multiplier) = drive + .fetch_action_fee_multiplier_with_fee(&epoch, None, platform_version) + .expect("expected to read the multiplier"); + assert_eq!(multiplier, 1_500); +} + +#[test] +fn should_create_the_pots_tree_of_a_chain_that_reached_version_14_without_it() { + // A chain that upgraded to protocol version 14, or was born at it, on a build from before + // the fee pots has neither tree: the upgrade step that creates them already ran. + let (drive, contract) = drive_with_contract(); + let platform_version = PlatformVersion::latest(); + let prefunded_path = prefunded_specialized_balances_path(); + for pot in BOTH { + drive + .grove + .delete( + &prefunded_path, + contract_fee_pots_key(pot), + None, + None, + &platform_version.drive.grove_version, + ) + .unwrap() + .expect("expected to delete the empty pots tree"); + } + + // Reading a pot of a chain without the trees finds nothing, as on any other chain. + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Moderators), + ContractFeePotState::default() + ); + + // The first fee of each pot creates its tree, and later ones find it there. + add(&drive, contract.id(), ContractFeePot::Moderators, 100); + add(&drive, contract.id(), ContractFeePot::Moderators, 11); + add(&drive, contract.id(), ContractFeePot::Owner, 5); + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Moderators).credits, + 111 + ); + assert_eq!( + fetch(&drive, contract.id(), ContractFeePot::Owner).credits, + 5 + ); + assert_proved(&drive, contract.id(), &BOTH); + + // The credits are inside the sum the platform checks, as on a chain born with the trees. + drive + .add_to_system_credits(116, None, platform_version) + .expect("expected to add to the system credits"); + assert!(drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("expected to sum the credits") + .ok() + .expect("expected the sum to be judged")); +} diff --git a/packages/rs-drive/src/drive/contract/fee_pots/types.rs b/packages/rs-drive/src/drive/contract/fee_pots/types.rs new file mode 100644 index 00000000000..11589ee15dc --- /dev/null +++ b/packages/rs-drive/src/drive/contract/fee_pots/types.rs @@ -0,0 +1,52 @@ +use dpp::balances::credits::Credits; +use dpp::block::epoch::EpochIndex; +use dpp::data_contract::document_type::action_fees::ContractFeePot; + +/// What Drive holds about one of a contract's fee pots. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ContractFeePotState { + /// The credits in the pot + pub credits: Credits, + /// The epoch the pot was last claimed in, `None` when it never was + pub last_claim_epoch: Option, +} + +/// What Drive holds about both fee pots of a contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ContractFeePots { + /// The pot the contract owner claims + pub owner: ContractFeePotState, + /// The pot the contract's moderation team shares + pub moderators: ContractFeePotState, +} + +impl ContractFeePots { + /// The state of `pot` + pub fn pot(&self, pot: ContractFeePot) -> &ContractFeePotState { + match pot { + ContractFeePot::Owner => &self.owner, + ContractFeePot::Moderators => &self.moderators, + } + } + + /// The state of `pot`, mutably + pub fn pot_mut(&mut self, pot: ContractFeePot) -> &mut ContractFeePotState { + match pot { + ContractFeePot::Owner => &mut self.owner, + ContractFeePot::Moderators => &mut self.moderators, + } + } +} + +/// The stored form of a last claim epoch: the epoch index, two bytes big endian. +pub fn encode_epoch_index(epoch_index: EpochIndex) -> Vec { + epoch_index.to_be_bytes().to_vec() +} + +/// Reads a stored last claim epoch. +pub fn decode_epoch_index(value: &[u8]) -> Result { + let bytes: [u8; 2] = value + .try_into() + .map_err(|_| format!("expected 2 bytes, got {}", value.len()))?; + Ok(EpochIndex::from_be_bytes(bytes)) +} diff --git a/packages/rs-drive/src/drive/contract/mod.rs b/packages/rs-drive/src/drive/contract/mod.rs index 35363d58cd8..0fcf9567739 100644 --- a/packages/rs-drive/src/drive/contract/mod.rs +++ b/packages/rs-drive/src/drive/contract/mod.rs @@ -9,6 +9,9 @@ mod apply; mod contract_fetch_info; #[cfg(feature = "server")] mod estimation_costs; +/// The fee pots a contract's document action fees accumulate in +#[cfg(any(feature = "server", feature = "verify"))] +pub mod fee_pots; #[cfg(feature = "server")] pub(crate) mod get_fetch; #[cfg(feature = "server")] diff --git a/packages/rs-drive/src/drive/contract/paths.rs b/packages/rs-drive/src/drive/contract/paths.rs index 9cbc4398912..eb233a1d221 100644 --- a/packages/rs-drive/src/drive/contract/paths.rs +++ b/packages/rs-drive/src/drive/contract/paths.rs @@ -1,6 +1,7 @@ use crate::drive::RootTree; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::config::moderation::ContractModerationList; +use dpp::data_contract::document_type::action_fees::ContractFeePot; use crate::drive::votes::paths::{ACTIVE_POLLS_TREE_KEY, CONTESTED_RESOURCE_TREE_KEY}; use dpp::data_contract::DataContract; @@ -247,3 +248,58 @@ pub fn contract_moderation_list_path_vec( contract_moderation_list_key(list).to_vec(), ] } + +/// The key under the prefunded specialized balances tree (`[40]`) of the sum tree holding every +/// contract's owner fee pot (protocol version 14): `contract id -> SumItem(credits)`. The +/// `owner` parts of the contract's document action fees accumulate there until the owner +/// claims them. With the voting balances at `128` on top, `64` and `192` keep the tree +/// balanced. +pub const PREFUNDED_BALANCES_FOR_CONTRACT_OWNER_FEES: u8 = 64; + +/// The key under the prefunded specialized balances tree (`[40]`) of the sum tree holding every +/// contract's moderators fee pot (protocol version 14): `contract id -> SumItem(credits)`. The +/// `moderators` parts of the contract's document action fees accumulate there until a member +/// of the moderation team claims them for the team. +pub const PREFUNDED_BALANCES_FOR_CONTRACT_MODERATOR_FEES: u8 = 192; + +/// The key under a contract's other tree (`[64, id, 2]`) of the epoch its owner fee pot was last +/// claimed in, a two-byte big-endian item (protocol version 14). Absent until the first claim. +/// Below `128`, so the banlist stays on top of the other tree. +pub const CONTRACT_LAST_OWNER_FEE_CLAIM_EPOCH_KEY: u8 = 32; + +/// The key under a contract's other tree (`[64, id, 2]`) of the epoch its moderators fee pot was +/// last claimed in, a two-byte big-endian item (protocol version 14). Absent until the first +/// claim. Below `128`, so the banlist stays on top of the other tree. +pub const CONTRACT_LAST_MODERATORS_FEE_CLAIM_EPOCH_KEY: u8 = 96; + +/// The key, under the prefunded specialized balances tree, of the sum tree of a kind of pot. +pub fn contract_fee_pots_key(pot: ContractFeePot) -> &'static [u8; 1] { + match pot { + ContractFeePot::Owner => &[PREFUNDED_BALANCES_FOR_CONTRACT_OWNER_FEES], + ContractFeePot::Moderators => &[PREFUNDED_BALANCES_FOR_CONTRACT_MODERATOR_FEES], + } +} + +/// `[40, 64]` or `[40, 192]`: the sum tree holding every contract's pot of one kind. +pub fn contract_fee_pots_path(pot: ContractFeePot) -> [&'static [u8]; 2] { + [ + Into::<&[u8; 1]>::into(RootTree::PreFundedSpecializedBalances), + contract_fee_pots_key(pot), + ] +} + +/// `[40, 64]` or `[40, 192]`: the sum tree holding every contract's pot of one kind. +pub fn contract_fee_pots_path_vec(pot: ContractFeePot) -> Vec> { + vec![ + Into::<&[u8; 1]>::into(RootTree::PreFundedSpecializedBalances).to_vec(), + contract_fee_pots_key(pot).to_vec(), + ] +} + +/// The key, under a contract's other tree, of the epoch a pot was last claimed in. +pub fn contract_last_fee_claim_epoch_key(pot: ContractFeePot) -> &'static [u8; 1] { + match pot { + ContractFeePot::Owner => &[CONTRACT_LAST_OWNER_FEE_CLAIM_EPOCH_KEY], + ContractFeePot::Moderators => &[CONTRACT_LAST_MODERATORS_FEE_CLAIM_EPOCH_KEY], + } +} diff --git a/packages/rs-drive/src/drive/contract/structure.rs b/packages/rs-drive/src/drive/contract/structure.rs index 2dfea307414..dfb3ebab819 100644 --- a/packages/rs-drive/src/drive/contract/structure.rs +++ b/packages/rs-drive/src/drive/contract/structure.rs @@ -1,5 +1,7 @@ use crate::drive::contract::paths::{ - CONTRACT_BANLIST_KEY, CONTRACT_OTHER_KEY, CONTRACT_SUSPENSIONS_KEY, CONTRACT_VERSION_KEY, + CONTRACT_BANLIST_KEY, CONTRACT_LAST_MODERATORS_FEE_CLAIM_EPOCH_KEY, + CONTRACT_LAST_OWNER_FEE_CLAIM_EPOCH_KEY, CONTRACT_OTHER_KEY, CONTRACT_SUSPENSIONS_KEY, + CONTRACT_VERSION_KEY, }; use crate::drive::document::structure::document_type; use crate::drive::RootTree; @@ -103,6 +105,34 @@ pub(crate) fn structure() -> StructureNode { deserializing the contract. Rewritten on every \ update.", ), + StructureNode::fixed( + "last_owner_fee_claim_epoch", + &[CONTRACT_LAST_OWNER_FEE_CLAIM_EPOCH_KEY], + "LastOwnerFeeClaimEpoch", + "CONTRACT_LAST_OWNER_FEE_CLAIM_EPOCH_KEY", + ) + .kind(ElementKind::Item) + .lazy() + .value("epoch index, u16 big endian") + .describe( + "The epoch the contract's owner fee pot was last \ + claimed in. Written by the first claim; a pot is \ + claimed at most once per epoch.", + ), + StructureNode::fixed( + "last_moderators_fee_claim_epoch", + &[CONTRACT_LAST_MODERATORS_FEE_CLAIM_EPOCH_KEY], + "LastModeratorsFeeClaimEpoch", + "CONTRACT_LAST_MODERATORS_FEE_CLAIM_EPOCH_KEY", + ) + .kind(ElementKind::Item) + .lazy() + .value("epoch index, u16 big endian") + .describe( + "The epoch the contract's moderators fee pot was \ + last claimed in. Written by the first claim; a \ + pot is claimed at most once per epoch.", + ), StructureNode::fixed( "banlist", &[CONTRACT_BANLIST_KEY], diff --git a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs index 7533f3be245..194f4b52960 100644 --- a/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs +++ b/packages/rs-drive/src/drive/document/index_uniqueness/mod.rs @@ -128,6 +128,7 @@ mod tests { token_cost: None, gas_fees_paid_by: GasFeesPaidBy::default(), contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, }); let data = BTreeMap::from([( diff --git a/packages/rs-drive/src/drive/initialization/v4/mod.rs b/packages/rs-drive/src/drive/initialization/v4/mod.rs index fac8f829121..c89df4de467 100644 --- a/packages/rs-drive/src/drive/initialization/v4/mod.rs +++ b/packages/rs-drive/src/drive/initialization/v4/mod.rs @@ -96,6 +96,14 @@ impl Drive { // (`Platform::transition_to_version_14`) calls the same helper. self.insert_once_per_identity_distributions_root_tree(transaction, platform_version)?; + // Contract fee pot trees (protocol version 14): the two sum trees, beside the voting + // balances, that hold what every contract's document action fees have collected. They + // go in after the batch apply, which creates the voting balances tree, and one after + // the other, exactly as the upgrade path (`Platform::transition_to_version_14`) does + // through the same helper: the prefunded balances Merk is then built by the same + // sequence of inserts on both node populations. + self.insert_contract_fee_pot_trees(transaction, platform_version)?; + Ok(()) } } diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/mod.rs index 31eeebb6753..99404d3360f 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -38,9 +39,15 @@ impl Drive { ); Ok(()) } + 1 => { + Self::add_estimation_costs_for_prefunded_specialized_balance_update_v1( + estimated_costs_only_with_layer_info, + ); + Ok(()) + } version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_estimation_costs_for_prefunded_specialized_balance_update".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v1/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v1/mod.rs new file mode 100644 index 00000000000..0bc8579d093 --- /dev/null +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/estimation_costs/for_prefunded_specialized_balance_update/v1/mod.rs @@ -0,0 +1,75 @@ +use crate::drive::Drive; + +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::{EstimatedLevel, PotentiallyAtMaxElements}; +use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; +use grovedb::{EstimatedLayerInformation, TreeType}; + +use crate::drive::constants::AVERAGE_BALANCE_SIZE; +use crate::drive::prefunded_specialized_balances::{ + prefunded_specialized_balances_for_voting_path_vec, prefunded_specialized_balances_path, +}; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; +use grovedb::EstimatedSumTrees::{AllSumTrees, SomeSumTrees}; +use std::collections::HashMap; + +impl Drive { + /// Adds estimation costs for a prefunded specialized balance update (protocol version 14: + /// like v0, with the prefunded balances layer described as the three trees it now holds). + /// + /// This method operates on the provided HashMap, `estimated_costs_only_with_layer_info`, and adds + /// new entries to it, representing the estimated costs for the total system credits update. + #[inline(always)] + pub(super) fn add_estimation_costs_for_prefunded_specialized_balance_update_v1( + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + // todo: this will be inserted at the same time as other estimated costs for documents, + // hence we add the full information, but it would be much better that estimated costs would + // be merged instead of overwritten + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path([]), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + // We are on the 3rd level + estimated_layer_count: EstimatedLevel(3, false), + estimated_layer_sizes: AllSubtrees( + 1, + SomeSumTrees { + sum_trees_weight: 1, + big_sum_trees_weight: 0, + count_trees_weight: 0, + count_sum_trees_weight: 0, + non_sum_trees_weight: 1, + provable_sum_trees_weight: 0, + provable_count_trees_weight: 0, + provable_count_sum_trees_weight: 0, + provable_count_provable_sum_trees_weight: 0, + }, + None, + ), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path(prefunded_specialized_balances_path()), + EstimatedLayerInformation { + tree_type: TreeType::SumTree, + // From protocol version 14 the voting balances have two siblings, the contract + // fee pot trees, and sit on top of them: v0 described the layer as holding the + // voting balances alone. The contract fee pot estimation describes it the same + // way, so a batch that writes to both gets one answer whichever ran last. + estimated_layer_count: EstimatedLevel(1, false), + estimated_layer_sizes: AllSubtrees(1, AllSumTrees, None), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(prefunded_specialized_balances_for_voting_path_vec()), + EstimatedLayerInformation { + tree_type: TreeType::SumTree, + estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_sizes: AllItems(DEFAULT_HASH_SIZE_U8, AVERAGE_BALANCE_SIZE, None), + }, + ); + } +} diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs index 1fe7d3fff3b..393d63e7138 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs @@ -34,6 +34,11 @@ pub fn prefunded_specialized_balances_path() -> [&'static [u8]; 1] { )] } +/// prefunded specialized balances vector +pub fn prefunded_specialized_balances_path_vec() -> Vec> { + vec![Into::<&[u8; 1]>::into(RootTree::PreFundedSpecializedBalances).to_vec()] +} + /// prefunded specialized balances for voting pub fn prefunded_specialized_balances_for_voting_path() -> [&'static [u8]; 2] { [ diff --git a/packages/rs-drive/src/drive/prefunded_specialized_balances/structure.rs b/packages/rs-drive/src/drive/prefunded_specialized_balances/structure.rs index df1608e3b55..6c0ac23e716 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/structure.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/structure.rs @@ -1,3 +1,6 @@ +use crate::drive::contract::paths::{ + PREFUNDED_BALANCES_FOR_CONTRACT_MODERATOR_FEES, PREFUNDED_BALANCES_FOR_CONTRACT_OWNER_FEES, +}; use crate::drive::prefunded_specialized_balances::PREFUNDED_BALANCES_FOR_VOTING; use crate::drive::RootTree; use crate::structure::{ElementKind, StructureNode}; @@ -41,4 +44,48 @@ pub(crate) fn structure() -> StructureNode { .describe("What is left to pay for votes on this poll."), ), ) + .child(contract_fee_pots( + "owner_fee_pots", + PREFUNDED_BALANCES_FOR_CONTRACT_OWNER_FEES, + "ContractOwnerFees", + "PREFUNDED_BALANCES_FOR_CONTRACT_OWNER_FEES", + "The owner parts of every contract's document action fees, \ + until the contract owner claims them.", + "What the contract's owner can claim, at most once per epoch.", + )) + .child(contract_fee_pots( + "moderators_fee_pots", + PREFUNDED_BALANCES_FOR_CONTRACT_MODERATOR_FEES, + "ContractModeratorFees", + "PREFUNDED_BALANCES_FOR_CONTRACT_MODERATOR_FEES", + "The moderators parts of every contract's document action \ + fees, until a member of the moderation team claims them \ + for the team.", + "What the contract's moderation team shares equally, at \ + most once per epoch.", + )) +} + +/// One of the two sum trees of contract fee pots: they differ in key and wording only. +fn contract_fee_pots( + id: &'static str, + key: u8, + name: &'static str, + constant: &'static str, + description: &'static str, + pot_description: &'static str, +) -> StructureNode { + StructureNode::fixed(id, &[key], name, constant) + .kind(ElementKind::SumTree) + .since(14) + .source("packages/rs-drive/src/drive/contract/paths.rs") + .book("fees/overview.md") + .describe(description) + .child( + StructureNode::identifier("pot", "contract_id", "The data contract id") + .kind(ElementKind::SumItem) + .lazy() + .value("credits") + .describe(pot_description), + ) } diff --git a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs index 83b9501a136..849688101fc 100644 --- a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs +++ b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs @@ -24,6 +24,7 @@ use dpp::state_transition::batch_transition::batched_transition::token_transitio use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; +use dpp::state_transition::contract_fee_claim_transition::accessors::ContractFeeClaimTransitionAccessorsV0; use dpp::state_transition::contract_user_moderation_transition::accessors::ContractUserModerationTransitionAccessorsV0; use dpp::state_transition::contract_user_moderation_transition::ContractUserModerationAction; use dpp::state_transition::data_contract_create_transition::accessors::DataContractCreateTransitionAccessorsV0; @@ -289,6 +290,39 @@ impl Drive { &platform_version.drive.grove_version, )? } + // The pot the claim paid out with the epoch it was last claimed in, and the balance + // of every identity a payout of that pot goes to. + StateTransition::ContractFeeClaim(st) => { + let contract_id = st.data_contract_id(); + let Some(contract_fetch_info) = self.get_contract_with_fetch_info( + contract_id.to_buffer(), + false, + None, + platform_version, + )? + else { + return Err(Error::Proof(ProofError::UnknownContract(format!( + "unknown contract with id {} in contract fee claim proving", + contract_id + )))); + }; + let recipients: Vec<[u8; 32]> = st + .pot() + .recipients(&contract_fetch_info.contract) + .into_iter() + .map(|recipient| recipient.to_buffer()) + .collect(); + let pot_query = Drive::contract_fee_pots_query( + contract_id.to_buffer(), + &[st.pot()], + &platform_version.drive.grove_version, + )?; + let balances_query = Drive::balances_for_identity_ids_query(&recipients); + PathQuery::merge( + vec![&pot_query, &balances_query], + &platform_version.drive.grove_version, + )? + } // Only the rewritten key: the verifier compares that one key. StateTransition::IdentityKeyLimitsUpdate(st) => { IdentityKeysRequest::new_specific_key_query_without_limit( diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/contract_fee_claim_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/contract_fee_claim_transition.rs new file mode 100644 index 00000000000..3e9035b0345 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/contract_fee_claim_transition.rs @@ -0,0 +1,81 @@ +use crate::error::drive::DriveError; +use crate::error::fee::FeeError; +use crate::error::Error; +use crate::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; +use crate::state_transition_action::contract::contract_fee_claim::v0::ContractFeeClaimTransitionActionV0; +use crate::state_transition_action::contract::contract_fee_claim::ContractFeeClaimTransitionAction; +use crate::util::batch::DriveOperation::{ContractFeePotOperation, IdentityOperation}; +use crate::util::batch::{ContractFeePotOperationType, DriveOperation, IdentityOperationType}; +use dpp::block::epoch::Epoch; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; + +impl DriveHighLevelOperationConverter for ContractFeeClaimTransitionAction { + fn into_high_level_drive_operations<'a>( + self, + _epoch: &Epoch, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .state_transitions + .convert_to_high_level_operations + .contract_fee_claim_transition + { + 0 => { + let ContractFeeClaimTransitionAction::V0(ContractFeeClaimTransitionActionV0 { + claimant_id, + data_contract_id: contract_id, + identity_contract_nonce, + pot, + epoch_index, + payouts, + .. + }) = self; + + // What leaves the pot is what reaches the balances: the credits only move. + let paid_out = payouts.values().try_fold(0 as Credits, |total, amount| { + total + .checked_add(*amount) + .ok_or(Error::Fee(FeeError::Overflow( + "the payouts of a contract fee claim overflow credits", + ))) + })?; + + let mut operations = vec![ + IdentityOperation(IdentityOperationType::UpdateIdentityContractNonce { + identity_id: claimant_id.to_buffer(), + contract_id: contract_id.to_buffer(), + nonce: identity_contract_nonce, + }), + ContractFeePotOperation(ContractFeePotOperationType::DeductFromPot { + contract_id, + pot, + amount: paid_out, + }), + ]; + operations.extend(payouts.into_iter().map(|(identity_id, added_balance)| { + IdentityOperation(IdentityOperationType::AddToIdentityBalance { + identity_id: identity_id.to_buffer(), + added_balance, + }) + })); + operations.push(ContractFeePotOperation( + ContractFeePotOperationType::SetLastClaimEpoch { + contract_id, + pot, + epoch_index, + }, + )); + Ok(operations) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "ContractFeeClaimTransitionAction::into_high_level_drive_operations" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/mod.rs index 5d1d13cac99..577fc6523e8 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/contract/mod.rs @@ -1,3 +1,4 @@ +mod contract_fee_claim_transition; mod contract_user_moderation_transition; mod data_contract_create_transition; mod data_contract_update_transition; diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs index d2a57e87b20..fadc4280f53 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/mod.rs @@ -61,6 +61,10 @@ impl DriveHighLevelOperationConverter for StateTransitionAction { contract_user_moderation_transition, ) => contract_user_moderation_transition .into_high_level_drive_operations(epoch, platform_version), + StateTransitionAction::ContractFeeClaimAction(contract_fee_claim_transition) => { + contract_fee_claim_transition + .into_high_level_drive_operations(epoch, platform_version) + } StateTransitionAction::IdentityKeyLimitsUpdateAction( identity_key_limits_update_transition, ) => identity_key_limits_update_transition 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 c7fe89697a2..28f4e2eef2d 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 @@ -4,6 +4,7 @@ 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; @@ -96,4 +97,10 @@ impl DocumentBaseTransitionActionAccessorsV0 for DocumentBaseTransitionAction { DocumentBaseTransitionAction::V0(v0) => v0.contract_gas_fees_paid_by, } } + + fn declared_action_fee(&self) -> Option<(ActionFeePricing, DocumentActionFee)> { + match self { + DocumentBaseTransitionAction::V0(v0) => v0.declared_action_fee.as_deref().copied(), + } + } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/transformer.rs index 84fb5fcf744..079b584c1a2 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/transformer.rs @@ -1,5 +1,6 @@ use dpp::platform_value::Identifier; use std::sync::Arc; +use dpp::data_contract::document_type::action_fees::{DocumentActionFee, DocumentActionFees}; use dpp::data_contract::document_type::DocumentType; use dpp::prelude::ConsensusValidationResult; use dpp::ProtocolError; @@ -15,6 +16,7 @@ impl DocumentBaseTransitionAction { value: &DocumentBaseTransition, get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, get_token_cost: impl Fn(&DocumentType) -> Option, + get_action_fee: impl Fn(&DocumentActionFees) -> Option, action: &str, ) -> Result, Error> { Ok( @@ -22,6 +24,7 @@ impl DocumentBaseTransitionAction { value, get_data_contract, get_token_cost, + get_action_fee, action, )? .map(|v0| v0.into()), 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 22fe752e84f..e13583a5940 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 @@ -3,6 +3,7 @@ pub mod transformer; use crate::drive::contract::DataContractFetchInfo; use dpp::balances::credits::TokenAmount; +use dpp::data_contract::document_type::action_fees::{ActionFeePricing, DocumentActionFee}; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::identifier::Identifier; use dpp::prelude::IdentityNonce; @@ -29,6 +30,10 @@ pub struct DocumentBaseTransitionActionV0 { /// Who the document type's token cost offers to pay the gas: `DocumentOwner` when the action /// has no token cost, since only a token payment can be sponsored pub contract_gas_fees_paid_by: GasFeesPaidBy, + /// The fee the document type declares for this action and how it is priced, `None` when + /// it declares none (protocol version 14). Boxed: most actions declare none, and the + /// action sits in the largest variant of the batched transition enum. + pub declared_action_fee: Option>, } /// document base transition action accessors v0 @@ -65,4 +70,6 @@ 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)>; } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/v0/transformer.rs index 9f243d7d86d..8b6680fb7ad 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_base_transition_action/v0/transformer.rs @@ -1,4 +1,6 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use dpp::data_contract::document_type::action_fees::{DocumentActionFee, DocumentActionFees}; use dpp::data_contract::document_type::DocumentType; use dpp::platform_value::Identifier; use std::sync::Arc; @@ -25,6 +27,7 @@ impl DocumentBaseTransitionActionV0 { value: &DocumentBaseTransition, get_data_contract: impl Fn(Identifier) -> Result, ProtocolError>, get_token_cost: impl Fn(&DocumentType) -> Option, + get_action_fee: impl Fn(&DocumentActionFees) -> Option, action: &str, ) -> Result, Error> { let data_contract_id = value.data_contract_id(); @@ -115,6 +118,12 @@ impl DocumentBaseTransitionActionV0 { let contract_gas_fees_paid_by = document_action_token_cost .map(|cost| cost.gas_fees_paid_by) .unwrap_or(GasFeesPaidBy::DocumentOwner); + // The fee the document type declares for this action, with how it is priced. Only a v3 + // meta-schema contract (protocol version 14) carries one. What is charged is settled + // where state is read, since the default pricing follows the epoch's fee multiplier. + let declared_action_fee = document_type + .action_fees() + .and_then(|fees| get_action_fee(fees).map(|fee| Box::new((fees.pricing(), fee)))); Ok(DocumentBaseTransitionActionV0 { id: value.id(), identity_contract_nonce: value.identity_contract_nonce(), @@ -123,6 +132,7 @@ impl DocumentBaseTransitionActionV0 { token_cost, gas_fees_paid_by, contract_gas_fees_paid_by, + declared_action_fee, } .into()) } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs index 7fdbaba02e9..40d3f5efa43 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs @@ -51,6 +51,7 @@ impl DocumentCreateTransitionActionV0 { base, get_data_contract, |document_type| document_type.document_creation_token_cost(), + |action_fees| action_fees.document_creation_action_fee(), "create", )?; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_delete_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_delete_transition_action/v0/transformer.rs index ec815461626..8902ab33c45 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_delete_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_delete_transition_action/v0/transformer.rs @@ -34,6 +34,7 @@ impl DocumentDeleteTransitionActionV0 { base, get_data_contract, |document_type| document_type.document_deletion_token_cost(), + |action_fees| action_fees.document_deletion_action_fee(), "delete", )?; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/v0/transformer.rs index 040124df134..f6c84e89f12 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_index_only_delete_transition_action/v0/transformer.rs @@ -35,6 +35,7 @@ impl DocumentIndexOnlyDeleteTransitionActionV0 { base, get_data_contract, |document_type| document_type.document_deletion_token_cost(), + |action_fees| action_fees.document_deletion_action_fee(), "indexOnlyDelete", )?; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/transformer.rs index c0a706aa30c..187ab56eca1 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_purchase_transition_action/v0/transformer.rs @@ -39,6 +39,7 @@ impl DocumentPurchaseTransitionActionV0 { base, get_data_contract, |document_type| document_type.document_purchase_token_cost(), + |action_fees| action_fees.document_purchase_action_fee(), "purchase", )?; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs index 61f46c7b562..44e8bc27f32 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs @@ -44,6 +44,7 @@ impl DocumentReplaceTransitionActionV0 { base, get_data_contract, |document_type| document_type.document_replacement_token_cost(), + |action_fees| action_fees.document_replacement_action_fee(), "replace", )?; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/transformer.rs index 17264725946..ed742f378f0 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_transfer_transition_action/v0/transformer.rs @@ -42,6 +42,7 @@ impl DocumentTransferTransitionActionV0 { base, get_data_contract, |document_type| document_type.document_transfer_token_cost(), + |action_fees| action_fees.document_transfer_action_fee(), "transfer", )?; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/transformer.rs index f18dca6085d..928c9b0a078 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_update_price_transition_action/v0/transformer.rs @@ -38,6 +38,7 @@ impl DocumentUpdatePriceTransitionActionV0 { base, get_data_contract, |document_type| document_type.document_update_price_token_cost(), + |action_fees| action_fees.document_price_update_action_fee(), "update_price", )?; 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 5dd2756f9c8..645962f8ea3 100644 --- a/packages/rs-drive/src/state_transition_action/batch/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/mod.rs @@ -6,12 +6,19 @@ use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::drive::contract_groups::types::ContractGroupMembershipsForContract; use dpp::fee::fee_result::FeeResult; use dpp::consensus::ConsensusError; +use dpp::balances::credits::MAX_CREDITS; use dpp::fee::Credits; use dpp::identity::SecurityLevel; use dpp::platform_value::Identifier; use dpp::prelude::UserFeeIncrease; use dpp::ProtocolError; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +use dpp::data_contract::document_type::action_fees::{ + ActionFeePricing, ContractFeePot, DocumentActionFee, FEE_MULTIPLIER_PERMILLE_BASE, +}; +use dpp::prelude::FeeMultiplier; +use crate::util::batch::drive_op_batch::{ContractFeePotOperationType, IdentityOperationType}; +use crate::util::batch::DriveOperation; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; /// batched transition @@ -59,6 +66,105 @@ impl ResolvedGasSponsor { } } +/// The action fee one document transition of a batch owes (protocol version 14): what its +/// document type declares for the action, priced for the epoch the batch executes in. +/// +/// It names no payer. Whoever pays the batch's gas pays its action fees, and that is settled +/// by fee validation: [`action_fee_operations`] turns the fees into operations once it is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedDocumentActionFee { + /// The contract the document type belongs to, whose pots the fee goes to + pub contract_id: Identifier, + /// The owner of that contract, who never pays into their own owner pot + pub contract_owner_id: Identifier, + /// The credits charged + pub fee: DocumentActionFee, +} + +impl ResolvedDocumentActionFee { + /// What `payer_id` owes for this fee. The owner part is dropped when the payer is the + /// contract owner: it would travel through the owner pot back to them and only cost + /// writes. The moderators part is always owed. + pub fn owed_by(&self, payer_id: &Identifier) -> DocumentActionFee { + if *payer_id == self.contract_owner_id { + DocumentActionFee { + owner: 0, + moderators: self.fee.moderators, + } + } else { + self.fee + } + } +} + +/// What `payer_id` owes for all of `action_fees`. +pub fn action_fees_total( + payer_id: &Identifier, + action_fees: &[ResolvedDocumentActionFee], +) -> Result { + Ok(action_fees.iter().fold(0 as Credits, |total, fee| { + saturating_credits(total, fee.owed_by(payer_id).saturating_total()) + })) +} + +/// The sum of two amounts of credits, held at `MAX_CREDITS`. Action fees that add up to more +/// than any balance can hold are owed in full and paid by nobody: fee validation refuses the +/// batch for an insufficient balance, a consensus error, where an overflow would have been an +/// internal one that no client can act on. +fn saturating_credits(a: Credits, b: Credits) -> Credits { + a.saturating_add(b).min(MAX_CREDITS) +} + +/// The operations that charge `action_fees` to `payer_id`: one removal from the payer's +/// balance and one addition per contract fee pot that receives something. Empty when nothing +/// is owed. +/// +/// The additions are summed per pot because a pot's new total is computed from the committed +/// one: two additions to the same pot in one batch would lose the first. The credits only +/// move, from an identity balance into pots under the prefunded balances sum tree, so the sum +/// of all credits is unchanged. +/// +/// Fee validation and execution both build the operations here, for the payer fee validation +/// settled on, so what is estimated is what is applied. +pub fn action_fee_operations( + payer_id: Identifier, + action_fees: &[ResolvedDocumentActionFee], +) -> Result>, ProtocolError> { + let mut per_pot: BTreeMap<(Identifier, ContractFeePot), Credits> = BTreeMap::new(); + for action_fee in action_fees { + let owed = action_fee.owed_by(&payer_id); + for pot in [ContractFeePot::Owner, ContractFeePot::Moderators] { + let amount = owed.part(pot); + if amount == 0 { + continue; + } + let pot_total = per_pot.entry((action_fee.contract_id, pot)).or_default(); + *pot_total = saturating_credits(*pot_total, amount); + } + } + // What leaves the payer is what reaches the pots, by construction. + let total = per_pot.values().fold(0 as Credits, |total, amount| { + saturating_credits(total, *amount) + }); + if total == 0 { + return Ok(vec![]); + } + let mut operations = vec![DriveOperation::IdentityOperation( + IdentityOperationType::RemoveFromIdentityBalance { + identity_id: payer_id.to_buffer(), + balance_to_remove: total, + }, + )]; + operations.extend(per_pot.into_iter().map(|((contract_id, pot), amount)| { + DriveOperation::ContractFeePotOperation(ContractFeePotOperationType::AddToPot { + contract_id, + pot, + amount, + }) + })); + Ok(operations) +} + /// Who pays the gas of a whole batch, as `GasFeesPaidBy::resolve` names it for each of its /// transitions #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -177,6 +283,74 @@ 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. + pub fn declared_action_fees( + &self, + ) -> Vec<(Identifier, Identifier, ActionFeePricing, DocumentActionFee)> { + match self { + BatchTransitionAction::V0(v0) => v0 + .transitions + .iter() + .filter_map(|transition| match transition { + BatchedTransitionAction::DocumentAction(document_action) => { + let base = document_action.base(); + let (pricing, fee) = base.declared_action_fee()?; + let contract = &base.data_contract_fetch_info_ref().contract; + Some((contract.id(), contract.owner_id(), pricing, fee)) + } + _ => None, + }) + .collect(), + } + } + + /// 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. + pub fn action_fee_multiplier_permille(&self) -> Option { + match self { + BatchTransitionAction::V0(v0) => v0.action_fee_multiplier_permille, + } + } + + /// Records the fee multiplier of the epoch the batch executes in + pub fn set_action_fee_multiplier_permille(&mut self, multiplier: Option) { + match self { + BatchTransitionAction::V0(v0) => v0.action_fee_multiplier_permille = multiplier, + } + } + + /// The action fees the batch owes: what its document transitions declare, priced. + /// + /// They are read off the transitions as they are now, not as the transformer built them. + /// State validation replaces a transition that fails with a nonce bump after the + /// transformer ran, and a bump declares nothing, so a fee is only ever owed for an action + /// that executes. + pub fn resolved_action_fees(&self) -> Result, ProtocolError> { + self.declared_action_fees() + .into_iter() + .map(|(contract_id, contract_owner_id, pricing, fee)| { + let fee_multiplier_permille = match pricing { + ActionFeePricing::Fixed => FEE_MULTIPLIER_PERMILLE_BASE, + ActionFeePricing::FeeMultiplier => self + .action_fee_multiplier_permille() + .ok_or(ProtocolError::CorruptedCodeExecution( + "the batch transformer reads the fee multiplier of every batch that \ + declares an action fee priced by it" + .to_string(), + ))?, + }; + Ok(ResolvedDocumentActionFee { + contract_id, + contract_owner_id, + fee: fee.charged(pricing, fee_multiplier_permille)?, + }) + }) + .collect() + } + /// Records the contract owner who sponsors the batch's gas, with their balance pub fn set_gas_sponsor(&mut self, gas_sponsor: Option) { match self { diff --git a/packages/rs-drive/src/state_transition_action/batch/tests.rs b/packages/rs-drive/src/state_transition_action/batch/tests.rs index 3ed547a0192..516a8ed2c26 100644 --- a/packages/rs-drive/src/state_transition_action/batch/tests.rs +++ b/packages/rs-drive/src/state_transition_action/batch/tests.rs @@ -133,6 +133,7 @@ fn test_document_base_v0() -> DocumentBaseTransitionActionV0 { token_cost: None, gas_fees_paid_by: GasFeesPaidBy::default(), contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, } } @@ -2936,6 +2937,7 @@ fn stamp_test_create_action(protocol_version: u32) -> DocumentCreateTransitionAc token_cost: None, gas_fees_paid_by: GasFeesPaidBy::default(), contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, }); DocumentCreateTransitionAction::V0(DocumentCreateTransitionActionV0 { base, @@ -2956,6 +2958,7 @@ fn stamp_test_replace_action(protocol_version: u32) -> DocumentReplaceTransition token_cost: None, gas_fees_paid_by: GasFeesPaidBy::default(), contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, }); DocumentReplaceTransitionAction::V0(DocumentReplaceTransitionActionV0 { base, @@ -3225,3 +3228,137 @@ fn should_not_let_a_failed_transition_name_a_payer() { Ok(GasPayer::DocumentOwner) ); } + +mod action_fees { + use crate::state_transition_action::batch::{ + action_fee_operations, action_fees_total, ResolvedDocumentActionFee, + }; + use crate::util::batch::drive_op_batch::{ContractFeePotOperationType, IdentityOperationType}; + use crate::util::batch::DriveOperation; + use dpp::balances::credits::MAX_CREDITS; + use dpp::data_contract::document_type::action_fees::{ContractFeePot, DocumentActionFee}; + use dpp::platform_value::Identifier; + + fn id(seed: u8) -> Identifier { + Identifier::from([seed; 32]) + } + + fn fee( + contract: u8, + contract_owner: u8, + owner: u64, + moderators: u64, + ) -> ResolvedDocumentActionFee { + ResolvedDocumentActionFee { + contract_id: id(contract), + contract_owner_id: id(contract_owner), + fee: DocumentActionFee { owner, moderators }, + } + } + + /// What was removed from the payer, and what was added to each pot of each contract + type Summary = ( + Option<(Identifier, u64)>, + Vec<(Identifier, ContractFeePot, u64)>, + ); + + /// The operations as `(removed from the payer, [(contract, pot, amount)])` + fn summarize(operations: Vec>) -> Summary { + let mut removed = None; + let mut added = vec![]; + for operation in operations { + match operation { + DriveOperation::IdentityOperation( + IdentityOperationType::RemoveFromIdentityBalance { + identity_id, + balance_to_remove, + }, + ) => { + assert!(removed.is_none(), "expected a single removal"); + removed = Some((Identifier::from(identity_id), balance_to_remove)); + } + DriveOperation::ContractFeePotOperation( + ContractFeePotOperationType::AddToPot { + contract_id, + pot, + amount, + }, + ) => added.push((contract_id, pot, amount)), + other => panic!("unexpected operation {other:?}"), + } + } + (removed, added) + } + + #[test] + fn should_charge_both_parts_to_a_payer_who_is_not_the_contract_owner() { + let fees = [fee(1, 9, 10, 100)]; + assert_eq!(action_fees_total(&id(5), &fees).expect("total"), 110); + let (removed, added) = summarize(action_fee_operations(id(5), &fees).expect("operations")); + assert_eq!(removed, Some((id(5), 110))); + assert_eq!( + added, + vec![ + (id(1), ContractFeePot::Owner, 10), + (id(1), ContractFeePot::Moderators, 100) + ] + ); + } + + #[test] + fn should_drop_the_owner_part_when_the_contract_owner_pays() { + let fees = [fee(1, 9, 10, 100)]; + assert_eq!(action_fees_total(&id(9), &fees).expect("total"), 100); + let (removed, added) = summarize(action_fee_operations(id(9), &fees).expect("operations")); + assert_eq!(removed, Some((id(9), 100))); + assert_eq!(added, vec![(id(1), ContractFeePot::Moderators, 100)]); + } + + #[test] + fn should_charge_nothing_when_nothing_is_owed() { + assert!(action_fee_operations(id(5), &[]) + .expect("operations") + .is_empty()); + // The contract owner pays a fee that only has an owner part: nothing moves. + let fees = [fee(1, 9, 10, 0)]; + assert_eq!(action_fees_total(&id(9), &fees).expect("total"), 0); + assert!(action_fee_operations(id(9), &fees) + .expect("operations") + .is_empty()); + } + + #[test] + fn should_add_to_each_pot_once_however_many_transitions_pay_into_it() { + // A pot's new total is computed from the committed one, so two additions to the same + // pot in one batch would lose the first. + let fees = [fee(1, 9, 10, 100), fee(1, 9, 1, 2), fee(2, 9, 0, 7)]; + let (removed, added) = summarize(action_fee_operations(id(5), &fees).expect("operations")); + assert_eq!(removed, Some((id(5), 120))); + assert_eq!( + added, + vec![ + (id(1), ContractFeePot::Owner, 11), + (id(1), ContractFeePot::Moderators, 102), + (id(2), ContractFeePot::Moderators, 7) + ] + ); + let paid_into_pots: u64 = added.iter().map(|(_, _, amount)| amount).sum(); + assert_eq!( + paid_into_pots, 120, + "what leaves the payer is what reaches the pots" + ); + } + + #[test] + fn should_hold_fees_that_add_up_past_the_maximum_credits_at_the_maximum() { + // Nobody can pay it, so fee validation refuses the batch for an insufficient balance: + // a consensus error, where an overflow would have been an internal one. + let fees = [fee(1, 9, u64::MAX / 2, 0), fee(1, 9, u64::MAX / 2, 5)]; + assert_eq!( + action_fees_total(&id(5), &fees).expect("total"), + MAX_CREDITS + ); + let (removed, _) = summarize(action_fee_operations(id(5), &fees).expect("operations")); + assert_eq!(removed, Some((id(5), MAX_CREDITS))); + } +} 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 19082811a3a..c5908b41de7 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 @@ -1,5 +1,8 @@ use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; -use crate::state_transition_action::batch::{GasPayer, ResolvedContractGroupMemberships, ResolvedGasSponsor}; +use crate::state_transition_action::batch::{ + GasPayer, ResolvedContractGroupMemberships, ResolvedGasSponsor, +}; +use dpp::prelude::FeeMultiplier; use dpp::consensus::state::token::{GasFeesPaidByNotAllowedError, InconsistentGasFeesPaidByInBatchError}; use dpp::consensus::ConsensusError; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -33,6 +36,11 @@ pub struct BatchTransitionActionV0 { /// The contract owner sponsoring the batch's gas, resolved by the batch transformer from /// protocol version 14 (see `ResolvedGasSponsor`) pub gas_sponsor: Option, + /// The fee multiplier, in permille, of the epoch the batch executes in, read by the batch + /// transformer from protocol version 14 when some document transition declares an action + /// 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 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 diff --git a/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/mod.rs b/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/mod.rs new file mode 100644 index 00000000000..c69562446e1 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/mod.rs @@ -0,0 +1,72 @@ +/// transformer +pub mod transformer; +/// v0 +pub mod v0; + +use crate::state_transition_action::contract::contract_fee_claim::v0::ContractFeeClaimTransitionActionV0; +use derive_more::From; +use dpp::block::epoch::EpochIndex; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::fee::Credits; +use dpp::platform_value::Identifier; +use dpp::prelude::{IdentityNonce, UserFeeIncrease}; +use std::collections::BTreeMap; + +/// The action of a contract fee claim transition: the payout of one of a contract's fee pots, +/// with what each recipient is paid as settled when the transition was validated. +#[derive(Debug, Clone, From)] +pub enum ContractFeeClaimTransitionAction { + /// v0 + V0(ContractFeeClaimTransitionActionV0), +} + +impl ContractFeeClaimTransitionAction { + /// The claimant that signed + pub fn claimant_id(&self) -> Identifier { + match self { + ContractFeeClaimTransitionAction::V0(action) => action.claimant_id, + } + } + + /// The contract whose pot is paid out + pub fn data_contract_id(&self) -> Identifier { + match self { + ContractFeeClaimTransitionAction::V0(action) => action.data_contract_id, + } + } + + /// The claimant's nonce for the contract + pub fn identity_contract_nonce(&self) -> IdentityNonce { + match self { + ContractFeeClaimTransitionAction::V0(action) => action.identity_contract_nonce, + } + } + + /// The pot that is paid out + pub fn pot(&self) -> ContractFeePot { + match self { + ContractFeeClaimTransitionAction::V0(action) => action.pot, + } + } + + /// The epoch of the claim + pub fn epoch_index(&self) -> EpochIndex { + match self { + ContractFeeClaimTransitionAction::V0(action) => action.epoch_index, + } + } + + /// What each recipient is paid + pub fn payouts(&self) -> &BTreeMap { + match self { + ContractFeeClaimTransitionAction::V0(action) => &action.payouts, + } + } + + /// fee multiplier + pub fn user_fee_increase(&self) -> UserFeeIncrease { + match self { + ContractFeeClaimTransitionAction::V0(action) => action.user_fee_increase, + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/transformer.rs b/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/transformer.rs new file mode 100644 index 00000000000..52edeb27fef --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/transformer.rs @@ -0,0 +1,28 @@ +use crate::state_transition_action::contract::contract_fee_claim::v0::ContractFeeClaimTransitionActionV0; +use crate::state_transition_action::contract::contract_fee_claim::ContractFeeClaimTransitionAction; +use dpp::block::epoch::EpochIndex; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use std::collections::BTreeMap; + +impl ContractFeeClaimTransitionAction { + /// The action of a borrowed transition, carrying the epoch of the claim and what each + /// recipient is paid + pub fn from_borrowed_transition_with_payouts( + value: &ContractFeeClaimTransition, + epoch_index: EpochIndex, + payouts: BTreeMap, + ) -> Self { + match value { + ContractFeeClaimTransition::V0(v0) => { + ContractFeeClaimTransitionActionV0::from_borrowed_transition_with_payouts( + v0, + epoch_index, + payouts, + ) + .into() + } + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/v0/mod.rs b/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/v0/mod.rs new file mode 100644 index 00000000000..ec2c6a65578 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/v0/mod.rs @@ -0,0 +1,29 @@ +mod transformer; + +use dpp::block::epoch::EpochIndex; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::prelude::{IdentityNonce, UserFeeIncrease}; +use std::collections::BTreeMap; + +/// action v0 +#[derive(Debug, Clone)] +pub struct ContractFeeClaimTransitionActionV0 { + /// the claimant that signed + pub claimant_id: Identifier, + /// the contract whose pot is paid out + pub data_contract_id: Identifier, + /// the claimant's nonce for the contract, used to prevent replay attacks + pub identity_contract_nonce: IdentityNonce, + /// the pot that is paid out + pub pot: ContractFeePot, + /// the epoch of the claim, recorded as the pot's last claim epoch + pub epoch_index: EpochIndex, + /// what each recipient is paid, as settled when the transition was validated: the whole + /// owner pot to the contract owner, or an equal share of the moderators pot to every + /// member of the moderation team. Never empty, and every amount is above zero. + pub payouts: BTreeMap, + /// fee multiplier + pub user_fee_increase: UserFeeIncrease, +} diff --git a/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/v0/transformer.rs new file mode 100644 index 00000000000..9d10b379662 --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/contract/contract_fee_claim/v0/transformer.rs @@ -0,0 +1,34 @@ +use crate::state_transition_action::contract::contract_fee_claim::v0::ContractFeeClaimTransitionActionV0; +use dpp::block::epoch::EpochIndex; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0; +use std::collections::BTreeMap; + +impl ContractFeeClaimTransitionActionV0 { + /// The action of a borrowed transition, carrying the epoch of the claim and what each + /// recipient is paid + pub fn from_borrowed_transition_with_payouts( + value: &ContractFeeClaimTransitionV0, + epoch_index: EpochIndex, + payouts: BTreeMap, + ) -> Self { + let ContractFeeClaimTransitionV0 { + owner_id, + data_contract_id, + identity_contract_nonce, + pot, + user_fee_increase, + .. + } = value; + ContractFeeClaimTransitionActionV0 { + claimant_id: *owner_id, + data_contract_id: *data_contract_id, + identity_contract_nonce: *identity_contract_nonce, + pot: *pot, + epoch_index, + payouts, + user_fee_increase: *user_fee_increase, + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/contract/mod.rs b/packages/rs-drive/src/state_transition_action/contract/mod.rs index b15730941a5..b501ccb64c8 100644 --- a/packages/rs-drive/src/state_transition_action/contract/mod.rs +++ b/packages/rs-drive/src/state_transition_action/contract/mod.rs @@ -1,3 +1,5 @@ +/// contract fee claim +pub mod contract_fee_claim; /// user moderation pub mod contract_user_moderation; /// create diff --git a/packages/rs-drive/src/state_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/mod.rs index 9a3d13ac66f..bf7914816a7 100644 --- a/packages/rs-drive/src/state_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/mod.rs @@ -18,6 +18,7 @@ use crate::state_transition_action::address_funds::address_credit_withdrawal::Ad use crate::state_transition_action::address_funds::address_funding_from_asset_lock::AddressFundingFromAssetLockTransitionAction; use crate::state_transition_action::address_funds::address_funds_transfer::AddressFundsTransferTransitionAction; use crate::state_transition_action::batch::BatchTransitionAction; +use crate::state_transition_action::contract::contract_fee_claim::ContractFeeClaimTransitionAction; use crate::state_transition_action::contract::contract_user_moderation::ContractUserModerationTransitionAction; use crate::state_transition_action::contract::data_contract_create::DataContractCreateTransitionAction; use crate::state_transition_action::contract::data_contract_update::DataContractUpdateTransitionAction; @@ -122,6 +123,8 @@ pub enum StateTransitionAction { IdentityTopUpFromShieldedPoolAction(IdentityTopUpFromShieldedPoolTransitionAction), /// contract user moderation: one edit of a contract's banlist or suspension list ContractUserModerationAction(ContractUserModerationTransitionAction), + /// contract fee claim: the payout of one of a contract's fee pots + ContractFeeClaimAction(ContractFeeClaimTransitionAction), } impl StateTransitionAction { @@ -133,6 +136,7 @@ impl StateTransitionAction { StateTransitionAction::ContractUserModerationAction(action) => { action.user_fee_increase() } + StateTransitionAction::ContractFeeClaimAction(action) => action.user_fee_increase(), StateTransitionAction::BatchAction(action) => action.user_fee_increase(), StateTransitionAction::IdentityCreateAction(action) => action.user_fee_increase(), StateTransitionAction::IdentityTopUpAction(action) => action.user_fee_increase(), diff --git a/packages/rs-drive/src/state_transition_action/system/bump_identity_data_contract_nonce_action/transformer.rs b/packages/rs-drive/src/state_transition_action/system/bump_identity_data_contract_nonce_action/transformer.rs index 66a39e1c839..b37446ef6e2 100644 --- a/packages/rs-drive/src/state_transition_action/system/bump_identity_data_contract_nonce_action/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/system/bump_identity_data_contract_nonce_action/transformer.rs @@ -4,6 +4,7 @@ use dpp::ProtocolError; use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; use dpp::state_transition::contract_user_moderation_transition::ContractUserModerationTransition; use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; @@ -239,6 +240,15 @@ impl BumpIdentityDataContractNonceAction { } } + /// from borrowed contract fee claim transition + pub fn from_borrowed_contract_fee_claim_transition(value: &ContractFeeClaimTransition) -> Self { + match value { + ContractFeeClaimTransition::V0(v0) => { + BumpIdentityDataContractNonceActionV0::from_borrowed_contract_fee_claim(v0).into() + } + } + } + /// from borrowed contract user moderation transition pub fn from_borrowed_contract_user_moderation_transition( value: &ContractUserModerationTransition, @@ -374,6 +384,7 @@ mod tests { token_cost: None, gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, }); let action = BumpIdentityDataContractNonceAction::from_document_base_transition_action( base_action, @@ -396,6 +407,7 @@ mod tests { token_cost: None, gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, }); let action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action( diff --git a/packages/rs-drive/src/state_transition_action/system/bump_identity_data_contract_nonce_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/system/bump_identity_data_contract_nonce_action/v0/transformer.rs index b490fd71816..813d52b1182 100644 --- a/packages/rs-drive/src/state_transition_action/system/bump_identity_data_contract_nonce_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/system/bump_identity_data_contract_nonce_action/v0/transformer.rs @@ -2,6 +2,7 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::platform_value::Identifier; use dpp::prelude::UserFeeIncrease; use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; +use dpp::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0; use dpp::state_transition::contract_user_moderation_transition::v0::ContractUserModerationTransitionV0; use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransitionV0; use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; @@ -176,6 +177,23 @@ impl BumpIdentityDataContractNonceActionV0 { } } + /// from borrowed contract fee claim transition + pub fn from_borrowed_contract_fee_claim(value: &ContractFeeClaimTransitionV0) -> Self { + let ContractFeeClaimTransitionV0 { + owner_id, + data_contract_id, + identity_contract_nonce, + user_fee_increase, + .. + } = value; + BumpIdentityDataContractNonceActionV0 { + identity_id: *owner_id, + data_contract_id: *data_contract_id, + identity_contract_nonce: *identity_contract_nonce, + user_fee_increase: *user_fee_increase, + } + } + /// from data contract update action pub fn from_data_contract_update_action(value: DataContractUpdateTransitionActionV0) -> Self { let DataContractUpdateTransitionActionV0 { @@ -309,6 +327,7 @@ mod tests { token_cost: None, gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, }); let action = BumpIdentityDataContractNonceActionV0::from_document_base_transition_action( base_action, @@ -334,6 +353,7 @@ mod tests { token_cost: None, gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, }); let action = BumpIdentityDataContractNonceActionV0::from_borrowed_document_base_transition_action( diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 3713aeb1ff0..04681aeebdb 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -278,7 +278,9 @@ mod fixtures { use crate::structure::export::{LayerShape, ShapeNode, StateShape}; use crate::structure::shape::shape_at; use crate::structure::{KeySpec, NodeId}; - use crate::util::batch::drive_op_batch::AddressFundsOperationType; + use crate::util::batch::drive_op_batch::{ + AddressFundsOperationType, ContractFeePotOperationType, + }; use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; use crate::util::batch::DriveOperation; use crate::util::batch::GroveDbOpBatch; @@ -322,6 +324,7 @@ mod fixtures { }; use dpp::data_contract::config::v0::{DataContractConfigSettersV0, DataContractConfigV0}; use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::document_type::action_fees::ContractFeePot; use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::data_contract::group::v0::GroupV0; use dpp::data_contract::group::Group; @@ -702,6 +705,36 @@ mod fixtures { platform_version, ) .expect("expected to suspend"); + // Both fee pots hold credits and were claimed once: the pots and the last claim + // epochs are created on first use. + let fee_pot_operations = [ContractFeePot::Owner, ContractFeePot::Moderators] + .into_iter() + .flat_map(|pot| { + [ + ContractFeePotOperationType::AddToPot { + contract_id: contract.id(), + pot, + amount: 1_000, + }, + ContractFeePotOperationType::SetLastClaimEpoch { + contract_id: contract.id(), + pot, + epoch_index: 3, + }, + ] + }) + .map(DriveOperation::ContractFeePotOperation) + .collect(); + drive + .apply_drive_operations( + fee_pot_operations, + true, + &BlockInfo::default(), + None, + platform_version, + None, + ) + .expect("expected to fill and claim the fee pots"); conformance_of(&drive, "moderated_contract", run); } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/contract_fee_pot.rs b/packages/rs-drive/src/util/batch/drive_op_batch/contract_fee_pot.rs new file mode 100644 index 00000000000..878e8aa917f --- /dev/null +++ b/packages/rs-drive/src/util/batch/drive_op_batch/contract_fee_pot.rs @@ -0,0 +1,100 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::batch::drive_op_batch::DriveLowLevelOperationConverter; +use dpp::balances::credits::Credits; +use dpp::block::block_info::BlockInfo; +use dpp::block::epoch::EpochIndex; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use platform_version::version::PlatformVersion; +use std::collections::HashMap; + +/// Operations on the fee pots a contract's document action fees accumulate in. +/// +/// Neither `AddToPot` nor `DeductFromPot` creates or destroys credits: the batch that carries +/// one also moves the same credits out of, or into, identity balances. +#[derive(Clone, Debug)] +pub enum ContractFeePotOperationType { + /// Adds credits to a pot. A batch carries at most one of these per contract and pot: the + /// new total is computed from the committed one. + AddToPot { + /// The contract the pot belongs to. + contract_id: Identifier, + /// The pot. + pot: ContractFeePot, + /// The credits to add. + amount: Credits, + }, + /// Takes credits out of a pot. + DeductFromPot { + /// The contract the pot belongs to. + contract_id: Identifier, + /// The pot. + pot: ContractFeePot, + /// The credits to take out. + amount: Credits, + }, + /// Records the epoch a pot was claimed in. + SetLastClaimEpoch { + /// The contract the pot belongs to. + contract_id: Identifier, + /// The pot. + pot: ContractFeePot, + /// The epoch of the claim. + epoch_index: EpochIndex, + }, +} + +impl DriveLowLevelOperationConverter for ContractFeePotOperationType { + fn into_low_level_drive_operations( + self, + drive: &Drive, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + _block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match self { + ContractFeePotOperationType::AddToPot { + contract_id, + pot, + amount, + } => drive.add_to_contract_fee_pot_operations( + contract_id, + pot, + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + ContractFeePotOperationType::DeductFromPot { + contract_id, + pot, + amount, + } => drive.deduct_from_contract_fee_pot_operations( + contract_id, + pot, + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + ContractFeePotOperationType::SetLastClaimEpoch { + contract_id, + pot, + epoch_index, + } => drive.set_contract_last_fee_claim_epoch_operations( + contract_id, + pot, + epoch_index, + estimated_costs_only_with_layer_info, + platform_version, + ), + } + } +} diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs index 9de0fddeb36..2e3392dee59 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/mod.rs @@ -1,5 +1,6 @@ mod address_funds; mod contract; +mod contract_fee_pot; mod contract_group; mod contract_moderation; mod document; @@ -23,6 +24,7 @@ use dpp::fee::Credits; pub use address_funds::AddressFundsOperationType; pub use contract::DataContractOperationType; +pub use contract_fee_pot::ContractFeePotOperationType; pub use contract_group::ContractGroupOperationType; pub use contract_moderation::ContractModerationOperationType; pub use document::DocumentOperation; @@ -99,6 +101,8 @@ pub enum DriveOperation<'a> { ContractGroupOperation(ContractGroupOperationType), /// A contract moderation operation: an entry of a banlist or a suspension list ContractModerationOperation(ContractModerationOperationType), + /// A contract fee pot operation: credits of a contract's document action fees + ContractFeePotOperation(ContractFeePotOperationType), /// An address funds operation AddressFundsOperation(AddressFundsOperationType), /// A shielded pool operation @@ -223,6 +227,15 @@ impl DriveLowLevelOperationConverter for DriveOperation<'_> { platform_version, ) } + DriveOperation::ContractFeePotOperation(contract_fee_pot_operation_type) => { + contract_fee_pot_operation_type.into_low_level_drive_operations( + drive, + estimated_costs_only_with_layer_info, + block_info, + transaction, + platform_version, + ) + } DriveOperation::AddressFundsOperation(address_funds_operation_type) => { address_funds_operation_type.into_low_level_drive_operations( drive, diff --git a/packages/rs-drive/src/util/batch/mod.rs b/packages/rs-drive/src/util/batch/mod.rs index 6fff9f8dd71..c0ed77882f7 100644 --- a/packages/rs-drive/src/util/batch/mod.rs +++ b/packages/rs-drive/src/util/batch/mod.rs @@ -7,6 +7,7 @@ pub mod drive_op_batch; pub mod grovedb_op_batch; +pub use drive_op_batch::ContractFeePotOperationType; pub use drive_op_batch::ContractGroupOperationType; pub use drive_op_batch::ContractModerationOperationType; pub use drive_op_batch::DataContractOperationType; diff --git a/packages/rs-drive/src/verify/contract_moderation/mod.rs b/packages/rs-drive/src/verify/contract_moderation/mod.rs index c1346a436f8..0dcc01b15e2 100644 --- a/packages/rs-drive/src/verify/contract_moderation/mod.rs +++ b/packages/rs-drive/src/verify/contract_moderation/mod.rs @@ -1,2 +1,3 @@ +mod verify_contract_fee_pots; mod verify_contract_moderation_entries; mod verify_contract_moderation_status; diff --git a/packages/rs-drive/src/verify/contract_moderation/verify_contract_fee_pots/mod.rs b/packages/rs-drive/src/verify/contract_moderation/verify_contract_fee_pots/mod.rs new file mode 100644 index 00000000000..75729d08f8d --- /dev/null +++ b/packages/rs-drive/src/verify/contract_moderation/verify_contract_fee_pots/mod.rs @@ -0,0 +1,60 @@ +mod v0; + +use crate::drive::contract::fee_pots::types::ContractFeePots; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::verify::RootHash; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; + +impl Drive { + /// Verifies the proof of a contract's fee pots: the credits each proved pot holds and the + /// epoch it was last claimed in. + /// + /// Only the pots in `pots` are proved. The other one is returned empty and never claimed, + /// which says nothing about it: callers that did not ask for a pot must not read it. + /// + /// # Parameters + /// + /// * `proof`: The proof. + /// * `contract_id`: The contract the pots belong to. + /// * `pots`: The pots that were proved, at least one. + /// * `verify_subset_of_proof`: Whether the proof may prove more than these pots, as the + /// proof of a contract fee claim's execution does. + /// * `platform_version`: The platform version. + /// + /// # Returns + /// + /// * `Ok((RootHash, ContractFeePots))` with the root hash and the pots. + /// * `Err(Error)` when the version is unknown or the proof is not one of these pots. + pub fn verify_contract_fee_pots( + proof: &[u8], + contract_id: Identifier, + pots: &[ContractFeePot], + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, ContractFeePots), Error> { + match platform_version + .drive + .methods + .verify + .contract_moderation + .verify_contract_fee_pots + { + 0 => Self::verify_contract_fee_pots_v0( + proof, + contract_id, + pots, + verify_subset_of_proof, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "verify_contract_fee_pots".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/verify/contract_moderation/verify_contract_fee_pots/v0/mod.rs b/packages/rs-drive/src/verify/contract_moderation/verify_contract_fee_pots/v0/mod.rs new file mode 100644 index 00000000000..58a22136157 --- /dev/null +++ b/packages/rs-drive/src/verify/contract_moderation/verify_contract_fee_pots/v0/mod.rs @@ -0,0 +1,97 @@ +use crate::drive::contract::fee_pots::types::{decode_epoch_index, ContractFeePots}; +use crate::drive::contract::paths::{ + contract_fee_pots_key, contract_last_fee_claim_epoch_key, CONTRACT_OTHER_KEY, +}; +use crate::drive::Drive; +use crate::error::proof::ProofError; +use crate::error::Error; +use crate::verify::RootHash; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::{Element, GroveDb}; + +impl Drive { + pub(super) fn verify_contract_fee_pots_v0( + proof: &[u8], + contract_id: Identifier, + pots: &[ContractFeePot], + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, ContractFeePots), Error> { + if pots.is_empty() { + return Err(Error::Proof(ProofError::IncorrectProof( + "a contract fee pots proof needs at least one pot".to_string(), + ))); + } + let path_query = Self::contract_fee_pots_query( + contract_id.to_buffer(), + pots, + &platform_version.drive.grove_version, + )?; + let (root_hash, proved_key_values) = if verify_subset_of_proof { + GroveDb::verify_subset_query(proof, &path_query, &platform_version.drive.grove_version)? + } else { + GroveDb::verify_query(proof, &path_query, &platform_version.drive.grove_version)? + }; + + let mut fee_pots = ContractFeePots::default(); + for (path, key, element) in proved_key_values { + let Some(element) = element else { + continue; + }; + let in_other_tree = path.last().map(Vec::as_slice) == Some(&[CONTRACT_OTHER_KEY]); + if in_other_tree { + // A last claim epoch: `[64, contract id, 2] -> 32 | 96` + let Some(pot) = pots + .iter() + .find(|pot| key.as_slice() == contract_last_fee_claim_epoch_key(**pot)) + else { + return Err(Error::Proof(ProofError::CorruptedProof( + "contract fee pots proof holds an entry outside the pots asked for" + .to_string(), + ))); + }; + let Element::Item(value, _) = element else { + return Err(Error::Proof(ProofError::CorruptedProof( + "contract fee pot last claim epoch is not an item".to_string(), + ))); + }; + fee_pots.pot_mut(*pot).last_claim_epoch = + Some(decode_epoch_index(&value).map_err(|description| { + Error::Proof(ProofError::CorruptedProof(format!( + "contract fee pot last claim epoch is malformed: {}", + description + ))) + })?); + } else { + // A pot: `[40, 64 | 192] -> contract id` + if key.as_slice() != contract_id.as_slice() { + return Err(Error::Proof(ProofError::CorruptedProof( + "contract fee pots proof holds another contract's pot".to_string(), + ))); + } + let Some(pot) = pots.iter().find(|pot| { + path.last().map(Vec::as_slice) == Some(contract_fee_pots_key(**pot)) + }) else { + return Err(Error::Proof(ProofError::CorruptedProof( + "contract fee pots proof holds a pot outside the pots asked for" + .to_string(), + ))); + }; + let Element::SumItem(credits, _) = element else { + return Err(Error::Proof(ProofError::CorruptedProof( + "contract fee pot is not a sum item".to_string(), + ))); + }; + fee_pots.pot_mut(*pot).credits = u64::try_from(credits).map_err(|_| { + Error::Proof(ProofError::CorruptedProof( + "contract fee pot holds a negative amount".to_string(), + )) + })?; + } + } + + Ok((root_hash, fee_pots)) + } +} diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index e45aaff3b0b..02bfef5fea7 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -31,6 +31,7 @@ use dpp::state_transition::identity_credit_transfer_to_addresses_transition::acc use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dpp::data_contract::config::v2::DataContractConfigGettersV2; use dpp::data_contract::config::moderation::ContractModerationList; +use dpp::state_transition::contract_fee_claim_transition::accessors::ContractFeeClaimTransitionAccessorsV0; use dpp::state_transition::contract_user_moderation_transition::accessors::ContractUserModerationTransitionAccessorsV0; use dpp::state_transition::contract_user_moderation_transition::ContractUserModerationAction; use dpp::state_transition::identity_key_limits_update_transition::accessors::IdentityKeyLimitsUpdateTransitionAccessorsV0; @@ -54,7 +55,7 @@ use dpp::state_transition::identity_credit_withdrawal_transition::accessors::Ide use dpp::state_transition::identity_topup_transition::accessors::IdentityTopUpTransitionAccessorsV0; use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; use dpp::state_transition::proof_result::StateTransitionProofOutcome; -use dpp::state_transition::proof_result::StateTransitionProofResult::{VerifiedAddressInfos, VerifiedContractModerationListStatuses, VerifiedBalanceTransfer, VerifiedDataContract, VerifiedDocuments, VerifiedIdentity, VerifiedIdentityFullWithAddressInfos, VerifiedIdentityWithAddressInfos, VerifiedMasternodeVote, VerifiedPartialIdentity, VerifiedTokenActionWithDocument, VerifiedTokenBalance, VerifiedTokenGroupActionWithDocument, VerifiedTokenGroupActionWithTokenBalance, VerifiedTokenGroupActionWithTokenIdentityInfo, VerifiedTokenGroupActionWithTokenPricingSchedule, VerifiedTokenIdentitiesBalances, VerifiedTokenIdentityInfo, VerifiedTokenPricingSchedule}; +use dpp::state_transition::proof_result::StateTransitionProofResult::{VerifiedAddressInfos, VerifiedContractFeeClaim, VerifiedContractModerationListStatuses, VerifiedBalanceTransfer, VerifiedDataContract, VerifiedDocuments, VerifiedIdentity, VerifiedIdentityFullWithAddressInfos, VerifiedIdentityWithAddressInfos, VerifiedMasternodeVote, VerifiedPartialIdentity, VerifiedTokenActionWithDocument, VerifiedTokenBalance, VerifiedTokenGroupActionWithDocument, VerifiedTokenGroupActionWithTokenBalance, VerifiedTokenGroupActionWithTokenIdentityInfo, VerifiedTokenGroupActionWithTokenPricingSchedule, VerifiedTokenIdentitiesBalances, VerifiedTokenIdentityInfo, VerifiedTokenPricingSchedule}; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dpp::tokens::info::v0::IdentityTokenInfoV0Accessors; use dpp::voting::vote_polls::VotePoll; @@ -1218,6 +1219,80 @@ impl Drive { VerifiedContractModerationListStatuses(contract_id, identity_id, statuses), )) } + StateTransition::ContractFeeClaim(transition) => { + let contract_id = transition.data_contract_id(); + let pot = transition.pot(); + let contract = known_contracts_provider_fn(&contract_id)?.ok_or(Error::Proof( + ProofError::UnknownContract(format!( + "unknown contract with id {} in contract fee claim verification", + contract_id + )), + ))?; + let recipients: Vec<[u8; 32]> = pot + .recipients(&contract) + .into_iter() + .map(|recipient| recipient.to_buffer()) + .collect(); + + // The proof holds the pot with its last claim epoch and the recipients' + // balances; each part is verified as a subset of it, and they must agree on + // the state they are read from. + let (root_hash, fee_pots) = Drive::verify_contract_fee_pots( + proof, + contract_id, + &[pot], + true, + platform_version, + )?; + let (balances_root_hash, balances): ( + RootHash, + BTreeMap>, + ) = Drive::verify_identity_balances_for_identity_ids( + proof, + true, + &recipients, + platform_version, + )?; + if balances_root_hash != root_hash { + return Err(Error::Proof(ProofError::CorruptedProof( + "the pot and the balances of a contract fee claim proof are read from different states" + .to_string(), + ))); + } + + let fee_pot = fee_pots.pot(pot); + // A pot that was never claimed has no last claim epoch: the claim did not + // execute. A later claim leaves a later epoch and verifies just the same, so + // this only authenticates the affected state. + let last_claim_epoch = + fee_pot + .last_claim_epoch + .ok_or(Error::Proof(ProofError::IncorrectProof(format!( + "proof of state transition execution does not show a claim of the {} fee pot of contract {}", + pot, contract_id + ))))?; + let balances = balances + .into_iter() + .map(|(recipient, balance)| { + balance.map(|balance| (recipient, balance)).ok_or(Error::Proof( + ProofError::IncorrectProof(format!( + "proof did not contain the balance of {}, a recipient of the {} fee pot of contract {}", + recipient, pot, contract_id + )), + )) + }) + .collect::, Error>>()?; + Ok(( + root_hash, + VerifiedContractFeeClaim( + contract_id, + pot, + last_claim_epoch, + fee_pot.credits, + balances, + ), + )) + } StateTransition::IdentityKeyLimitsUpdate(transition) => { // The proof holds the rewritten key, nothing more. let (root_hash, identity) = Drive::verify_identity_keys_by_identity_id( @@ -2523,6 +2598,10 @@ impl Drive { // moderation leaving the same entry verifies just the same, so this only // authenticates the affected state. StateTransition::ContractUserModeration(_) => false, + // The proven pot shows that it was claimed and what it holds now; a later claim + // of the same pot verifies just the same, so this only authenticates the affected + // state. + StateTransition::ContractFeeClaim(_) => false, // The proven vote is stored under the masternode's identity and // must equal the transition's declared vote. StateTransition::MasternodeVote(_) => true, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs index 86db53993c8..844b0ec9b2b 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/mod.rs @@ -24,6 +24,7 @@ pub struct DPPStateTransitionSerializationVersions { /// `ContractUserModeration` (protocol version 14): bans and suspends identities on a /// moderated contract. pub contract_user_moderation_state_transition: FeatureVersionBounds, + pub contract_fee_claim_state_transition: FeatureVersionBounds, pub batch_state_transition: FeatureVersionBounds, pub document_base_state_transition: FeatureVersionBounds, pub document_create_state_transition: DocumentFeatureVersionBounds, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs index 553b27fb0cb..423fd3b2095 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v1.rs @@ -75,6 +75,11 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V1: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + contract_fee_claim_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, batch_state_transition: FeatureVersionBounds { min_version: 0, max_version: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs index b52869fdedb..b2ff5de0ece 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v2.rs @@ -75,6 +75,11 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V2: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + contract_fee_claim_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, batch_state_transition: FeatureVersionBounds { min_version: 0, max_version: 1, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs index 1175dae3e1e..0bfd6fc6840 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs @@ -82,6 +82,11 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V3: DPPStateTransitionSerializ max_version: 0, default_current_version: 0, }, + contract_fee_claim_state_transition: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, batch_state_transition: FeatureVersionBounds { min_version: 0, max_version: 1, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 60901cb83a6..06b46921a71 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -133,6 +133,7 @@ pub struct DriveAbciStateTransitionValidationVersions { pub contract_update_state_transition: DriveAbciStateTransitionValidationVersion, /// `ContractUserModeration` (protocol version 14). pub contract_user_moderation_state_transition: DriveAbciStateTransitionValidationVersion, + pub contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion, /// Validation of the `refersTo` reference declarations a contract's /// document types carry, run at contract create and update. Only /// reachable from contract create/update state validation 1 and above. diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index 3f77314747a..5c4a32fe8f1 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -118,6 +118,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 21142c8edab..87db84be22e 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -131,6 +131,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: Some(0), + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index 58328dc60f8..0155575b4b8 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -118,6 +118,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index c3d905acf5e..1bad8364568 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -118,6 +118,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index e71fbc3c59c..1ec242e2dcb 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -121,6 +121,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index 1961ed553c7..09e0ada5244 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -122,6 +122,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index d73a6a40462..cbf5c7e6e41 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -125,6 +125,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index a2f3d6df795..2282a6065cb 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -119,6 +119,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index 8d3c9dab095..b972fae1cbe 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -133,6 +133,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs index 967af8a1a15..2951f42e14f 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs @@ -126,6 +126,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V9: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, + contract_fee_claim_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs index b3f62defa34..50d5ab67c3b 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/mod.rs @@ -14,6 +14,7 @@ pub struct DriveContractMethodVersions { pub costs: DriveContractCostsMethodVersions, pub get: DriveContractGetMethodVersions, pub moderation: DriveContractModerationMethodVersions, + pub fee_pots: DriveContractFeePotMethodVersions, } /// Drive methods for contract moderation: the banlist and the suspension list a moderated @@ -33,6 +34,22 @@ pub struct DriveContractModerationMethodVersions { pub add_estimation_costs_for_contract_moderation_entry: FeatureVersion, } +/// Drive methods for the two fee pots a contract's document action fees accumulate in +/// (`[40, 64, contract id]` the owner pot, `[40, 192, contract id]` the moderators pot) and +/// the epoch each pot was last claimed in (keys `32` and `96` of the contract's other tree, +/// `[64, id, 2]`). Protocol version 14. +#[derive(Clone, Debug, Default)] +pub struct DriveContractFeePotMethodVersions { + pub insert_contract_fee_pot_trees: FeatureVersion, + pub add_to_contract_fee_pot: FeatureVersion, + pub deduct_from_contract_fee_pot: FeatureVersion, + pub fetch_contract_fee_pot: FeatureVersion, + pub fetch_action_fee_multiplier: FeatureVersion, + pub set_contract_last_fee_claim_epoch: FeatureVersion, + pub prove_contract_fee_pots: FeatureVersion, + pub add_estimation_costs_for_contract_fee_pot_update: FeatureVersion, +} + #[derive(Clone, Debug, Default)] pub struct DriveContractProveMethodVersions { pub prove_contract: FeatureVersion, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v1.rs index b8ef6f22c2d..f17072ceb20 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v1.rs @@ -1,6 +1,7 @@ use crate::version::drive_versions::drive_contract_method_versions::{ DriveContractApplyMethodVersions, DriveContractCostsMethodVersions, - DriveContractGetMethodVersions, DriveContractInsertMethodVersions, DriveContractMethodVersions, + DriveContractFeePotMethodVersions, DriveContractGetMethodVersions, + DriveContractInsertMethodVersions, DriveContractMethodVersions, DriveContractModerationMethodVersions, DriveContractProveMethodVersions, DriveContractUpdateMethodVersions, }; @@ -56,4 +57,14 @@ pub const DRIVE_CONTRACT_METHOD_VERSIONS_V1: DriveContractMethodVersions = add_estimation_costs_for_contract_moderation_trees: 0, add_estimation_costs_for_contract_moderation_entry: 0, }, + fee_pots: DriveContractFeePotMethodVersions { + insert_contract_fee_pot_trees: 0, + add_to_contract_fee_pot: 0, + deduct_from_contract_fee_pot: 0, + fetch_contract_fee_pot: 0, + fetch_action_fee_multiplier: 0, + set_contract_last_fee_claim_epoch: 0, + prove_contract_fee_pots: 0, + add_estimation_costs_for_contract_fee_pot_update: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v2.rs index 77b1ff2b603..8a4272f4835 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v2.rs @@ -1,6 +1,7 @@ use crate::version::drive_versions::drive_contract_method_versions::{ DriveContractApplyMethodVersions, DriveContractCostsMethodVersions, - DriveContractGetMethodVersions, DriveContractInsertMethodVersions, DriveContractMethodVersions, + DriveContractFeePotMethodVersions, DriveContractGetMethodVersions, + DriveContractInsertMethodVersions, DriveContractMethodVersions, DriveContractModerationMethodVersions, DriveContractProveMethodVersions, DriveContractUpdateMethodVersions, }; @@ -56,4 +57,14 @@ pub const DRIVE_CONTRACT_METHOD_VERSIONS_V2: DriveContractMethodVersions = add_estimation_costs_for_contract_moderation_trees: 0, add_estimation_costs_for_contract_moderation_entry: 0, }, + fee_pots: DriveContractFeePotMethodVersions { + insert_contract_fee_pot_trees: 0, + add_to_contract_fee_pot: 0, + deduct_from_contract_fee_pot: 0, + fetch_contract_fee_pot: 0, + fetch_action_fee_multiplier: 0, + set_contract_last_fee_claim_epoch: 0, + prove_contract_fee_pots: 0, + add_estimation_costs_for_contract_fee_pot_update: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v3.rs index 0ee3bc8441f..7e9cfffd04e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v3.rs @@ -1,6 +1,7 @@ use crate::version::drive_versions::drive_contract_method_versions::{ DriveContractApplyMethodVersions, DriveContractCostsMethodVersions, - DriveContractGetMethodVersions, DriveContractInsertMethodVersions, DriveContractMethodVersions, + DriveContractFeePotMethodVersions, DriveContractGetMethodVersions, + DriveContractInsertMethodVersions, DriveContractMethodVersions, DriveContractModerationMethodVersions, DriveContractProveMethodVersions, DriveContractUpdateMethodVersions, }; @@ -67,4 +68,14 @@ pub const DRIVE_CONTRACT_METHOD_VERSIONS_V3: DriveContractMethodVersions = add_estimation_costs_for_contract_moderation_trees: 0, add_estimation_costs_for_contract_moderation_entry: 0, }, + fee_pots: DriveContractFeePotMethodVersions { + insert_contract_fee_pot_trees: 0, + add_to_contract_fee_pot: 0, + deduct_from_contract_fee_pot: 0, + fetch_contract_fee_pot: 0, + fetch_action_fee_multiplier: 0, + set_contract_last_fee_claim_epoch: 0, + prove_contract_fee_pots: 0, + add_estimation_costs_for_contract_fee_pot_update: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs index ba76f654b16..886410121d3 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs @@ -49,6 +49,7 @@ pub struct DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions pub identity_update_transition: FeatureVersion, pub identity_key_limits_update_transition: FeatureVersion, pub contract_user_moderation_transition: FeatureVersion, + pub contract_fee_claim_transition: FeatureVersion, pub masternode_vote_transition: FeatureVersion, pub bump_identity_data_contract_nonce: FeatureVersion, pub bump_identity_nonce: FeatureVersion, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs index b11caf32649..7ab6be15f10 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs @@ -37,6 +37,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V1: DriveStateTransitionMethodV identity_update_transition: 0, identity_key_limits_update_transition: 0, contract_user_moderation_transition: 0, + contract_fee_claim_transition: 0, masternode_vote_transition: 0, bump_identity_data_contract_nonce: 0, bump_identity_nonce: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs index 8830a432d5b..5fb33d6d22c 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs @@ -38,6 +38,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V2: DriveStateTransitionMethodV identity_update_transition: 1, //changed identity_key_limits_update_transition: 0, contract_user_moderation_transition: 0, + contract_fee_claim_transition: 0, masternode_vote_transition: 0, bump_identity_data_contract_nonce: 0, bump_identity_nonce: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs index 17552de6a0a..817cdc582c9 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs @@ -42,6 +42,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V3: DriveStateTransitionMethodV identity_update_transition: 1, identity_key_limits_update_transition: 0, contract_user_moderation_transition: 0, + contract_fee_claim_transition: 0, masternode_vote_transition: 0, bump_identity_data_contract_nonce: 0, bump_identity_nonce: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs index bb995c12168..b4e6636725c 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs @@ -42,6 +42,7 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4: DriveStateTransitionMethodV identity_update_transition: 1, identity_key_limits_update_transition: 0, contract_user_moderation_transition: 0, + contract_fee_claim_transition: 0, masternode_vote_transition: 0, bump_identity_data_contract_nonce: 0, bump_identity_nonce: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs index 4c3c20a4d93..170720f185c 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs @@ -40,6 +40,7 @@ pub struct DriveVerifyContractGroupMethodVersions { pub struct DriveVerifyContractModerationMethodVersions { pub verify_contract_moderation_status: FeatureVersion, pub verify_contract_moderation_entries: FeatureVersion, + pub verify_contract_fee_pots: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs index b0051a18d8b..460c545daa2 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs @@ -86,6 +86,7 @@ pub const DRIVE_VERIFY_METHOD_VERSIONS_V1: DriveVerifyMethodVersions = DriveVeri contract_moderation: DriveVerifyContractModerationMethodVersions { verify_contract_moderation_status: 0, verify_contract_moderation_entries: 0, + verify_contract_fee_pots: 0, }, token: DriveVerifyTokenMethodVersions { verify_token_balances_for_identity_ids: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/v9.rs b/packages/rs-platform-version/src/version/drive_versions/v9.rs index ddd3547f352..7fca887f026 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v9.rs @@ -138,7 +138,7 @@ pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { add_prefunded_specialized_balance_operations: 1, deduct_from_prefunded_specialized_balance: 1, deduct_from_prefunded_specialized_balance_operations: 0, - estimated_cost_for_prefunded_specialized_balance_update: 0, + estimated_cost_for_prefunded_specialized_balance_update: 1, // changed: the prefunded balances layer holds three trees, the voting balances and the two contract fee pot trees empty_prefunded_specialized_balance: 0, }, group: DRIVE_GROUP_METHOD_VERSIONS_V1, diff --git a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs index 8149f46102b..0a8ade18b58 100644 --- a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs +++ b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs @@ -13,3 +13,7 @@ pub const IDENTITY_KEY_LIMITS_UPDATE_INITIAL_PROTOCOL_VERSION: ProtocolVersion = /// `ContractUserModeration` (banning and suspending identities on a moderated data contract) /// activates with protocol version 14. pub const CONTRACT_USER_MODERATION_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; + +/// The protocol version that introduces document action fees and the `ContractFeeClaim` state +/// transition, which pays out the fee pots they collect in. +pub const CONTRACT_FEE_CLAIM_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 14; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 6b0a0ada5bd..22ea238f65a 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -352,6 +352,32 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// adds the `moderation` method table; the verify and /// query tables gain the status and entries methods. /// +/// 17. **Document action fees and the contract fee claim**: a document type +/// may charge a fixed fee in credits for an action on one of its documents +/// (the `actionFees` keyword of the v3 document meta-schema, read by +/// `try_from_schema` 3), split between the contract's owner pot and its +/// moderators pot and priced as written or scaled by the fee multiplier of +/// the epoch. The fees of a document type never change (document type +/// `validate_update` 1), and a `moderators` part needs declared moderation +/// (contract create and update basic structure 2). Whoever pays the gas +/// pays the fee, the contract owner never into their own owner pot, and +/// only for an action that executes: `validate_fees_of_event` 1 and +/// `execute_event` 1 (`DRIVE_ABCI_METHOD_VERSIONS_V10`) settle the payer +/// and move the credits with the batch's own operations, outside the fee. +/// `DRIVE_CONTRACT_METHOD_VERSIONS_V4` gains the `fee_pots` method table: +/// the pots are sum items under two sum trees of the prefunded specialized +/// balances (`[40, 64]` and `[40, 192]`), which `create_initial_state_structure` +/// 4 and the upgrade to this version create, so they stay inside the total +/// credits the platform checks every block, and the epoch each pot was +/// last claimed in is an item of the contract's other tree (`32` and `96`). +/// `ContractFeeClaim` (state transition type 25, gated by +/// `CONTRACT_FEE_CLAIM_INITIAL_PROTOCOL_VERSION`) pays a pot out, at most +/// once per epoch each: the owner pot to the contract owner, the moderators +/// pot in equal shares to the moderation team, what the split leaves over +/// staying in the pot. `DRIVE_ABCI_VALIDATION_VERSIONS_V10` turns its gates +/// on, `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4` adds its converter, and +/// the verify table gains `verify_contract_fee_pots`. +/// /// * `ShieldFromIdentity` (state transition type 21) activates: /// `SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION = 14` gates it in /// `is_allowed`, and `DRIVE_ABCI_VALIDATION_VERSIONS_V10` is the first diff --git a/packages/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index 0799f74ef1b..8d9a015995c 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -8,6 +8,7 @@ pub use address_inputs::fetch_inputs_with_nonce; pub mod broadcast; pub(crate) mod broadcast_identity; pub mod broadcast_request; +pub mod contract_fee_claim; pub mod contract_user_moderation; #[cfg(feature = "shielded")] pub mod identity_create_from_shielded_pool; diff --git a/packages/rs-sdk/src/platform/transition/contract_fee_claim.rs b/packages/rs-sdk/src/platform/transition/contract_fee_claim.rs new file mode 100644 index 00000000000..e08059eef99 --- /dev/null +++ b/packages/rs-sdk/src/platform/transition/contract_fee_claim.rs @@ -0,0 +1,148 @@ +//! Pay out the fee pots of a data contract (protocol version 14). +//! +//! A document type may charge a fixed fee in credits for actions on its documents (the +//! `actionFees` keyword). The `owner` parts collect in the contract's owner pot and the +//! `moderators` parts in its moderators pot. A [`ContractFeeClaimTransition`], signed by a +//! CRITICAL authentication key, pays a pot out: the owner pot to the contract owner, who alone +//! may claim it, and the moderators pot in equal shares to the contract's moderation team, any +//! member of which may claim it. A pot is paid out at most once per epoch. +//! +//! ```ignore +//! let claim = moderator_identity +//! .claim_contract_fees(&sdk, contract_id, ContractFeePot::Moderators, None, signer, None) +//! .await?; +//! ``` + +use dpp::block::epoch::EpochIndex; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::{Identity, IdentityPublicKey}; +use dpp::platform_value::Identifier; +use dpp::state_transition::contract_fee_claim_transition::methods::ContractFeeClaimTransitionMethodsV0; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use dpp::state_transition::proof_result::StateTransitionProofResult; +use std::collections::BTreeMap; + +use crate::platform::transition::broadcast::BroadcastStateTransition; +use crate::platform::transition::contract_user_moderation::{ + ensure_provider_resolves_contract, signing_key_for_moderation, +}; +use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::validation::ensure_valid_state_transition_structure; +use crate::{Error, Sdk}; + +use super::waitable::Waitable; + +/// A contract fee pot after a claim, as the proof of the claim shows it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimedContractFees { + /// The contract whose pot was paid out + pub contract_id: Identifier, + /// The pot that was paid out + pub pot: ContractFeePot, + /// The epoch the pot was last claimed in: the epoch of this claim, unless the pot was + /// claimed again since + pub last_claim_epoch: EpochIndex, + /// The credits left in the pot: what an equal split left over, and any fee collected + /// since the claim + pub remaining_credits: Credits, + /// The balance, after the claim, of every identity a payout of the pot goes to + pub balances: BTreeMap, +} + +impl TryFrom for ClaimedContractFees { + type Error = Error; + + fn try_from(value: StateTransitionProofResult) -> Result { + match value { + StateTransitionProofResult::VerifiedContractFeeClaim( + contract_id, + pot, + last_claim_epoch, + remaining_credits, + balances, + ) => Ok(Self { + contract_id, + pot, + last_claim_epoch, + remaining_credits, + balances, + }), + other => Err(Error::Generic(format!( + "expected a contract fee claim proof result, got {other}" + ))), + } + } +} + +#[async_trait::async_trait] +pub trait ClaimContractFees: Waitable { + /// Pays out `pot` of `contract_id`, signed by this identity, and resolves once the pot and + /// the balances it paid are proved. The contract owner claims the owner pot; any member of + /// the contract's moderation team claims the moderators pot, for the whole team. If + /// `signing_key_to_use` is not set, the first CRITICAL authentication key without contract + /// bounds that the signer can sign with is used. + /// + /// The proof shows the pot and the balances, not this exact transition (the nonce is not + /// stored), so it is waited for as affected state. + async fn claim_contract_fees + Send>( + &self, + sdk: &Sdk, + contract_id: Identifier, + pot: ContractFeePot, + signing_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result; +} + +#[async_trait::async_trait] +impl ClaimContractFees for Identity { + async fn claim_contract_fees + Send>( + &self, + sdk: &Sdk, + contract_id: Identifier, + pot: ContractFeePot, + signing_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result { + let signing_key_id = match signing_key_to_use { + Some(key) => key.id(), + None => signing_key_for_moderation(self, &signer)?, + }; + + // The proof of a claim covers the balance of every identity the pot pays, and the + // verifier reads who they are from the contract through the context provider. The + // moderation team can change by a contract update, and a verifier working from a copy + // that names the old team would ask for other balances than the node proved and refuse + // a claim that executed and was paid for. So the contract is fetched again, whatever + // copy the provider holds. + ensure_provider_resolves_contract(sdk, contract_id, true).await?; + + let identity_contract_nonce = sdk + .get_identity_contract_nonce(self.id(), contract_id, true, settings) + .await?; + let user_fee_increase = settings.and_then(|settings| settings.user_fee_increase); + let state_transition = ContractFeeClaimTransition::try_from_identity_with_signer( + self, + &signing_key_id, + contract_id, + pot, + identity_contract_nonce, + user_fee_increase.unwrap_or_default(), + &signer, + sdk.version(), + None, + ) + .await?; + ensure_valid_state_transition_structure(&state_transition, sdk.version())?; + + state_transition + .broadcast_and_wait_for_affected_state(sdk, settings) + .await + } +} diff --git a/packages/rs-sdk/src/platform/transition/contract_user_moderation.rs b/packages/rs-sdk/src/platform/transition/contract_user_moderation.rs index f14437c9bd3..62f4e38fc27 100644 --- a/packages/rs-sdk/src/platform/transition/contract_user_moderation.rs +++ b/packages/rs-sdk/src/platform/transition/contract_user_moderation.rs @@ -216,24 +216,7 @@ impl ModerateContractUser for Identity { // does not have the contract (the lists never change, so whatever copy it holds will // do) is the contract fetched and registered with it. if matches!(action, ContractUserModerationAction::Ban { .. }) { - if let Some(provider) = sdk.context_provider() { - let resolved = provider - .get_data_contract(&contract_id, sdk.version()) - .ok() - .flatten() - .is_some(); - if !resolved { - let contract = - DataContract::fetch(sdk, contract_id) - .await? - .ok_or_else(|| { - Error::Generic(format!( - "data contract {contract_id} does not exist" - )) - })?; - provider.register_data_contract(Arc::new(contract)); - } - } + ensure_provider_resolves_contract(sdk, contract_id, false).await?; } let identity_contract_nonce = sdk @@ -260,9 +243,39 @@ impl ModerateContractUser for Identity { } } +/// Makes sure the SDK's context provider can resolve `contract_id`, which the verifier of an +/// execution proof reads the contract through. A provider that can not resolve the contract +/// would refuse a result the network already accepted, so this runs before the nonce is taken +/// and anything is signed or paid for. +/// +/// With `refresh` the contract is fetched and registered even when the provider already holds +/// a copy, for a proof that depends on a part of the contract an update may have changed. +/// Without it, a copy the provider holds will do. +pub(super) async fn ensure_provider_resolves_contract( + sdk: &Sdk, + contract_id: Identifier, + refresh: bool, +) -> Result<(), Error> { + let Some(provider) = sdk.context_provider() else { + return Ok(()); + }; + let resolved = provider + .get_data_contract(&contract_id, sdk.version()) + .ok() + .flatten() + .is_some(); + if refresh || !resolved { + let contract = DataContract::fetch(sdk, contract_id) + .await? + .ok_or_else(|| Error::Generic(format!("data contract {contract_id} does not exist")))?; + provider.register_data_contract(Arc::new(contract)); + } + Ok(()) +} + /// The first enabled CRITICAL authentication key without contract bounds (a bound key may /// only sign batches) that the signer can sign with. -fn signing_key_for_moderation>( +pub(super) fn signing_key_for_moderation>( identity: &Identity, signer: &S, ) -> Result { diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 5b8e0db2516..7c69c181253 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -92,9 +92,12 @@ use dpp::consensus::basic::contract_group::{ }; use dpp::consensus::basic::contract_moderation::{ ContractModerationReasonTooLongError, ContractModerationSelfTargetError, + DocumentActionFeesWithoutModerationError, InvalidContractModerationConfigError, }; use dpp::consensus::state::contract_moderation::{ + ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, + ContractFeesNothingToClaimError, ContractModerationNotEnabledError, ContractModerationTargetNotAllowedError, ContractModerationCounterpartyBarredError, ContractModerationTargetNotFoundError, ContractModeratorIdentityNotFoundError, @@ -620,6 +623,15 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::ContractModerationCounterpartyBarredError(e) => { generic_consensus_error!(ContractModerationCounterpartyBarredError, e).into() } + StateError::ContractFeesAlreadyClaimedThisEpochError(e) => { + generic_consensus_error!(ContractFeesAlreadyClaimedThisEpochError, e).into() + } + StateError::ContractFeesNothingToClaimError(e) => { + generic_consensus_error!(ContractFeesNothingToClaimError, e).into() + } + StateError::ContractFeeClaimNotAllowedError(e) => { + generic_consensus_error!(ContractFeeClaimNotAllowedError, e).into() + } } } @@ -1188,6 +1200,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::ContractModerationSelfTargetError(e) => { generic_consensus_error!(ContractModerationSelfTargetError, e).into() } + BasicError::DocumentActionFeesWithoutModerationError(e) => { + generic_consensus_error!(DocumentActionFeesWithoutModerationError, e).into() + } BasicError::ContractModerationReasonTooLongError(e) => { generic_consensus_error!(ContractModerationReasonTooLongError, e).into() } diff --git a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs index 5da15ad0033..b50aea34cbf 100644 --- a/packages/wasm-dpp/src/state_transition/state_transition_factory.rs +++ b/packages/wasm-dpp/src/state_transition/state_transition_factory.rs @@ -95,6 +95,9 @@ impl StateTransitionFactoryWasm { StateTransition::ContractUserModeration(_) => Err(JsValue::from_str( "contract user moderation transitions are not supported in wasm-dpp StateTransitionFactory; use wasm-dpp2", )), + StateTransition::ContractFeeClaim(_) => Err(JsValue::from_str( + "contract fee claim transitions are not supported in wasm-dpp StateTransitionFactory; use wasm-dpp2", + )), }, Err(dpp::ProtocolError::StateTransitionError(e)) => match e { StateTransitionError::InvalidStateTransitionError { diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index 1985a429a66..0de257becd7 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -17,6 +17,7 @@ pub use model::{ DataContractJSONJs, DataContractObjectJs, DataContractWasm, tokens_configuration_from_js_value, }; pub use transitions::create::DataContractCreateTransitionWasm; +pub use transitions::fee_claim::{ContractFeeClaimWasm, contract_fee_pot_from_str}; pub use transitions::update::DataContractUpdateTransitionWasm; pub use transitions::user_moderation::{ ContractModerationReasonJs, ContractUserModerationWasm, moderation_action_from_parts, diff --git a/packages/wasm-dpp2/src/data_contract/transitions/fee_claim.rs b/packages/wasm-dpp2/src/data_contract/transitions/fee_claim.rs new file mode 100644 index 00000000000..c4aeddc29a2 --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/transitions/fee_claim.rs @@ -0,0 +1,307 @@ +use crate::error::{WasmDppError, WasmDppResult}; +use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; +use crate::impl_wasm_conversions_inner; +use crate::impl_wasm_type_info; +use crate::state_transitions::StateTransitionWasm; +use crate::utils::{try_from_options, try_to_u16, try_to_u32, try_to_u64}; +use dpp::data_contract::document_type::action_fees::ContractFeePot; +use dpp::platform_value::BinaryData; +use dpp::platform_value::string_encoding::Encoding::{Base64, Hex}; +use dpp::platform_value::string_encoding::{decode, encode}; +use dpp::prelude::UserFeeIncrease; +use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; +use dpp::state_transition::contract_fee_claim_transition::ContractFeeClaimTransition; +use dpp::state_transition::contract_fee_claim_transition::accessors::ContractFeeClaimTransitionAccessorsV0; +use dpp::state_transition::contract_fee_claim_transition::v0::ContractFeeClaimTransitionV0; +use dpp::state_transition::{ + StateTransition, StateTransitionHasUserFeeIncrease, StateTransitionIdentitySigned, + StateTransitionOwned, StateTransitionSingleSigned, +}; +use serde::Deserialize; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const CONTRACT_FEE_CLAIM_TS: &str = r#" +/** + * Pays out one of the two fee pots a data contract's document action fees collect in (protocol + * version 14). The owner pot goes to the contract owner, who alone may claim it; the moderators + * pot is split equally between the contract's moderation team, and any member may claim it. A + * pot is paid out at most once per epoch. Signed with a CRITICAL authentication key, under the + * signer's contract-scoped nonce. + */ +export interface ContractFeeClaimTransitionOptions { + /** The claimant that signs */ + ownerId: IdentifierLike; + /** The contract whose pot is paid out */ + dataContractId: IdentifierLike; + /** The signer's nonce for the contract */ + identityContractNonce: bigint; + /** The pot that is paid out */ + pot: "owner" | "moderators"; + userFeeIncrease?: number; +} + +/** + * ContractFeeClaim serialized as a plain object. + */ +export interface ContractFeeClaimObject { + $formatVersion: string; + ownerId: Uint8Array; + dataContractId: Uint8Array; + identityContractNonce: bigint; + pot: string; + userFeeIncrease: number; + signature?: Uint8Array; + signaturePublicKeyId?: number; +} + +/** + * ContractFeeClaim serialized as JSON. + */ +export interface ContractFeeClaimJSON { + $formatVersion: string; + ownerId: string; + dataContractId: string; + identityContractNonce: number | string; + pot: string; + userFeeIncrease: number; + signature?: string; + signaturePublicKeyId?: number; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "ContractFeeClaimTransitionOptions")] + pub type ContractFeeClaimTransitionOptionsJs; + + #[wasm_bindgen(typescript_type = "ContractFeeClaimObject")] + pub type ContractFeeClaimObjectJs; + + #[wasm_bindgen(typescript_type = "ContractFeeClaimJSON")] + pub type ContractFeeClaimJSONJs; +} + +/// Serde struct for the primitive fields of the options +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ContractFeeClaimOptionsInput { + identity_contract_nonce: u64, + pot: String, + /// `undefined` reaches serde as a unit value, so the fee is read as an option and defaulted + #[serde(default)] + user_fee_increase: Option, +} + +#[wasm_bindgen(js_name = "ContractFeeClaim")] +#[derive(Clone, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct ContractFeeClaimWasm(ContractFeeClaimTransition); + +impl From for ContractFeeClaimWasm { + fn from(val: ContractFeeClaimTransition) -> Self { + ContractFeeClaimWasm(val) + } +} + +impl From for ContractFeeClaimTransition { + fn from(val: ContractFeeClaimWasm) -> Self { + val.0 + } +} + +/// The pot for its name, as the wire shape spells it. +pub fn contract_fee_pot_from_str(pot: &str) -> WasmDppResult { + match pot { + "owner" => Ok(ContractFeePot::Owner), + "moderators" => Ok(ContractFeePot::Moderators), + other => Err(WasmDppError::invalid_argument(format!( + "unknown fee pot `{other}`: expected owner or moderators" + ))), + } +} + +#[wasm_bindgen(js_class = ContractFeeClaim)] +impl ContractFeeClaimWasm { + #[wasm_bindgen(constructor)] + pub fn constructor( + options: ContractFeeClaimTransitionOptionsJs, + ) -> WasmDppResult { + // Extract complex types first (borrows &options) + let owner_id: IdentifierWasm = try_from_options(&options, "ownerId")?; + let data_contract_id: IdentifierWasm = try_from_options(&options, "dataContractId")?; + + // Deserialize primitive fields via serde last (consumes options) + let input: ContractFeeClaimOptionsInput = serde_wasm_bindgen::from_value(options.into()) + .map_err(|e| WasmDppError::invalid_argument(e.to_string()))?; + + let pot = contract_fee_pot_from_str(&input.pot)?; + + Ok(ContractFeeClaimWasm(ContractFeeClaimTransition::V0( + ContractFeeClaimTransitionV0 { + owner_id: owner_id.into(), + data_contract_id: data_contract_id.into(), + identity_contract_nonce: input.identity_contract_nonce, + pot, + user_fee_increase: input.user_fee_increase.unwrap_or_default(), + signature_public_key_id: 0, + signature: Default::default(), + }, + ))) + } + + #[wasm_bindgen(js_name = "toBytes")] + pub fn to_bytes(&self) -> WasmDppResult> { + Ok(self.0.serialize_to_bytes()?) + } + + #[wasm_bindgen(js_name = "toHex")] + pub fn to_hex(&self) -> WasmDppResult { + let bytes = self.0.serialize_to_bytes()?; + Ok(encode(bytes.as_slice(), Hex)) + } + + #[wasm_bindgen(js_name = "toBase64")] + pub fn to_base64(&self) -> WasmDppResult { + let bytes = self.0.serialize_to_bytes()?; + Ok(encode(bytes.as_slice(), Base64)) + } + + #[wasm_bindgen(js_name = "fromBytes")] + pub fn from_bytes(bytes: Vec) -> WasmDppResult { + let rs_transition = + ContractFeeClaimTransition::deserialize_from_bytes_untrusted(bytes.as_slice())?; + + Ok(ContractFeeClaimWasm(rs_transition)) + } + + #[wasm_bindgen(js_name = "fromHex")] + pub fn from_hex(hex: String) -> WasmDppResult { + let bytes = + decode(hex.as_str(), Hex).map_err(|e| WasmDppError::serialization(e.to_string()))?; + ContractFeeClaimWasm::from_bytes(bytes) + } + + #[wasm_bindgen(js_name = "fromBase64")] + pub fn from_base64(base64: String) -> WasmDppResult { + let bytes = decode(base64.as_str(), Base64) + .map_err(|e| WasmDppError::serialization(e.to_string()))?; + ContractFeeClaimWasm::from_bytes(bytes) + } + + #[wasm_bindgen(setter = "ownerId")] + pub fn set_owner_id(&mut self, owner_id: IdentifierLikeJs) -> WasmDppResult<()> { + self.0.set_owner_id(owner_id.try_into()?); + Ok(()) + } + + #[wasm_bindgen(setter = "dataContractId")] + pub fn set_data_contract_id(&mut self, id: IdentifierLikeJs) -> WasmDppResult<()> { + self.0.set_data_contract_id(id.try_into()?); + Ok(()) + } + + #[wasm_bindgen(setter = "identityContractNonce")] + pub fn set_identity_contract_nonce(&mut self, nonce: &js_sys::BigInt) -> WasmDppResult<()> { + self.0 + .set_identity_contract_nonce(try_to_u64(nonce, "identityContractNonce")?); + Ok(()) + } + + #[wasm_bindgen(setter = "pot")] + pub fn set_pot(&mut self, pot: String) -> WasmDppResult<()> { + self.0.set_pot(contract_fee_pot_from_str(&pot)?); + Ok(()) + } + + #[wasm_bindgen(setter = "signature")] + pub fn set_signature(&mut self, signature: Vec) { + self.0.set_signature_bytes(signature) + } + + #[wasm_bindgen(setter = "signaturePublicKeyId")] + pub fn set_signature_public_key_id( + &mut self, + #[wasm_bindgen(js_name = "publicKeyId")] public_key_id: &js_sys::Number, + ) -> WasmDppResult<()> { + self.0 + .set_signature_public_key_id(try_to_u32(public_key_id, "signaturePublicKeyId")?); + Ok(()) + } + + #[wasm_bindgen(setter = "userFeeIncrease")] + pub fn set_user_fee_increase(&mut self, amount: &js_sys::Number) -> WasmDppResult<()> { + self.0 + .set_user_fee_increase(try_to_u16(amount, "userFeeIncrease")?); + Ok(()) + } + + #[wasm_bindgen(getter = "signature")] + pub fn signature(&self) -> Vec { + self.0.signature().to_vec() + } + + #[wasm_bindgen(getter = "signaturePublicKeyId")] + pub fn signature_public_key_id(&self) -> u32 { + self.0.signature_public_key_id() + } + + #[wasm_bindgen(getter = "userFeeIncrease")] + pub fn user_fee_increase(&self) -> u16 { + self.0.user_fee_increase() + } + + #[wasm_bindgen(getter = "ownerId")] + pub fn owner_id(&self) -> IdentifierWasm { + self.0.owner_id().into() + } + + #[wasm_bindgen(getter = "dataContractId")] + pub fn data_contract_id(&self) -> IdentifierWasm { + self.0.data_contract_id().into() + } + + #[wasm_bindgen(getter = "identityContractNonce")] + pub fn identity_contract_nonce(&self) -> u64 { + self.0.identity_contract_nonce() + } + + /// The pot that is paid out: owner or moderators + #[wasm_bindgen(getter = "pot")] + pub fn pot(&self) -> String { + self.0.pot().to_string() + } + + #[wasm_bindgen(js_name = "toStateTransition")] + pub fn to_state_transition(&self) -> StateTransitionWasm { + StateTransitionWasm::from(StateTransition::from(self.0.clone())) + } + + #[wasm_bindgen(js_name = "fromStateTransition")] + pub fn from_state_transition(st: &StateTransitionWasm) -> WasmDppResult { + let rs_st: StateTransition = st.clone().into(); + + match rs_st { + StateTransition::ContractFeeClaim(st) => Ok(ContractFeeClaimWasm(st)), + _ => Err(WasmDppError::invalid_argument( + "Invalid state transition type", + )), + } + } +} + +impl ContractFeeClaimWasm { + pub fn set_signature_binary_data(&mut self, data: BinaryData) { + self.0.set_signature(data) + } +} + +impl_wasm_conversions_inner!( + ContractFeeClaimWasm, + ContractFeeClaimTransition, + ContractFeeClaim, + ContractFeeClaimObjectJs, + ContractFeeClaimJSONJs +); + +impl_wasm_type_info!(ContractFeeClaimWasm, ContractFeeClaim); diff --git a/packages/wasm-dpp2/src/data_contract/transitions/mod.rs b/packages/wasm-dpp2/src/data_contract/transitions/mod.rs index 72ab8794bfb..0c782b270e2 100644 --- a/packages/wasm-dpp2/src/data_contract/transitions/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/transitions/mod.rs @@ -1,3 +1,4 @@ pub mod create; +pub mod fee_claim; pub mod update; pub mod user_moderation; diff --git a/packages/wasm-dpp2/src/lib.rs b/packages/wasm-dpp2/src/lib.rs index 1996a2aa1b6..650662a4178 100644 --- a/packages/wasm-dpp2/src/lib.rs +++ b/packages/wasm-dpp2/src/lib.rs @@ -41,9 +41,10 @@ pub use identity::signer::IdentitySignerWasm; pub use identity::transitions::pooling::PoolingWasm; pub use data_contract::{ - ContractBoundsWasm, ContractUserModerationWasm, DataContractCreateTransitionWasm, - DataContractUpdateTransitionWasm, DataContractWasm, DocumentPropertyReferenceArrayJs, - DocumentPropertyReferenceMapJs, DocumentWasm, tokens_configuration_from_js_value, + ContractBoundsWasm, ContractFeeClaimWasm, ContractUserModerationWasm, + DataContractCreateTransitionWasm, DataContractUpdateTransitionWasm, DataContractWasm, + DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, DocumentWasm, + tokens_configuration_from_js_value, }; pub use epoch::*; pub use group::*; diff --git a/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs b/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs index 1f39d926c40..cda6a045da5 100644 --- a/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs +++ b/packages/wasm-dpp2/src/state_transitions/base/state_transition.rs @@ -17,15 +17,16 @@ use dpp::prelude::Identifier; use dpp::prelude::{IdentityNonce, UserFeeIncrease}; use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable, Signable}; use dpp::state_transition::StateTransition::{ - Batch, ContractUserModeration, DataContractCreate, DataContractUpdate, IdentityCreditTransfer, - IdentityCreditWithdrawal, IdentityKeyLimitsUpdate, IdentityUpdate, MasternodeVote, - ShieldFromIdentity, + Batch, ContractFeeClaim, ContractUserModeration, DataContractCreate, DataContractUpdate, + IdentityCreditTransfer, IdentityCreditWithdrawal, IdentityKeyLimitsUpdate, IdentityUpdate, + MasternodeVote, ShieldFromIdentity, }; use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::batch_transition::batched_transition::BatchedTransition; use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::contract_fee_claim_transition::accessors::ContractFeeClaimTransitionAccessorsV0; use dpp::state_transition::contract_user_moderation_transition::accessors::ContractUserModerationTransitionAccessorsV0; use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; use dpp::state_transition::data_contract_create_transition::accessors::DataContractCreateTransitionAccessorsV0; @@ -219,6 +220,17 @@ impl StateTransitionWasm { st.verify_public_key_is_enabled(&public_key.clone().into())?; } + ContractFeeClaim(st) => { + st.verify_public_key_level_and_purpose( + &public_key.clone().into(), + StateTransitionSigningOptions { + allow_signing_with_any_security_level, + allow_signing_with_any_purpose, + }, + )?; + + st.verify_public_key_is_enabled(&public_key.clone().into())?; + } IdentityCreditTransfer(st) => { st.verify_public_key_level_and_purpose( &public_key.clone().into(), @@ -355,6 +367,7 @@ impl StateTransitionWasm { IdentityTopUpFromShieldedPool(_) => 22, IdentityKeyLimitsUpdate(_) => 23, ContractUserModeration(_) => 24, + ContractFeeClaim(_) => 25, } } @@ -417,6 +430,7 @@ impl StateTransitionWasm { DataContractCreate(_) => None, DataContractUpdate(contract_update) => Some(contract_update.identity_contract_nonce()), ContractUserModeration(st) => Some(st.identity_contract_nonce()), + ContractFeeClaim(st) => Some(st.identity_contract_nonce()), Batch(batch) => match batch { BatchTransition::V0(v0) => Some(v0.transitions.first()?.identity_contract_nonce()), BatchTransition::V1(v1) => match v1.transitions.first()? { @@ -465,6 +479,7 @@ impl StateTransitionWasm { IdentityUpdate(identity_update) => Some(identity_update.nonce()), IdentityKeyLimitsUpdate(st) => Some(st.nonce()), ContractUserModeration(_) => None, + ContractFeeClaim(_) => None, IdentityCreditTransfer(credit_transfer) => Some(credit_transfer.nonce()), MasternodeVote(mn_vote) => Some(mn_vote.nonce()), IdentityCreditTransferToAddresses(ct) => Some(ct.nonce()), @@ -596,6 +611,11 @@ impl StateTransitionWasm { self.0 = ContractUserModeration(st); } + ContractFeeClaim(mut st) => { + st.set_owner_id(owner_id); + + self.0 = ContractFeeClaim(st); + } IdentityCreditTransfer(mut credit_transfer) => { credit_transfer.set_identity_id(owner_id); @@ -676,6 +696,11 @@ impl StateTransitionWasm { st.into() } + ContractFeeClaim(mut st) => { + st.set_identity_contract_nonce(nonce); + + st.into() + } StateTransition::IdentityCreate(_) => { return Err(WasmDppError::invalid_argument( "Cannot set identity contract nonce for Identity Create", @@ -798,6 +823,11 @@ impl StateTransitionWasm { "Cannot set identity nonce for Contract User Moderation", )); } + ContractFeeClaim(_) => { + return Err(WasmDppError::invalid_argument( + "Cannot set identity nonce for Contract Fee Claim", + )); + } IdentityCreditTransfer(mut credit_transfer) => { credit_transfer.set_nonce(nonce); diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs index e08b1244bc7..32b351895e5 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/convert.rs @@ -6,7 +6,10 @@ use super::address_funds::{ VerifiedAddressInfosWasm, VerifiedIdentityFullWithAddressInfosWasm, VerifiedIdentityWithAddressInfosWasm, }; -use super::data_contract::{VerifiedContractModerationListStatusesWasm, VerifiedDataContractWasm}; +use super::data_contract::{ + VerifiedContractFeeClaimWasm, VerifiedContractModerationListStatusesWasm, + VerifiedDataContractWasm, +}; use super::document::VerifiedDocumentsWasm; use super::helpers::{ action_status_to_string, build_address_infos_map, build_nullifier_map, doc_to_wasm, @@ -71,7 +74,8 @@ export type StateTransitionProofResultType = | VerifiedShieldedNullifiersWithAddressInfos | VerifiedShieldedNullifiersWithWithdrawalDocument | VerifiedIdentityWithShieldedNullifiers - | VerifiedContractModerationListStatuses; + | VerifiedContractModerationListStatuses + | VerifiedContractFeeClaim; "#; #[wasm_bindgen] @@ -359,6 +363,28 @@ pub fn convert_proof_result( } .into() } + + StateTransitionProofResult::VerifiedContractFeeClaim( + contract_id, + pot, + last_claim_epoch, + remaining_credits, + balances, + ) => { + let balances = Map::from_entries(balances.into_iter().map(|(id, credits)| { + let key: JsValue = IdentifierWasm::from(id).to_base58().into(); + let val: JsValue = BigInt::from(credits).into(); + (key, val) + })); + VerifiedContractFeeClaimWasm { + contract_id: contract_id.into(), + pot: pot.to_string(), + last_claim_epoch, + remaining_credits, + balances, + } + .into() + } }; Ok(js_value.into()) diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs index 307de1bd37c..6e20ca6c82f 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs @@ -9,7 +9,9 @@ use crate::data_contract::{ }; use crate::error::{WasmDppError, WasmDppResult}; use crate::impl_wasm_type_info; +use crate::serialization::conversions::normalize_js_value_for_json; use dpp::data_contract::config::moderation::ContractModerationReason; +use js_sys::{BigInt, Map}; use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::*; @@ -204,3 +206,94 @@ impl_wasm_type_info!( VerifiedContractModerationListStatusesWasm, VerifiedContractModerationListStatuses ); + +/// `VerifiedContractFeeClaim` proof-result wrapper: the pot a contract fee claim paid out (the +/// contract, the pot, the epoch the pot was last claimed in, the credits left in it) and the +/// balance of every identity the claim paid, after the claim. A pot is paid out at most once +/// per epoch, so while `lastClaimEpoch` is the epoch of the claim the proof is of that claim. +#[wasm_bindgen(js_name = "VerifiedContractFeeClaim")] +#[derive(Clone)] +pub struct VerifiedContractFeeClaimWasm { + #[wasm_bindgen(getter_with_clone, js_name = "contractId")] + pub contract_id: IdentifierWasm, + /// The pot that was paid out: `owner` or `moderators` + #[wasm_bindgen(getter_with_clone)] + pub pot: String, + /// The epoch the pot was last claimed in: this claim's own, unless the pot was claimed + /// again since + #[wasm_bindgen(js_name = "lastClaimEpoch")] + pub last_claim_epoch: u16, + pub(super) remaining_credits: u64, + pub(super) balances: Map, // Map +} + +#[wasm_bindgen(js_class = VerifiedContractFeeClaim)] +impl VerifiedContractFeeClaimWasm { + /// The credits left in the pot after the claim + #[wasm_bindgen(getter = "remainingCredits")] + pub fn remaining_credits(&self) -> JsValue { + BigInt::from(self.remaining_credits).into() + } + + /// The balance of every identity the claim paid, after the claim + #[wasm_bindgen(getter)] + pub fn balances(&self) -> Map { + self.balances.clone() + } + + #[wasm_bindgen(js_name = toObject)] + pub fn to_object(&self) -> WasmDppResult { + Ok(js_obj(&[ + ("contractId", self.contract_id.into()), + ("pot", JsValue::from_str(&self.pot)), + ( + "lastClaimEpoch", + JsValue::from_f64(self.last_claim_epoch as f64), + ), + ( + "remainingCredits", + BigInt::from(self.remaining_credits).into(), + ), + ("balances", self.balances.clone().into()), + ])) + } + + /// Returns a `JSON.stringify`-friendly form: the `balances` `Map` is normalised to a plain + /// object so its entries survive serialisation (otherwise `JSON.stringify({balances: })` + /// produces `{"balances":{}}`). + #[wasm_bindgen(js_name = toJSON)] + pub fn to_json(&self) -> WasmDppResult { + Ok(js_obj(&[ + ( + "contractId", + JsValue::from_str(&self.contract_id.to_base58()), + ), + ("pot", JsValue::from_str(&self.pot)), + ( + "lastClaimEpoch", + JsValue::from_f64(self.last_claim_epoch as f64), + ), + ( + "remainingCredits", + json_safe_credits(self.remaining_credits), + ), + ( + "balances", + normalize_js_value_for_json(&self.balances.clone().into())?, + ), + ])) + } +} + +/// Credits in JSON: a number while it is exact in JavaScript, a decimal string past +/// `Number.MAX_SAFE_INTEGER`, where a number would silently round. +fn json_safe_credits(credits: u64) -> JsValue { + const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; + if credits <= MAX_SAFE_INTEGER { + JsValue::from_f64(credits as f64) + } else { + JsValue::from_str(&credits.to_string()) + } +} + +impl_wasm_type_info!(VerifiedContractFeeClaimWasm, VerifiedContractFeeClaim); diff --git a/packages/wasm-dpp2/tests/unit/ContractFeeClaimTransition.spec.ts b/packages/wasm-dpp2/tests/unit/ContractFeeClaimTransition.spec.ts new file mode 100644 index 00000000000..561a78f7215 --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/ContractFeeClaimTransition.spec.ts @@ -0,0 +1,148 @@ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; + +before(async () => { + await initWasm(); +}); + +const OWNER_ID = '11111111111111111111111111111111'; +const CONTRACT_ID = 'H2pb35GtKpjLinncBYeMsXkdDYXCbsFzzVmssce6pSJ1'; + +interface FeeClaimOptions { + pot?: 'owner' | 'moderators'; + identityContractNonce?: bigint; + userFeeIncrease?: number; +} + +function createTransition(options: FeeClaimOptions = {}) { + return new wasm.ContractFeeClaim({ + ownerId: OWNER_ID, + dataContractId: CONTRACT_ID, + identityContractNonce: options.identityContractNonce ?? BigInt(7), + pot: options.pot ?? 'owner', + userFeeIncrease: options.userFeeIncrease, + }); +} + +describe('ContractFeeClaim', () => { + describe('constructor()', () => { + it('should create a claim of the owner pot', () => { + const transition = createTransition(); + + expect(transition).to.be.an.instanceof(wasm.ContractFeeClaim); + expect(transition.pot).to.equal('owner'); + expect(transition.ownerId.toString()).to.equal(OWNER_ID); + expect(transition.dataContractId.toString()).to.equal(CONTRACT_ID); + expect(transition.identityContractNonce).to.equal(BigInt(7)); + expect(transition.userFeeIncrease).to.equal(0); + }); + + it('should create a claim of the moderators pot', () => { + const transition = createTransition({ pot: 'moderators', userFeeIncrease: 3 }); + + expect(transition.pot).to.equal('moderators'); + expect(transition.userFeeIncrease).to.equal(3); + }); + + it('should refuse an unknown pot', () => { + expect(() => createTransition({ pot: 'treasury' as never })).to.throw(); + }); + }); + + describe('toBytes() / fromBytes()', () => { + it('should round trip every pot through bytes, base64 and hex', () => { + for (const transition of [ + createTransition({ pot: 'owner' }), + createTransition({ pot: 'moderators', userFeeIncrease: 3 }), + ]) { + const bytes = transition.toBytes(); + expect(wasm.ContractFeeClaim.fromBytes(bytes).toBytes()).to.deep.equal(bytes); + expect(wasm.ContractFeeClaim.fromBase64(transition.toBase64()).toBytes()).to.deep.equal(bytes); + expect(wasm.ContractFeeClaim.fromHex(transition.toHex()).toBytes()).to.deep.equal(bytes); + } + }); + }); + + describe('setters', () => { + it('should update the nonce, the ids, the pot and the fee', () => { + const transition = createTransition(); + + transition.identityContractNonce = BigInt(8); + transition.userFeeIncrease = 2; + transition.pot = 'moderators'; + transition.ownerId = CONTRACT_ID; + transition.dataContractId = OWNER_ID; + + expect(transition.identityContractNonce).to.equal(BigInt(8)); + expect(transition.userFeeIncrease).to.equal(2); + expect(transition.pot).to.equal('moderators'); + expect(transition.ownerId.toString()).to.equal(CONTRACT_ID); + expect(transition.dataContractId.toString()).to.equal(OWNER_ID); + }); + + it('should refuse an unknown pot', () => { + const transition = createTransition(); + + expect(() => { + transition.pot = 'treasury' as never; + }).to.throw(); + }); + }); + + describe('toJSON()', () => { + it('should produce the expected JSON structure', () => { + const json = createTransition({ pot: 'moderators', userFeeIncrease: 4 }).toJSON(); + + expect(json.$formatVersion).to.equal('0'); + expect(json.ownerId).to.equal(OWNER_ID); + expect(json.dataContractId).to.equal(CONTRACT_ID); + expect(json.identityContractNonce).to.equal(7); + expect(json.pot).to.equal('moderators'); + expect(json.userFeeIncrease).to.equal(4); + expect(json.signature).to.equal(''); + expect(json.signaturePublicKeyId).to.equal(0); + }); + }); + + describe('fromJSON()', () => { + it('should restore the transition from JSON', () => { + const transition = createTransition({ pot: 'moderators', userFeeIncrease: 4 }); + + const restored = wasm.ContractFeeClaim.fromJSON(transition.toJSON()); + + expect(restored.pot).to.equal('moderators'); + expect(restored.dataContractId.toString()).to.equal(CONTRACT_ID); + expect(restored.identityContractNonce).to.equal(BigInt(7)); + expect(restored.userFeeIncrease).to.equal(4); + expect(restored.toBytes()).to.deep.equal(transition.toBytes()); + }); + }); + + describe('toObject() / fromObject()', () => { + it('should round trip through a plain object', () => { + const transition = createTransition({ pot: 'moderators' }); + + const obj = transition.toObject(); + expect(obj.$formatVersion).to.equal('0'); + expect(obj.ownerId).to.be.instanceOf(Uint8Array); + expect(obj.dataContractId.length).to.equal(32); + expect(obj.identityContractNonce).to.equal(BigInt(7)); + expect(obj.pot).to.equal('moderators'); + + const restored = wasm.ContractFeeClaim.fromObject(obj); + expect(restored.pot).to.equal('moderators'); + expect(restored.toBytes()).to.deep.equal(transition.toBytes()); + }); + }); + + describe('toStateTransition() / fromStateTransition()', () => { + it('should convert to and from a generic state transition', () => { + const transition = createTransition(); + const generic = transition.toStateTransition(); + + expect(generic.actionTypeNumber).to.equal(25); + expect(generic.identityContractNonce).to.equal(BigInt(7)); + expect(wasm.ContractFeeClaim.fromStateTransition(generic).toBytes()).to.deep.equal(transition.toBytes()); + }); + }); +});