diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 8e83c3ffb7f..7c228f3fe4d 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -115,6 +115,7 @@ - [Ranked Index Examples](drive/ranked-index-examples.md) - [Time-Range Index TTL](drive/time-range-ttl.md) - [Index-Only Document Types](drive/index-only-document-types.md) +- [Compilation Readiness](drive/compilation-readiness.md) # Testing diff --git a/book/src/drive/compilation-readiness.md b/book/src/drive/compilation-readiness.md new file mode 100644 index 00000000000..f9b8491ec3f --- /dev/null +++ b/book/src/drive/compilation-readiness.md @@ -0,0 +1,88 @@ +# Compilation Readiness + +A smart contract's executable bundle does not run the moment it is accepted. Every evonode first prepares it (validates, links and compiles it locally), and the bundle activates only once enough of the network has reported that it is ready. This chapter describes the state Drive keeps for that mechanism from protocol version 17: the rounds, the reports, the cursors, the deadlines and the funds. The block event that evaluates rounds, the state transition that carries a report, the fee schedule and the classification of local failures arrive in later parts and get their own sections here. + +## Why rounds are stored under their own key + +The confirmed policy has three rules that shape the layout: + +- One pending executable version per contract. A replacement atomically cancels the previous round and its activation timer, and reports for the old bundle cannot count for the new one. +- No automatic expiry. A round that never gathers enough reports stays pending forever. +- Bounded work per block. Every step the network takes for readiness (accepting a report, validating reporters against membership, activating, cleaning up) has a fixed cap. + +A round can accumulate one report per evonode of the network, and nothing bounds that number except churn. If a replacement deleted the old round's reports in the same batch, the cost of a replacement would grow with the number of reports, and the pinned GroveDB prices a recursive subtree delete by its contents while its average-case estimator prices only the tree element. So a round lives under its own key beneath its contract, and the contract holds a pointer to the current round. Replacing a round is a pointer swap plus a fixed number of inserts and deletes; the old subtree stays on disk, unreachable through the pointer, until a bounded cleanup step drains it over as many blocks as it needs. + +## Layout + +Everything lives under the existing `Votes` root (`112`) in a new child `r`, and the funds beside the voting funds under the prefunded specialized balances root (`40`): + +```text +112 Votes + └─ r Readiness (NormalTree) + ├─ 0 contracts (NormalTree) key: contract_id (32) + │ └─ contract_id (NormalTree) + │ ├─ c current round pointer Item: round_id (32) + │ └─ round_id (NormalTree, one per open round; normally exactly one) + │ ├─ 0 round record Item: ReadinessRound (versioned, serialized) + │ ├─ 1 reports CountTree; key: pro_tx_hash (32) -> Item: ReadinessReportRecord + │ └─ 2 scan cursor Item: ReadinessScanCursor (absent when no walk is open) + ├─ 1 deadlines (NormalTree) key: encode_u64(deadline_ms) (NormalTree) + │ └─ contract_id (32) -> Item: round_id (32) + ├─ 2 evaluation cursor Item: last contract_id visited by the block event + └─ 3 retired rounds awaiting cleanup (NormalTree) + key: round_id (32) -> Item: contract_id (32) + 40 PreFundedSpecializedBalances (SumTree) + ├─ 128 voting funds (existing) + └─ 129 readiness funds (SumTree) key: fund_id (32) -> SumItem +``` + +The keys `r`, `0` to `3`, `c` and `129` are provisional: the allocation register leaves new inner tags unallocated, and they are revised, if at all, before any network is asked to propose protocol version 17. + +Paths and constants are in `packages/rs-drive/src/drive/votes/paths.rs`; the readiness fund key and paths are in `packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs`. + +## The models + +Defined in `packages/rs-dpp/src/voting/readiness/`, all versioned enums serialized with the platform serializer: + +- `ReadinessRound` carries the contract, the round id, the bundle digest, the contract version the bundle activates, the preparation profile, when the bundle was accepted (committed block time and height), the status (`Pending` or `Crossed` with the crossing time and the activation deadline), the last evaluation mark (the core height of the membership view and the raw report count the round was last judged against), whether the fund ran short, the fund id and the payer. The round id is `hash_double(network_magic || contract_id || version || bundle_digest || accepted_at_height)`, so a replacement of the same bundle in a later block is a different round and a report signed for one network means nothing on another. +- `ReadinessReportRecord` is what is stored for one accepted report: the height it was accepted at and the profile it was compiled against. The count tree's own count is the raw number of distinct reporters; the record is there so the block event can judge a report later without the transition. +- `ReadinessScanCursor` is the persisted position of a paged walk over a round's reports, bound to the membership view (core height and eligible count) the walk started under. +- `ReadinessPayer` is the party the unused fund is refunded to. Only an identity can pay today; the contract-bucket owner is appended when typed storage flags land. + +## Rounds + +Opening a round (`open_readiness_round_operations`) writes the pointer, creates the round tree with its record and an empty reports count tree, creates the fund, and when a round was current retires it. Everything happens in one batch that the pinned GroveDB's consistency check accepts: every operation is on a distinct path and key, and no insert sits below a delete. An opening whose round id already has a tree under the contract is refused, whether that round is current or retired and awaiting cleanup: recreating the tree would leave the new round queued for cleanup. + +Retiring a round (`retire_readiness_round_operations`, shared by replacement, cancellation and activation) never opens the reports tree. It queues the round under `[112, r, 3]`, drops the deadline entry when the round had crossed (with its per-time tree when no other round shares that time), empties the fund, charges the cleanup reserve to the epoch's processing pool and reports the remainder owed to the payer. The refund is returned rather than written because one batch may both refund the old payer and debit the new funding on the same identity, and two absolute balance writes on one key in one batch collapse; when the same identity funded both rounds the opening nets them into a single write, and otherwise it debits the new payer the full funding and credits the old payer its own refund. For the same reason the pool credit is an absolute rewrite of the epoch's processing pool item, so one retirement per applied batch is the contract of the helper. + +An estimate reads no state, so opening and cancellation price the largest shape they can meet: a crossed round with a deadline entry and a time tree to remove, a fund to settle, the whole cleanup reserve credited to the pool, and a refund written to the retired round's payer as its own balance write. + +Cancellation (`cancel_readiness_round_operations`) deletes the pointer and retires the round. Activation (`activate_readiness_round_operations`) does the same for a round that has crossed, is still the contract's current round and whose deadline the block has reached; routing the activated bundle into the contract's method tables is the caller's hook and lives outside Drive. In both cases the remainder is credited to the payer as unused preparation funding. That credit can repay the payer's debt, and only `apply_drive_operations` routes a repaid debt to the processing pool, so a block applies all three settlements as `ReadinessOperationType` batch operations (`OpenRound`, `CancelRound`, `ActivateRound`). It refuses a batch holding two of them, or one beside another identity balance or readiness fund write, which the absolute settlement writes would overwrite. A settlement or readiness fund write also shares its batch with no raw GroveDB operation, since Drive cannot tell which balances a raw write touches. + +## Reports + +`insert_readiness_report_operations` inserts one report into the current round's count tree if absent and says whether it was new. A retransmitted report is not new and writes nothing, so the count tree's count is the number of distinct reporters. The count is not an eligibility proof: the block event validates the reporters against the block's membership view before a crossing, and `prune_readiness_reports_operations` deletes the ones found ineligible. + +`fetch_readiness_reports_page_operations` returns reports in key order, continuing after a given key, so the walk can be paged across blocks; `fetch_readiness_round_raw_count_operations` reads the count tree's count. + +## Cursors, crossings and deadlines + +A paged walk that does not finish in one block persists a `ReadinessScanCursor` under the round; a block whose membership view differs from the cursor's discards it and restarts, so a crossing is only ever committed from a walk completed against one coherent view. `update_readiness_round_evaluation_operations` rewrites the record with the last evaluation mark; a round whose mark matches the block's view and raw count is skipped at the cost of one read, and one whose mark differs is reconsidered, which is what makes a membership change reconsider every round even when the backlog spans several blocks. + +`record_readiness_crossing_operations` marks the round crossed at the block's committed time with a deadline of `crossing_ms + clamp(crossing_ms - accepted_at_ms, 2 minutes, 1 hour)` (the bounds are `readiness_additional_wait_min_ms` and `readiness_additional_wait_max_ms` in `SystemLimits`) and queues the deadline under `[112, r, 1, encode_u64(deadline)]`, the same shape as the contested vote poll end-date queue. `fetch_readiness_rounds_due_operations` reads the entries at or before a time, oldest first; an entry whose round id is no longer the contract's current round is stale (the round was replaced or cancelled during the wait) and is dropped without activation. Retirement removes a crossed round's entry, and the per-time tree once it is empty: the due query walks the time keys under a limit and an empty time tree still spends it, so emptied trees ahead of a live deadline would hide that deadline from every block. + +## Cleanup + +`cleanup_retired_readiness_round_operations` runs one bounded step over the first queued retired round: it deletes up to a caller-given number of reports through the limited path-query delete and, once none remain, the record, the cursor and the now-empty count tree, then the round tree and, when no live round remains, the contract tree, and finally the queue entry. A round with more reports than the bound drains over several steps. The retired subtree has been unreachable since retirement, so the deferred deletion is invisible to every consensus rule; it only reclaims storage, paid by the cleanup reserve charged at retirement. + +## Funds + +A readiness fund is one sum item per round under `[40, 129]`, keyed by `hash_double("dashvm-readiness-fund-v1" || round_id)`. The five methods (`add_readiness_fund_operations`, `deduct_from_readiness_fund_operations`, `empty_readiness_fund_operations`, `fetch_readiness_fund`, `prove_readiness_fund`) are copies of their voting siblings on `[40, 128]`; the two families stay separate rather than sharing a path flag so the shipped voting generations remain byte-identical. Deductions keep a reserve untouchable so a round can always pay for its own deferred cleanup. Because the root `40` is a sum tree, readiness funds are inside the credit conservation check for free. + +## Genesis and upgrade + +`add_initial_vote_tree_main_structure_operations` generation 1 creates `[112, r]` with its children and `[40, 129]` at genesis. It is the vote setup that creates the fund tree, not the prefunded balances helper, because that helper is unversioned and genesis builds every lower layer in one batch. On a chain upgrading to protocol version 17, generation 3 of the protocol change hook creates the same elements with insert-if-not-exists through the same helper, and a test pins that a node born at 17 and a node upgraded from 16 hold byte-identical subtrees. + +## Proofs + +Three verifiers under `packages/rs-drive/src/verify/voting/` compile with the `verify` feature alone: `verify_readiness_round` (the pointer, the record and the raw count, from a merged proof the prover builds with the same queries), `verify_readiness_report` (one report by contract, round and reporter) and `verify_readiness_fund`. A query endpoint over them is client work and arrives separately. diff --git a/book/src/versioning/platform-version.md b/book/src/versioning/platform-version.md index 32e7db207f5..29ff1a18059 100644 --- a/book/src/versioning/platform-version.md +++ b/book/src/versioning/platform-version.md @@ -108,6 +108,28 @@ record, and the next consensus change creates `v15.rs`. There is never a `v14.rs` that means one thing on a node built last month and another on a node built today. +Because the array is indexed by number, a version cannot be registered without +every number below it. The 5.0 development branch therefore carries protocol +version 17 (its own) together with 15 and 16, which the allocation register +reserves for the 4.3 and 4.4 releases. Until those branches merge their real +`v15.rs` and `v16.rs` forward, the two files are placeholders written as +struct updates over their predecessor +(`PlatformVersion { protocol_version: PROTOCOL_VERSION_15, ..PLATFORM_V14 }`). +A forward merge that brings the real file is resolved by taking the incoming +file. Because 16 and 17 are struct updates too, every field the incoming +version changes flows into them automatically, except the fields a later +version overrides explicitly: `PLATFORM_V17` names its own `drive` table, so a +Drive change arriving with the real 15 or 16 must be reconciled into that table +by hand in the same merge. + +Protocol version 17 also introduces the compilation readiness storage (see +[Compilation Readiness](../drive/compilation-readiness.md)): `DRIVE_VERSION_V10` +turns on the `readiness` group of `DRIVE_VOTE_METHOD_VERSIONS_V4` (the three +readiness verifiers are generation 0 in every verify table), +`DRIVE_ABCI_METHOD_VERSIONS_V11` selects the protocol change hook generation +that creates the structures on upgrade, and +`SYSTEM_LIMITS_V5` carries the activation wait bounds. + ## What a Version Snapshot Looks Like Here is the very first version, `PLATFORM_V1`, slightly abbreviated: diff --git a/packages/rs-dpp/src/voting/mod.rs b/packages/rs-dpp/src/voting/mod.rs index dc8d5cb5fec..ee7f18efaa2 100644 --- a/packages/rs-dpp/src/voting/mod.rs +++ b/packages/rs-dpp/src/voting/mod.rs @@ -1,4 +1,5 @@ pub mod contender_structs; +pub mod readiness; pub mod vote_choices; pub mod vote_info_storage; pub mod vote_polls; diff --git a/packages/rs-dpp/src/voting/readiness/mod.rs b/packages/rs-dpp/src/voting/readiness/mod.rs new file mode 100644 index 00000000000..f4b565c7429 --- /dev/null +++ b/packages/rs-dpp/src/voting/readiness/mod.rs @@ -0,0 +1,14 @@ +//! Compilation readiness: the state Platform keeps while the evonodes of the network prepare a +//! contract's executable bundle before it can be activated. +//! +//! A [`round::ReadinessRound`] is opened per contract when a bundle is accepted; evonodes send +//! signed readiness reports that Drive stores as [`report_record::ReadinessReportRecord`] +//! entries under the round's count tree; the block event validates the reporters against the +//! block's membership view in pages whose position is a [`scan_cursor::ReadinessScanCursor`], +//! and records the crossing and the activation deadline on the round. The +//! [`payer::ReadinessPayer`] is the party the unused fund is refunded to when the round retires. + +pub mod payer; +pub mod report_record; +pub mod round; +pub mod scan_cursor; diff --git a/packages/rs-dpp/src/voting/readiness/payer.rs b/packages/rs-dpp/src/voting/readiness/payer.rs new file mode 100644 index 00000000000..cfc112d2ddf --- /dev/null +++ b/packages/rs-dpp/src/voting/readiness/payer.rs @@ -0,0 +1,32 @@ +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::Identifier; +use std::fmt; + +/// The party that funded a compilation readiness round and receives its unused remainder when +/// the round retires (replacement, cancellation or activation). +/// +/// Only an identity can pay today. The contract bucket variant that the typed storage flags +/// work introduces is appended when it lands; the enum is positional on the wire, so variants +/// are only ever added at the end. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +pub enum ReadinessPayer { + /// An identity funded the round; the refund is an identity balance credit. + Identity(Identifier), +} + +impl ReadinessPayer { + /// The identity that receives the refund, when the payer is an identity. + pub fn identity_id(&self) -> Option { + match self { + ReadinessPayer::Identity(identity_id) => Some(*identity_id), + } + } +} + +impl fmt::Display for ReadinessPayer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReadinessPayer::Identity(identity_id) => write!(f, "Identity({})", identity_id), + } + } +} diff --git a/packages/rs-dpp/src/voting/readiness/report_record/mod.rs b/packages/rs-dpp/src/voting/readiness/report_record/mod.rs new file mode 100644 index 00000000000..9a57eec8a10 --- /dev/null +++ b/packages/rs-dpp/src/voting/readiness/report_record/mod.rs @@ -0,0 +1,121 @@ +mod v0; + +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::From; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_version::version::PlatformVersion; +use std::fmt; +pub use v0::ReadinessReportRecordV0; + +/// One accepted compilation readiness report, stored under the round's count tree keyed by the +/// reporting evonode's pro tx hash. The count tree's own count is the raw distinct report +/// count; the record carries what the block event needs to judge the report later. +#[derive( + Debug, + PartialEq, + Eq, + Clone, + From, + Encode, + Decode, + DecodeUntrusted, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, +)] +#[platform_serialize(unversioned)] +pub enum ReadinessReportRecord { + /// Version 0. + V0(ReadinessReportRecordV0), +} + +impl fmt::Display for ReadinessReportRecord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReadinessReportRecord::V0(record) => write!(f, "V0({})", record), + } + } +} + +impl ReadinessReportRecord { + /// Builds a record for a report accepted at `accepted_at_height` against + /// `preparation_profile`, in the structure version the platform version selects. + pub fn new( + accepted_at_height: u64, + preparation_profile: u16, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .voting_versions + .readiness_report_record_default_structure_version + { + 0 => Ok(ReadinessReportRecordV0 { + accepted_at_height, + preparation_profile, + } + .into()), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "ReadinessReportRecord::new".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + /// The platform block height at which the report was accepted. + pub fn accepted_at_height(&self) -> u64 { + match self { + ReadinessReportRecord::V0(record) => record.accepted_at_height, + } + } + + /// The preparation profile the reporting node compiled against. + pub fn preparation_profile(&self) -> u16 { + match self { + ReadinessReportRecord::V0(record) => record.preparation_profile, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::serialization::{ + PlatformDeserializableTrusted, PlatformDeserializableUntrusted, PlatformSerializable, + }; + + #[test] + fn should_round_trip_a_report_record_through_serialization() { + let platform_version = PlatformVersion::latest(); + let record = ReadinessReportRecord::new(42, 3, platform_version).expect("record"); + let bytes = record.serialize_to_bytes().expect("serialize"); + let restored = + ReadinessReportRecord::deserialize_from_bytes_trusted(&bytes).expect("deserialize"); + assert_eq!(restored, record); + assert_eq!( + ReadinessReportRecord::deserialize_from_bytes_untrusted(&bytes) + .expect("deserialize untrusted"), + restored + ); + assert_eq!(restored.accepted_at_height(), 42); + assert_eq!(restored.preparation_profile(), 3); + } + + #[test] + fn should_reject_an_unknown_structure_version() { + let mut platform_version = PlatformVersion::latest().clone(); + platform_version + .dpp + .voting_versions + .readiness_report_record_default_structure_version = 7; + let result = ReadinessReportRecord::new(1, 1, &platform_version); + assert!(matches!( + result, + Err(ProtocolError::UnknownVersionMismatch { received: 7, .. }) + )); + } +} diff --git a/packages/rs-dpp/src/voting/readiness/report_record/v0/mod.rs b/packages/rs-dpp/src/voting/readiness/report_record/v0/mod.rs new file mode 100644 index 00000000000..34e76268b8b --- /dev/null +++ b/packages/rs-dpp/src/voting/readiness/report_record/v0/mod.rs @@ -0,0 +1,23 @@ +use bincode::{Decode, DecodeUntrusted, Encode}; +use std::fmt; + +/// Version 0 of an accepted compilation readiness report. +#[derive(Debug, PartialEq, Eq, Clone, Default, Encode, Decode, DecodeUntrusted)] +pub struct ReadinessReportRecordV0 { + /// The platform block height at which the report was accepted. Reports accepted in block + /// `H` count from the readiness event of block `H + 1`, which runs before that block's + /// transitions. + pub accepted_at_height: u64, + /// The preparation profile the reporting node compiled against. + pub preparation_profile: u16, +} + +impl fmt::Display for ReadinessReportRecordV0 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "ReadinessReportRecordV0 {{ accepted_at_height: {}, preparation_profile: {} }}", + self.accepted_at_height, self.preparation_profile + ) + } +} diff --git a/packages/rs-dpp/src/voting/readiness/round/mod.rs b/packages/rs-dpp/src/voting/readiness/round/mod.rs new file mode 100644 index 00000000000..e1d72d796e9 --- /dev/null +++ b/packages/rs-dpp/src/voting/readiness/round/mod.rs @@ -0,0 +1,451 @@ +mod v0; + +use crate::util::hash::hash_double; +use crate::voting::readiness::payer::ReadinessPayer; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::From; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use platform_version::version::PlatformVersion; +use std::fmt; +pub use v0::{ReadinessEvaluation, ReadinessRoundStatus, ReadinessRoundV0}; + +/// Domain separator of the readiness fund id derived from a round id. Provisional under the +/// allocation register's prefunded purpose row. +pub const READINESS_FUND_ID_DOMAIN: &[u8] = b"dashvm-readiness-fund-v1"; + +/// One compilation readiness round: the pending executable bundle of a contract, the reports +/// the evonodes sent for it, the last membership view it was judged against and, once it has +/// crossed the threshold, its activation deadline. +#[derive( + Debug, + PartialEq, + Eq, + Clone, + From, + Encode, + Decode, + DecodeUntrusted, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, +)] +#[platform_serialize(unversioned)] +pub enum ReadinessRound { + /// Version 0. + V0(ReadinessRoundV0), +} + +impl fmt::Display for ReadinessRound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReadinessRound::V0(round) => write!(f, "V0({})", round), + } + } +} + +/// The inputs that identify a round when it opens. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReadinessRoundOpening { + /// The contract whose bundle is being prepared. + pub contract_id: Identifier, + /// The contract version the bundle activates. + pub version: u32, + /// The canonical bundle (manifest) digest every report binds. + pub bundle_digest: [u8; 32], + /// The preparation profile generation the reports must be compiled against. + pub preparation_profile: u16, + /// The committed block time at which the bundle was accepted. + pub accepted_at_ms: u64, + /// The block height at which the bundle was accepted. + pub accepted_at_height: u64, + /// The party that funds the round and receives the unused remainder. + pub payer: ReadinessPayer, +} + +impl ReadinessRound { + /// Opens a round in the structure version the platform version selects. + /// + /// The round id is `hash_double(network_magic || contract_id || version || bundle_digest + /// || accepted_at_height)`, so a replacement of the very same bundle in a later block is a + /// different round, and a report signed for a round on one network is meaningless on + /// another. The fund id derives from the round id under a fixed domain. + pub fn new( + network_magic: u32, + opening: ReadinessRoundOpening, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .voting_versions + .readiness_round_default_structure_version + { + 0 => { + let round_id = Self::derive_round_id(network_magic, &opening); + let funding_id = Self::derive_fund_id(round_id); + Ok(ReadinessRoundV0 { + contract_id: opening.contract_id, + round_id, + bundle_digest: opening.bundle_digest, + version: opening.version, + preparation_profile: opening.preparation_profile, + accepted_at_ms: opening.accepted_at_ms, + accepted_at_height: opening.accepted_at_height, + status: ReadinessRoundStatus::Pending, + last_evaluated: None, + awaiting_funding: false, + funding_id, + payer: opening.payer, + } + .into()) + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "ReadinessRound::new".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + /// The round id of an opening on a network. + pub fn derive_round_id(network_magic: u32, opening: &ReadinessRoundOpening) -> [u8; 32] { + let mut payload = Vec::with_capacity(4 + 32 + 4 + 32 + 8); + payload.extend_from_slice(&network_magic.to_be_bytes()); + payload.extend_from_slice(opening.contract_id.as_slice()); + payload.extend_from_slice(&opening.version.to_be_bytes()); + payload.extend_from_slice(&opening.bundle_digest); + payload.extend_from_slice(&opening.accepted_at_height.to_be_bytes()); + hash_double(payload) + } + + /// The readiness fund id of a round. + pub fn derive_fund_id(round_id: [u8; 32]) -> Identifier { + let mut payload = Vec::with_capacity(READINESS_FUND_ID_DOMAIN.len() + 32); + payload.extend_from_slice(READINESS_FUND_ID_DOMAIN); + payload.extend_from_slice(&round_id); + Identifier::from(hash_double(payload)) + } + + /// The contract whose bundle is being prepared. + pub fn contract_id(&self) -> Identifier { + match self { + ReadinessRound::V0(round) => round.contract_id, + } + } + + /// The round id. + pub fn round_id(&self) -> [u8; 32] { + match self { + ReadinessRound::V0(round) => round.round_id, + } + } + + /// The canonical bundle digest every report binds. + pub fn bundle_digest(&self) -> [u8; 32] { + match self { + ReadinessRound::V0(round) => round.bundle_digest, + } + } + + /// The contract version the bundle activates. + pub fn version(&self) -> u32 { + match self { + ReadinessRound::V0(round) => round.version, + } + } + + /// The preparation profile the reports must be compiled against. + pub fn preparation_profile(&self) -> u16 { + match self { + ReadinessRound::V0(round) => round.preparation_profile, + } + } + + /// The committed block time at which the bundle was accepted. + pub fn accepted_at_ms(&self) -> u64 { + match self { + ReadinessRound::V0(round) => round.accepted_at_ms, + } + } + + /// The block height at which the bundle was accepted. + pub fn accepted_at_height(&self) -> u64 { + match self { + ReadinessRound::V0(round) => round.accepted_at_height, + } + } + + /// The round's status. + pub fn status(&self) -> ReadinessRoundStatus { + match self { + ReadinessRound::V0(round) => round.status, + } + } + + /// Whether the round is still collecting reports. + pub fn is_pending(&self) -> bool { + matches!(self.status(), ReadinessRoundStatus::Pending) + } + + /// The activation deadline, once the round has crossed. + pub fn deadline_ms(&self) -> Option { + match self.status() { + ReadinessRoundStatus::Pending => None, + ReadinessRoundStatus::Crossed { deadline_ms, .. } => Some(deadline_ms), + } + } + + /// The membership view and raw count the round was last judged against. + pub fn last_evaluated(&self) -> Option { + match self { + ReadinessRound::V0(round) => round.last_evaluated, + } + } + + /// Whether the fund could not pay for the last membership page. + pub fn awaiting_funding(&self) -> bool { + match self { + ReadinessRound::V0(round) => round.awaiting_funding, + } + } + + /// The readiness fund the round's verification work is paid from. + pub fn funding_id(&self) -> Identifier { + match self { + ReadinessRound::V0(round) => round.funding_id, + } + } + + /// The party the unused fund is refunded to. + pub fn payer(&self) -> ReadinessPayer { + match self { + ReadinessRound::V0(round) => round.payer, + } + } + + /// Records the membership view and raw count the round was judged against, and whether + /// the fund ran short. + pub fn set_evaluation(&mut self, evaluation: ReadinessEvaluation, awaiting_funding: bool) { + match self { + ReadinessRound::V0(round) => { + round.last_evaluated = Some(evaluation); + round.awaiting_funding = awaiting_funding; + } + } + } + + /// Marks the round crossed at `crossing_ms` with an activation deadline computed as + /// `crossing_ms + clamp(crossing_ms - accepted_at_ms, min_wait_ms, max_wait_ms)`. + /// + /// The subtraction saturates (the acceptance is always earlier than the crossing) and the + /// addition is checked. Returns the deadline. A round crosses once: a crossed round is + /// refused and keeps its deadline. + pub fn record_crossing( + &mut self, + crossing_ms: u64, + min_wait_ms: u64, + max_wait_ms: u64, + ) -> Result { + if !self.is_pending() { + return Err(ProtocolError::CorruptedCodeExecution( + "recording a crossing on a readiness round that already crossed".to_string(), + )); + } + if min_wait_ms > max_wait_ms { + return Err(ProtocolError::CorruptedCodeExecution( + "readiness wait bounds are inverted".to_string(), + )); + } + let deadline_ms = match self { + ReadinessRound::V0(round) => { + let elapsed = crossing_ms.saturating_sub(round.accepted_at_ms); + let wait = elapsed.clamp(min_wait_ms, max_wait_ms); + let deadline_ms = crossing_ms + .checked_add(wait) + .ok_or(ProtocolError::Overflow("readiness activation deadline"))?; + round.status = ReadinessRoundStatus::Crossed { + crossing_ms, + deadline_ms, + }; + deadline_ms + } + }; + Ok(deadline_ms) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::serialization::{ + PlatformDeserializableTrusted, PlatformDeserializableUntrusted, PlatformSerializable, + }; + + fn opening() -> ReadinessRoundOpening { + ReadinessRoundOpening { + contract_id: Identifier::from([1u8; 32]), + version: 2, + bundle_digest: [3u8; 32], + preparation_profile: 1, + accepted_at_ms: 1_000_000, + accepted_at_height: 50, + payer: ReadinessPayer::Identity(Identifier::from([9u8; 32])), + } + } + + #[test] + fn should_round_trip_a_pending_round_through_serialization() { + let platform_version = PlatformVersion::latest(); + let round = ReadinessRound::new(0xBD6B0CBF, opening(), platform_version).expect("round"); + let bytes = round.serialize_to_bytes().expect("serialize"); + let restored = ReadinessRound::deserialize_from_bytes_trusted(&bytes).expect("deserialize"); + assert_eq!(restored, round); + assert_eq!( + ReadinessRound::deserialize_from_bytes_untrusted(&bytes) + .expect("deserialize untrusted"), + restored + ); + assert!(restored.is_pending()); + assert_eq!(restored.deadline_ms(), None); + assert_eq!(restored.last_evaluated(), None); + assert!(!restored.awaiting_funding()); + assert_eq!( + restored.funding_id(), + ReadinessRound::derive_fund_id(restored.round_id()) + ); + } + + #[test] + fn should_round_trip_a_crossed_round_with_an_evaluation_mark() { + let platform_version = PlatformVersion::latest(); + let mut round = + ReadinessRound::new(0xBD6B0CBF, opening(), platform_version).expect("round"); + round.set_evaluation( + ReadinessEvaluation { + core_height: 700, + raw_count: 320, + }, + true, + ); + let deadline = round + .record_crossing(1_600_000, 120_000, 3_600_000) + .expect("crossing"); + assert_eq!(deadline, 1_600_000 + 600_000); + let bytes = round.serialize_to_bytes().expect("serialize"); + let restored = ReadinessRound::deserialize_from_bytes_trusted(&bytes).expect("deserialize"); + assert_eq!(restored, round); + assert_eq!( + ReadinessRound::deserialize_from_bytes_untrusted(&bytes) + .expect("deserialize untrusted"), + restored + ); + assert!(!restored.is_pending()); + assert_eq!(restored.deadline_ms(), Some(2_200_000)); + assert_eq!( + restored.last_evaluated(), + Some(ReadinessEvaluation { + core_height: 700, + raw_count: 320 + }) + ); + assert!(restored.awaiting_funding()); + } + + #[test] + fn should_clamp_the_additional_wait_between_the_bounds() { + let platform_version = PlatformVersion::latest(); + let cases = [ + (30_000u64, 120_000u64), + (600_000, 600_000), + (7_200_000, 3_600_000), + ]; + for (elapsed, expected_wait) in cases { + let mut round = + ReadinessRound::new(0xBD6B0CBF, opening(), platform_version).expect("round"); + let crossing_ms = 1_000_000 + elapsed; + let deadline = round + .record_crossing(crossing_ms, 120_000, 3_600_000) + .expect("crossing"); + assert_eq!(deadline, crossing_ms + expected_wait, "elapsed {elapsed}"); + } + } + + #[test] + fn should_refuse_a_second_crossing_and_keep_the_first_deadline() { + let platform_version = PlatformVersion::latest(); + let mut round = + ReadinessRound::new(0xBD6B0CBF, opening(), platform_version).expect("round"); + let deadline = round + .record_crossing(1_600_000, 120_000, 3_600_000) + .expect("crossing"); + let crossed = round.clone(); + assert!(matches!( + round.record_crossing(2_000_000, 120_000, 3_600_000), + Err(ProtocolError::CorruptedCodeExecution(_)) + )); + assert_eq!(round, crossed); + assert_eq!(round.deadline_ms(), Some(deadline)); + } + + #[test] + fn should_saturate_when_the_crossing_time_precedes_acceptance() { + let platform_version = PlatformVersion::latest(); + let mut round = + ReadinessRound::new(0xBD6B0CBF, opening(), platform_version).expect("round"); + let deadline = round + .record_crossing(500, 120_000, 3_600_000) + .expect("crossing"); + assert_eq!(deadline, 500 + 120_000); + } + + #[test] + fn should_report_overflow_on_the_deadline_and_reject_inverted_bounds() { + let platform_version = PlatformVersion::latest(); + let mut round = + ReadinessRound::new(0xBD6B0CBF, opening(), platform_version).expect("round"); + assert!(matches!( + round.record_crossing(u64::MAX, 120_000, 3_600_000), + Err(ProtocolError::Overflow(_)) + )); + assert!(matches!( + round.record_crossing(2_000_000, 3_600_000, 120_000), + Err(ProtocolError::CorruptedCodeExecution(_)) + )); + assert!(round.is_pending()); + } + + #[test] + fn should_derive_a_different_round_id_per_network_height_digest_and_version() { + let base = opening(); + let id = ReadinessRound::derive_round_id(1, &base); + assert_ne!(id, ReadinessRound::derive_round_id(2, &base)); + assert_ne!( + id, + ReadinessRound::derive_round_id( + 1, + &ReadinessRoundOpening { + accepted_at_height: 51, + ..base + } + ) + ); + assert_ne!( + id, + ReadinessRound::derive_round_id( + 1, + &ReadinessRoundOpening { + bundle_digest: [4u8; 32], + ..base + } + ) + ); + assert_ne!( + id, + ReadinessRound::derive_round_id(1, &ReadinessRoundOpening { version: 3, ..base }) + ); + assert_eq!(id, ReadinessRound::derive_round_id(1, &base)); + } +} diff --git a/packages/rs-dpp/src/voting/readiness/round/v0/mod.rs b/packages/rs-dpp/src/voting/readiness/round/v0/mod.rs new file mode 100644 index 00000000000..e2b46dbbaee --- /dev/null +++ b/packages/rs-dpp/src/voting/readiness/round/v0/mod.rs @@ -0,0 +1,97 @@ +use crate::voting::readiness::payer::ReadinessPayer; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::Identifier; +use std::fmt; + +/// The membership view and raw report count a round was last judged against. A round whose +/// mark differs from the block's view or raw count needs reconsideration; one whose mark +/// matches is skipped at the cost of one record read. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Encode, Decode, DecodeUntrusted)] +pub struct ReadinessEvaluation { + /// The core height whose masternode list was the membership view. + pub core_height: u32, + /// The raw distinct report count at the time. + pub raw_count: u64, +} + +/// Where a round stands. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Encode, Decode, DecodeUntrusted)] +pub enum ReadinessRoundStatus { + /// Collecting reports; never expires on its own. + Pending, + /// A fully validated crossing was recorded; the bundle activates at the first block + /// boundary whose time is at or past the deadline. + Crossed { + /// The committed block time of the crossing. + crossing_ms: u64, + /// The committed block time at or after which the bundle activates. + deadline_ms: u64, + }, +} + +impl fmt::Display for ReadinessRoundStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReadinessRoundStatus::Pending => write!(f, "Pending"), + ReadinessRoundStatus::Crossed { + crossing_ms, + deadline_ms, + } => write!( + f, + "Crossed {{ crossing_ms: {}, deadline_ms: {} }}", + crossing_ms, deadline_ms + ), + } + } +} + +/// Version 0 of a compilation readiness round. +#[derive(Debug, PartialEq, Eq, Clone, Encode, Decode, DecodeUntrusted)] +pub struct ReadinessRoundV0 { + /// The contract whose bundle is being prepared. + pub contract_id: Identifier, + /// The round id every report binds; see `ReadinessRound::derive_round_id`. + pub round_id: [u8; 32], + /// The canonical bundle digest every report binds. + pub bundle_digest: [u8; 32], + /// The contract version the bundle activates. + pub version: u32, + /// The preparation profile the reports must be compiled against. + pub preparation_profile: u16, + /// The committed block time at which the bundle was accepted. + pub accepted_at_ms: u64, + /// The block height at which the bundle was accepted. + pub accepted_at_height: u64, + /// Where the round stands. + pub status: ReadinessRoundStatus, + /// The membership view and raw count the round was last judged against; `None` until the + /// first evaluation. + pub last_evaluated: Option, + /// The fund could not pay for the last membership page; the round waits for a top-up. + pub awaiting_funding: bool, + /// The readiness fund the verification work is paid from. + pub funding_id: Identifier, + /// The party the unused fund is refunded to. + pub payer: ReadinessPayer, +} + +impl fmt::Display for ReadinessRoundV0 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "ReadinessRoundV0 {{ contract_id: {}, round_id: {}, bundle_digest: {}, version: {}, preparation_profile: {}, accepted_at_ms: {}, accepted_at_height: {}, status: {}, last_evaluated: {:?}, awaiting_funding: {}, funding_id: {}, payer: {} }}", + self.contract_id, + hex::encode(self.round_id), + hex::encode(self.bundle_digest), + self.version, + self.preparation_profile, + self.accepted_at_ms, + self.accepted_at_height, + self.status, + self.last_evaluated, + self.awaiting_funding, + self.funding_id, + self.payer + ) + } +} diff --git a/packages/rs-dpp/src/voting/readiness/scan_cursor/mod.rs b/packages/rs-dpp/src/voting/readiness/scan_cursor/mod.rs new file mode 100644 index 00000000000..54cdd5c22d3 --- /dev/null +++ b/packages/rs-dpp/src/voting/readiness/scan_cursor/mod.rs @@ -0,0 +1,261 @@ +mod v0; + +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::From; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_version::version::PlatformVersion; +use std::fmt; +pub use v0::ReadinessScanCursorV0; + +/// The persisted position of a paged eligibility walk over a round's reports. +/// +/// A walk is bound to the membership view it started under (`core_height` and `hpmn_len`); a +/// block whose view differs discards the cursor and restarts from the first report, so a +/// crossing is only ever committed from a walk completed against one coherent view. +#[derive( + Debug, + PartialEq, + Eq, + Clone, + From, + Encode, + Decode, + DecodeUntrusted, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, +)] +#[platform_serialize(unversioned)] +pub enum ReadinessScanCursor { + /// Version 0. + V0(ReadinessScanCursorV0), +} + +impl fmt::Display for ReadinessScanCursor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ReadinessScanCursor::V0(cursor) => write!(f, "V0({})", cursor), + } + } +} + +impl ReadinessScanCursor { + /// Starts a cursor for a walk under the membership view identified by `core_height` with + /// `hpmn_len` eligible evonodes, in the structure version the platform version selects. + pub fn new( + core_height: u32, + hpmn_len: u32, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .voting_versions + .readiness_scan_cursor_default_structure_version + { + 0 => Ok(ReadinessScanCursorV0 { + core_height, + hpmn_len, + next_pro_tx_hash: None, + eligible_so_far: 0, + pruned_so_far: 0, + examined_so_far: 0, + } + .into()), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "ReadinessScanCursor::new".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + /// Whether the cursor was taken under the given membership view. + pub fn is_bound_to(&self, core_height: u32, hpmn_len: u32) -> bool { + match self { + ReadinessScanCursor::V0(cursor) => { + cursor.core_height == core_height && cursor.hpmn_len == hpmn_len + } + } + } + + /// The core height of the membership view the walk runs under. + pub fn core_height(&self) -> u32 { + match self { + ReadinessScanCursor::V0(cursor) => cursor.core_height, + } + } + + /// The number of eligible evonodes of the membership view the walk runs under. + pub fn hpmn_len(&self) -> u32 { + match self { + ReadinessScanCursor::V0(cursor) => cursor.hpmn_len, + } + } + + /// The last report key examined; the next page starts after it. `None` before the first + /// page. + pub fn next_pro_tx_hash(&self) -> Option<[u8; 32]> { + match self { + ReadinessScanCursor::V0(cursor) => cursor.next_pro_tx_hash, + } + } + + /// Reports found eligible so far. + pub fn eligible_so_far(&self) -> u32 { + match self { + ReadinessScanCursor::V0(cursor) => cursor.eligible_so_far, + } + } + + /// Reports pruned so far. + pub fn pruned_so_far(&self) -> u32 { + match self { + ReadinessScanCursor::V0(cursor) => cursor.pruned_so_far, + } + } + + /// Reports examined so far. + pub fn examined_so_far(&self) -> u32 { + match self { + ReadinessScanCursor::V0(cursor) => cursor.examined_so_far, + } + } + + /// Advances the cursor past a page that examined `examined` reports ending at + /// `last_pro_tx_hash`, of which `eligible` were eligible and `pruned` were pruned. + /// + /// Eligible and pruned reports are distinct reports of the page, so a page classifying + /// more reports than it examined is refused, and pages follow report key order, so a page + /// ending at or before the previous position is refused; either way the cursor is left + /// unchanged. Restarting the walk takes a new cursor. + pub fn advance( + &mut self, + last_pro_tx_hash: [u8; 32], + examined: u32, + eligible: u32, + pruned: u32, + ) -> Result<(), ProtocolError> { + if u64::from(eligible) + u64::from(pruned) > u64::from(examined) { + return Err(ProtocolError::CorruptedCodeExecution( + "a readiness scan page classified more reports than it examined".to_string(), + )); + } + match self { + ReadinessScanCursor::V0(cursor) => { + // The next page starts strictly after the position, so a repeated or rewound + // page would count its reports again. + if cursor + .next_pro_tx_hash + .is_some_and(|previous| last_pro_tx_hash <= previous) + { + return Err(ProtocolError::CorruptedCodeExecution( + "a readiness scan page must end after the previous page".to_string(), + )); + } + // Every total is checked before any field moves, so an overflow leaves the + // position and the counts describing the same progress. + let examined_so_far = cursor + .examined_so_far + .checked_add(examined) + .ok_or(ProtocolError::Overflow("readiness scan examined count"))?; + let eligible_so_far = cursor + .eligible_so_far + .checked_add(eligible) + .ok_or(ProtocolError::Overflow("readiness scan eligible count"))?; + let pruned_so_far = cursor + .pruned_so_far + .checked_add(pruned) + .ok_or(ProtocolError::Overflow("readiness scan pruned count"))?; + cursor.next_pro_tx_hash = Some(last_pro_tx_hash); + cursor.examined_so_far = examined_so_far; + cursor.eligible_so_far = eligible_so_far; + cursor.pruned_so_far = pruned_so_far; + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::serialization::{ + PlatformDeserializableTrusted, PlatformDeserializableUntrusted, PlatformSerializable, + }; + + #[test] + fn should_round_trip_a_scan_cursor_through_serialization() { + let platform_version = PlatformVersion::latest(); + let mut cursor = ReadinessScanCursor::new(1_000, 400, platform_version).expect("cursor"); + cursor.advance([7u8; 32], 512, 500, 12).expect("advance"); + let bytes = cursor.serialize_to_bytes().expect("serialize"); + let restored = + ReadinessScanCursor::deserialize_from_bytes_trusted(&bytes).expect("deserialize"); + assert_eq!(restored, cursor); + assert_eq!( + ReadinessScanCursor::deserialize_from_bytes_untrusted(&bytes) + .expect("deserialize untrusted"), + restored + ); + assert!(restored.is_bound_to(1_000, 400)); + assert!(!restored.is_bound_to(1_001, 400)); + assert!(!restored.is_bound_to(1_000, 399)); + assert_eq!(restored.next_pro_tx_hash(), Some([7u8; 32])); + assert_eq!(restored.examined_so_far(), 512); + assert_eq!(restored.eligible_so_far(), 500); + assert_eq!(restored.pruned_so_far(), 12); + } + + #[test] + fn should_report_overflow_when_advancing_past_u32() { + let platform_version = PlatformVersion::latest(); + let mut cursor = ReadinessScanCursor::new(1, 1, platform_version).expect("cursor"); + cursor.advance([1u8; 32], u32::MAX, 0, 0).expect("advance"); + let before = cursor.clone(); + assert!(matches!( + cursor.advance([2u8; 32], 1, 0, 0), + Err(ProtocolError::Overflow(_)) + )); + assert_eq!(cursor, before); + } + + #[test] + fn should_refuse_a_page_classifying_more_reports_than_it_examined() { + let platform_version = PlatformVersion::latest(); + let mut cursor = ReadinessScanCursor::new(1, 1, platform_version).expect("cursor"); + cursor.advance([1u8; 32], 10, 5, 5).expect("advance"); + let before = cursor.clone(); + for (eligible, pruned) in [(3, 1), (0, 4), (u32::MAX, u32::MAX)] { + assert!(matches!( + cursor.advance([2u8; 32], 3, eligible, pruned), + Err(ProtocolError::CorruptedCodeExecution(_)) + )); + assert_eq!(cursor, before); + } + assert_eq!(cursor.next_pro_tx_hash(), Some([1u8; 32])); + assert_eq!(cursor.examined_so_far(), 10); + assert_eq!(cursor.eligible_so_far(), 5); + assert_eq!(cursor.pruned_so_far(), 5); + } + + #[test] + fn should_refuse_a_page_that_repeats_or_rewinds_the_position() { + let platform_version = PlatformVersion::latest(); + let mut cursor = ReadinessScanCursor::new(1, 1, platform_version).expect("cursor"); + cursor.advance([2u8; 32], 1, 1, 0).expect("advance"); + let before = cursor.clone(); + for last_pro_tx_hash in [[2u8; 32], [1u8; 32]] { + assert!(matches!( + cursor.advance(last_pro_tx_hash, 1, 1, 0), + Err(ProtocolError::CorruptedCodeExecution(_)) + )); + assert_eq!(cursor, before); + } + cursor.advance([3u8; 32], 1, 1, 0).expect("advance"); + assert_eq!(cursor.next_pro_tx_hash(), Some([3u8; 32])); + assert_eq!(cursor.eligible_so_far(), 2); + } +} diff --git a/packages/rs-dpp/src/voting/readiness/scan_cursor/v0/mod.rs b/packages/rs-dpp/src/voting/readiness/scan_cursor/v0/mod.rs new file mode 100644 index 00000000000..f6429f8d47d --- /dev/null +++ b/packages/rs-dpp/src/voting/readiness/scan_cursor/v0/mod.rs @@ -0,0 +1,34 @@ +use bincode::{Decode, DecodeUntrusted, Encode}; +use std::fmt; + +/// Version 0 of the persisted position of a paged eligibility walk. +#[derive(Debug, PartialEq, Eq, Clone, Default, Encode, Decode, DecodeUntrusted)] +pub struct ReadinessScanCursorV0 { + /// The core height whose masternode list is the membership view of the walk. + pub core_height: u32, + /// The number of eligible evonodes in that view; a different count means a different view. + pub hpmn_len: u32, + /// The last report key examined; the next page starts after it. + pub next_pro_tx_hash: Option<[u8; 32]>, + /// Reports found eligible so far. + pub eligible_so_far: u32, + /// Reports pruned so far. + pub pruned_so_far: u32, + /// Reports examined so far. + pub examined_so_far: u32, +} + +impl fmt::Display for ReadinessScanCursorV0 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "ReadinessScanCursorV0 {{ core_height: {}, hpmn_len: {}, next_pro_tx_hash: {:?}, eligible_so_far: {}, pruned_so_far: {}, examined_so_far: {} }}", + self.core_height, + self.hpmn_len, + self.next_pro_tx_hash.map(hex::encode), + self.eligible_so_far, + self.pruned_so_far, + self.examined_so_far + ) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs index 7eb94485d87..130b427562b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/mod.rs @@ -1,6 +1,7 @@ mod v0; mod v1; mod v2; +mod v3; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -44,6 +45,9 @@ impl Platform { /// - If the version is `2`, it calls `perform_events_on_first_block_of_protocol_change_v2`, which /// empties the contract cache, so no read is billed at a fee cached under the old fee /// schedule, and then runs v1. + /// - If the version is `3`, it calls `perform_events_on_first_block_of_protocol_change_v3`, which + /// runs v2 and additionally creates the compilation readiness structures when crossing + /// protocol version 17. /// - If no version is specified (`None`), the function does nothing and returns `Ok(())`. /// - If a different version is specified, it returns an error indicating an unknown version mismatch. /// @@ -82,10 +86,17 @@ impl Platform { previous_protocol_version, platform_version, ), + Some(3) => self.perform_events_on_first_block_of_protocol_change_v3( + platform_state, + block_info, + transaction, + previous_protocol_version, + platform_version, + ), None => Ok(()), Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "perform_events_on_first_block_of_protocol_change".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1, 2, 3], received: version, })), } @@ -179,7 +190,7 @@ mod tests { received, })) => { assert_eq!(method, "perform_events_on_first_block_of_protocol_change"); - assert_eq!(known_versions, vec![0, 1, 2]); + assert_eq!(known_versions, vec![0, 1, 2, 3]); assert_eq!(received, 255); } _ => panic!("expected UnknownVersionMismatch error"), diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v3/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v3/mod.rs new file mode 100644 index 00000000000..6620282b93c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v3/mod.rs @@ -0,0 +1,632 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use dpp::version::ProtocolVersion; +use drive::drive::Drive; +use drive::grovedb::batch::GroveOp; +use drive::grovedb::Transaction; +use drive::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; +use drive::util::batch::GroveDbOpBatch; + +impl Platform { + /// Runs the protocol change events of generation 2 and then the transition to protocol + /// version 17 when the chain crosses it: the compilation readiness structures are + /// created through the same helper genesis uses. + /// + /// The transition runs after the cache clear and refresh of generation 2 because it writes + /// no contract; it touches only the `[Votes] / r` and `[PreFundedSpecializedBalances] / 129` + /// trees, which no cache holds. + pub(super) fn perform_events_on_first_block_of_protocol_change_v3( + &self, + platform_state: &PlatformState, + block_info: &BlockInfo, + transaction: &Transaction, + previous_protocol_version: ProtocolVersion, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + self.perform_events_on_first_block_of_protocol_change_v2( + platform_state, + block_info, + transaction, + previous_protocol_version, + platform_version, + )?; + + if previous_protocol_version < 17 && platform_version.protocol_version >= 17 { + self.transition_to_version_17_compilation_readiness(transaction, platform_version)?; + } + + Ok(()) + } + + /// When transitioning to version 17 we add the compilation readiness tree under + /// `[Votes]` with its contracts, deadlines and retired-rounds children, and the + /// readiness fund sum tree under `[PreFundedSpecializedBalances]`. + /// + /// CONSENSUS-CRITICAL: the elements come from the shared helper + /// `Drive::add_readiness_structure_operations`, which genesis (vote setup generation 1) + /// also calls, so a fresh genesis-v17 node and an in-place-upgraded v17 node hold + /// byte-identical subtrees. Every element is inserted only if absent, so a retried block + /// after a rejected proposal, or a second run in the same transaction, is a no-op. + pub(super) fn transition_to_version_17_compilation_readiness( + &self, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut batch = GroveDbOpBatch::new(); + Drive::add_readiness_structure_operations(&mut batch); + for operation in batch { + let (GroveOp::InsertOrReplace { element } + | GroveOp::InsertOrReplaceDontCheckForBackwardsReferences { element }) = operation.op + else { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "the readiness structure helper emits only inserts", + ))); + }; + let path = operation.path.to_path(); + let key = operation + .key + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "the readiness structure helper emits only keyed inserts", + )))? + .get_key(); + self.drive.grove_insert_if_not_exists( + path.as_slice().into(), + &key, + element, + Some(transaction), + None, + &platform_version.drive, + )?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::block::block_info::BlockInfo; + use dpp::block::epoch::Epoch; + use dpp::identifier::Identifier; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::Identity; + use dpp::version::PlatformVersion; + use dpp::voting::readiness::payer::ReadinessPayer; + use dpp::voting::readiness::round::ReadinessRoundOpening; + use drive::drive::prefunded_specialized_balances::{ + prefunded_specialized_balances_path, PREFUNDED_BALANCES_FOR_READINESS, + }; + use drive::drive::votes::paths::{ + readiness_tree_path_vec, vote_root_path, READINESS_CONTRACTS_TREE_KEY, + READINESS_DEADLINES_TREE_KEY, READINESS_RETIRED_ROUNDS_TREE_KEY, READINESS_TREE_KEY, + }; + use drive::drive::votes::readiness::ReadinessRoundFunding; + use drive::grovedb::{Element, PathQuery, Query, SizedQuery}; + use drive::query::QueryResultType; + use platform_version::version::v16::PLATFORM_V16; + use platform_version::version::v17::PLATFORM_V17; + use rand::rngs::StdRng; + use rand::{RngCore, SeedableRng}; + + const INITIAL_FUNDING: u64 = 20_000_000; + const CLEANUP_RESERVE: u64 = 1_000_000; + + fn block_info() -> BlockInfo { + BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 100, + epoch: Epoch::new(0).expect("expected epoch"), + } + } + + /// Reads every element one level under `path`, failing loudly on a query error. + fn read_level( + platform: &crate::platform_types::platform::Platform, + transaction: drive::grovedb::TransactionArg, + path: Vec>, + platform_version: &PlatformVersion, + ) -> Vec<(Vec, Element)> { + let mut query = Query::new(); + query.insert_all(); + let path_query = PathQuery::new(path, SizedQuery::new(query, None, None)); + let (results, _) = platform + .drive + .grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + ) + .expect("expected the level to be readable"); + results.to_key_elements() + } + + /// Reads every element at every level under `path`, depth first, keyed by its full path. + fn read_subtree( + platform: &crate::platform_types::platform::Platform, + transaction: drive::grovedb::TransactionArg, + path: Vec>, + platform_version: &PlatformVersion, + ) -> Vec<(Vec>, Element)> { + let mut elements = vec![]; + for (key, element) in read_level(platform, transaction, path.clone(), platform_version) { + let mut element_path = path.clone(); + element_path.push(key); + let is_tree = element.is_any_tree(); + elements.push((element_path.clone(), element)); + if is_tree { + elements.extend(read_subtree( + platform, + transaction, + element_path, + platform_version, + )); + } + } + elements + } + + /// CONSENSUS-CRITICAL equivalence guard for the v16 -> v17 boundary. + /// + /// The readiness structures are built two ways that MUST be byte-identical: + /// + /// * GENESIS path: vote setup generation 1 inside `create_initial_state_structure`. + /// * UPGRADE path: `Platform::transition_to_version_17_compilation_readiness` at the + /// activation block on a node born at v16. + /// + /// Both go through `Drive::add_readiness_structure_operations`; this pins that they + /// keep doing so, element by element through every level of `[Votes] / r` and + /// `[PreFundedSpecializedBalances] / 129`. + /// + /// The whole database root is not compared: the `[Votes]` Merk holds the same children + /// on both nodes but its AVL shape depends on insertion order (genesis builds `r` with + /// its siblings in one batch, the upgrade appends it to an existing tree), so the + /// `[Votes]` element's root key legitimately differs. A chain has one history, so no two + /// nodes of one network ever take different paths. + #[test] + fn test_genesis_v17_and_upgrade_to_v17_build_identical_readiness_structures() { + let platform_version_17 = &PLATFORM_V17; + let grove_version = &platform_version_17.drive.grove_version; + + let platform_genesis = TestPlatformBuilder::new() + .with_initial_protocol_version(PLATFORM_V17.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_upgraded = TestPlatformBuilder::new() + .with_initial_protocol_version(PLATFORM_V16.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + assert!( + platform_upgraded + .drive + .grove + .get( + &vote_root_path(), + &[READINESS_TREE_KEY as u8], + None, + grove_version + ) + .unwrap() + .is_err(), + "v16 genesis must not contain the readiness tree before the upgrade" + ); + assert!( + platform_upgraded + .drive + .grove + .get( + &prefunded_specialized_balances_path(), + &[PREFUNDED_BALANCES_FOR_READINESS], + None, + grove_version + ) + .unwrap() + .is_err(), + "v16 genesis must not contain the readiness fund tree before the upgrade" + ); + + let transaction = platform_upgraded.drive.grove.start_transaction(); + platform_upgraded + .transition_to_version_17_compilation_readiness(&transaction, platform_version_17) + .expect("upgrade: the readiness transition should succeed"); + + for (path, key) in [ + (vote_root_path().to_vec(), READINESS_TREE_KEY as u8), + ( + prefunded_specialized_balances_path().to_vec(), + PREFUNDED_BALANCES_FOR_READINESS, + ), + ] { + let genesis_element = platform_genesis + .drive + .grove + .get(path.as_slice(), &[key], None, grove_version) + .unwrap() + .expect("genesis: element"); + let upgraded_element = platform_upgraded + .drive + .grove + .get(path.as_slice(), &[key], Some(&transaction), grove_version) + .unwrap() + .expect("upgrade: element"); + assert_eq!( + genesis_element, upgraded_element, + "CONSENSUS FORK: the element at key {key} differs between a fresh genesis-v17 \ + node and an in-place-upgraded v17 node" + ); + } + + let genesis_children = read_level( + &platform_genesis, + None, + readiness_tree_path_vec(), + platform_version_17, + ); + let upgraded_children = read_level( + &platform_upgraded, + Some(&transaction), + readiness_tree_path_vec(), + platform_version_17, + ); + assert_eq!( + genesis_children, upgraded_children, + "CONSENSUS FORK: the [Votes] / r children differ between genesis and upgrade" + ); + assert_eq!( + genesis_children + .iter() + .map(|(key, _)| key.clone()) + .collect::>(), + vec![ + vec![READINESS_CONTRACTS_TREE_KEY], + vec![READINESS_DEADLINES_TREE_KEY], + vec![READINESS_RETIRED_ROUNDS_TREE_KEY], + ] + ); + + // Every level below the two roots, so a nested element or a nested tree's shape (its + // root key) cannot differ unseen. + for path in [ + readiness_tree_path_vec(), + prefunded_specialized_balances_path() + .iter() + .map(|segment| segment.to_vec()) + .chain([vec![PREFUNDED_BALANCES_FOR_READINESS]]) + .collect(), + ] { + let genesis_subtree = + read_subtree(&platform_genesis, None, path.clone(), platform_version_17); + let upgraded_subtree = read_subtree( + &platform_upgraded, + Some(&transaction), + path.clone(), + platform_version_17, + ); + assert_eq!( + genesis_subtree, upgraded_subtree, + "CONSENSUS FORK: the subtree under {path:?} differs between a fresh genesis-v17 \ + node and an in-place-upgraded v17 node" + ); + } + } + + /// A rejected proposal drops its transaction; the retried block runs the transition + /// again on the same state and must produce the same root, and a second run in one + /// transaction changes nothing. + #[test] + fn test_transition_to_version_17_is_idempotent_and_replayable() { + let platform_version_17 = &PLATFORM_V17; + let grove_version = &platform_version_17.drive.grove_version; + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(PLATFORM_V16.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let dropped = platform.drive.grove.start_transaction(); + platform + .transition_to_version_17_compilation_readiness(&dropped, platform_version_17) + .expect("expected the first attempt to succeed"); + let root_after_first = platform + .drive + .grove + .root_hash(Some(&dropped), grove_version) + .unwrap() + .expect("expected a root hash"); + platform + .drive + .grove + .rollback_transaction(&dropped) + .expect("expected to roll back"); + + let retried = platform.drive.grove.start_transaction(); + platform + .transition_to_version_17_compilation_readiness(&retried, platform_version_17) + .expect("expected the retry to succeed"); + platform + .transition_to_version_17_compilation_readiness(&retried, platform_version_17) + .expect("expected a second run in the same transaction to be a no-op"); + let root_after_retry = platform + .drive + .grove + .root_hash(Some(&retried), grove_version) + .unwrap() + .expect("expected a root hash"); + assert_eq!(root_after_first, root_after_retry); + } + + /// The dispatcher selects generation 3 at protocol version 17, which runs the readiness + /// transition when crossing 17 and skips it when the previous version is already 17. The + /// generation selected at 16 does not know the structures. + #[test] + fn test_transition_from_version_16_triggers_17_only_once() { + let platform_version_17 = &PLATFORM_V17; + let grove_version = &platform_version_17.drive.grove_version; + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(PLATFORM_V16.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = platform.drive.grove.start_transaction(); + let platform_state = platform.state.load(); + let block_info = block_info(); + + // At 16 the hook knows nothing of readiness. + platform + .perform_events_on_first_block_of_protocol_change( + &platform_state, + &block_info, + &transaction, + PLATFORM_V16.protocol_version - 1, + &PLATFORM_V16, + ) + .expect("expected the transition to 16 to succeed"); + assert!(platform + .drive + .grove + .get( + &vote_root_path(), + &[READINESS_TREE_KEY as u8], + Some(&transaction), + grove_version, + ) + .unwrap() + .is_err()); + + platform + .perform_events_on_first_block_of_protocol_change( + &platform_state, + &block_info, + &transaction, + PLATFORM_V16.protocol_version, + platform_version_17, + ) + .expect("expected the transition from 16 to succeed"); + assert!(platform + .drive + .grove + .get( + &vote_root_path(), + &[READINESS_TREE_KEY as u8], + Some(&transaction), + grove_version, + ) + .unwrap() + .is_ok()); + + let root_before = platform + .drive + .grove + .root_hash(Some(&transaction), grove_version) + .unwrap() + .expect("expected a root hash"); + platform + .perform_events_on_first_block_of_protocol_change( + &platform_state, + &block_info, + &transaction, + platform_version_17.protocol_version, + platform_version_17, + ) + .expect("expected a same-version run to succeed"); + let root_after = platform + .drive + .grove + .root_hash(Some(&transaction), grove_version) + .unwrap() + .expect("expected a root hash"); + assert_eq!(root_before, root_after); + } + + /// Funds, deducts, cancels and conserves on a database born at 17 and on one upgraded + /// from 16, so both shapes behave the same under the readiness fund flows. + #[test] + fn test_readiness_fund_flows_on_fresh_and_upgraded_databases() { + let platform_version_17 = &PLATFORM_V17; + for born_at in [PLATFORM_V16.protocol_version, PLATFORM_V17.protocol_version] { + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(born_at) + .build_with_mock_rpc() + .set_genesis_state(); + let transaction = platform.drive.grove.start_transaction(); + if born_at < PLATFORM_V17.protocol_version { + platform + .transition_to_version_17_compilation_readiness( + &transaction, + platform_version_17, + ) + .expect("upgrade"); + } + + // A payer with a balance, mirrored into the system credits so conservation holds. + let mut rng = StdRng::seed_from_u64(born_at as u64); + let payer = Identity::random_identity(1, Some(rng.next_u64()), platform_version_17) + .expect("payer"); + let payer_id = payer.id(); + let payer_balance = payer.balance(); + platform + .drive + .add_new_identity( + payer, + false, + &block_info(), + true, + Some(&transaction), + platform_version_17, + ) + .expect("add payer"); + platform + .drive + .add_to_system_credits(payer_balance, Some(&transaction), platform_version_17) + .expect("system credits"); + assert!(platform + .drive + .calculate_total_credits_balance(Some(&transaction), &platform_version_17.drive) + .expect("totals") + .ok() + .expect("verdict")); + + let contract_id = [born_at as u8; 32]; + let opening = ReadinessRoundOpening { + contract_id: Identifier::from(contract_id), + version: 1, + bundle_digest: [0xD1u8; 32], + preparation_profile: 1, + accepted_at_ms: block_info().time_ms, + accepted_at_height: block_info().height, + payer: ReadinessPayer::Identity(payer_id), + }; + let (round, operations) = platform + .drive + .open_readiness_round_operations( + opening, + ReadinessRoundFunding { + initial_funding: INITIAL_FUNDING, + cleanup_reserve: CLEANUP_RESERVE, + }, + &block_info(), + &mut None, + Some(&transaction), + platform_version_17, + ) + .expect("open"); + platform + .drive + .apply_batch_low_level_drive_operations( + None, + Some(&transaction), + operations, + &mut vec![], + &platform_version_17.drive, + ) + .expect("apply open"); + assert_eq!( + platform + .drive + .fetch_readiness_fund( + round.funding_id().to_buffer(), + Some(&transaction), + platform_version_17 + ) + .expect("fund"), + Some(INITIAL_FUNDING) + ); + assert!(platform + .drive + .calculate_total_credits_balance(Some(&transaction), &platform_version_17.drive) + .expect("totals") + .ok() + .expect("verdict")); + + // A verification fee moves from the fund to the pool. + let mut operations = platform + .drive + .deduct_from_readiness_fund_operations( + round.funding_id(), + 10_000, + CLEANUP_RESERVE, + &mut None, + Some(&transaction), + platform_version_17, + ) + .expect("deduct"); + let mut reads = vec![]; + operations.push( + platform + .drive + .add_readiness_pool_credit_operation( + &block_info(), + 10_000, + true, + Some(&transaction), + &mut reads, + platform_version_17, + ) + .expect("pool"), + ); + platform + .drive + .apply_batch_low_level_drive_operations( + None, + Some(&transaction), + operations, + &mut vec![], + &platform_version_17.drive, + ) + .expect("apply deduct"); + assert!(platform + .drive + .calculate_total_credits_balance(Some(&transaction), &platform_version_17.drive) + .expect("totals") + .ok() + .expect("verdict")); + + // Cancellation refunds the remainder minus the reserve. + let (cancelled, operations) = platform + .drive + .cancel_readiness_round_operations( + contract_id, + CLEANUP_RESERVE, + &block_info(), + &mut None, + Some(&transaction), + platform_version_17, + ) + .expect("cancel"); + assert!(cancelled.is_some()); + platform + .drive + .apply_batch_low_level_drive_operations( + None, + Some(&transaction), + operations, + &mut vec![], + &platform_version_17.drive, + ) + .expect("apply cancel"); + let balance = platform + .drive + .fetch_identity_balance( + payer_id.to_buffer(), + Some(&transaction), + platform_version_17, + ) + .expect("balance") + .expect("payer"); + assert_eq!(balance, payer_balance - 10_000 - CLEANUP_RESERVE); + assert!(platform + .drive + .calculate_total_credits_balance(Some(&transaction), &platform_version_17.drive) + .expect("totals") + .ok() + .expect("verdict")); + } + } +} diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 4e4c8f0a377..3f307497a56 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "latest_protocol_version": 14, + "latest_protocol_version": 17, "element_kinds": [ { "name": "Item", @@ -1854,6 +1854,47 @@ } ] }, + { + "id": "prefunded_balances.for_readiness", + "key": { + "type": "fixed", + "hex": "81", + "label": "ForReadiness", + "constant": "PREFUNDED_BALANCES_FOR_READINESS" + }, + "kinds": [ + "SumTree" + ], + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs", + "book": "drive/compilation-readiness.md", + "description": "Balances that pay for compilation readiness rounds. The key is provisional.", + "children": [ + { + "id": "prefunded_balances.for_readiness.fund", + "key": { + "type": "dynamic", + "name": "fund_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "hash32", + "description": "A double sha256 of a domain tag and the round id" + }, + "kinds": [ + "SumItem" + ], + "value": "credits", + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs", + "description": "What is left of one round's funding, the cleanup reserve included.", + "children": [] + } + ] + }, { "id": "prefunded_balances.moderators_fee_pots", "key": { @@ -4702,6 +4743,303 @@ ] } ] + }, + { + "id": "votes.readiness", + "key": { + "type": "fixed", + "hex": "72", + "label": "Readiness", + "constant": "READINESS_TREE_KEY", + "ascii": true + }, + "kinds": [ + "Tree" + ], + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "book": "drive/compilation-readiness.md", + "description": "Readiness rounds: a contract's executable bundle activates once enough evonodes report that they have prepared it. The keys are provisional.", + "children": [ + { + "id": "votes.readiness.contracts", + "key": { + "type": "fixed", + "hex": "00", + "label": "Contracts", + "constant": "READINESS_CONTRACTS_TREE_KEY" + }, + "kinds": [ + "Tree" + ], + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The contracts holding a readiness round.", + "children": [ + { + "id": "votes.readiness.contracts.contract", + "key": { + "type": "dynamic", + "name": "contract_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The data contract id" + }, + "kinds": [ + "Tree" + ], + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "Created with the contract's first round. Stays while a retired round still waits for its cleanup.", + "children": [ + { + "id": "votes.readiness.contracts.contract.current_round", + "key": { + "type": "fixed", + "hex": "63", + "label": "CurrentRound", + "constant": "READINESS_CURRENT_ROUND_POINTER_KEY", + "ascii": true + }, + "kinds": [ + "Item" + ], + "value": "round_id (32 bytes)", + "since": 17, + "presence": "until_deleted", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The contract's current round. Replacing a round swaps the pointer; cancelling or activating it deletes it.", + "children": [] + }, + { + "id": "votes.readiness.contracts.contract.round", + "key": { + "type": "dynamic", + "name": "round_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "hash32", + "description": "The round id, a double sha256 of the network, the contract, the version, the bundle digest and the height it was accepted at" + }, + "kinds": [ + "Tree" + ], + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One round, normally only the current one. A retired round stays here, unreachable through the pointer, until its cleanup.", + "children": [ + { + "id": "votes.readiness.contracts.contract.round.record", + "key": { + "type": "fixed", + "hex": "00", + "label": "Record", + "constant": "READINESS_ROUND_RECORD_KEY" + }, + "kinds": [ + "Item" + ], + "value": "serialized ReadinessRound", + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The bundle, the status, the last evaluation mark and the fund of the round.", + "children": [] + }, + { + "id": "votes.readiness.contracts.contract.round.reports", + "key": { + "type": "fixed", + "hex": "01", + "label": "Reports", + "constant": "READINESS_ROUND_REPORTS_TREE_KEY" + }, + "kinds": [ + "CountTree" + ], + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The accepted reports; the count is the raw number of distinct reporters.", + "children": [ + { + "id": "votes.readiness.contracts.contract.round.reports.report", + "key": { + "type": "dynamic", + "name": "pro_tx_hash", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The reporting evonode's pro tx hash" + }, + "kinds": [ + "Item" + ], + "value": "serialized ReadinessReportRecord", + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One evonode's report that it prepared the bundle.", + "children": [] + } + ] + }, + { + "id": "votes.readiness.contracts.contract.round.scan_cursor", + "key": { + "type": "fixed", + "hex": "02", + "label": "ScanCursor", + "constant": "READINESS_ROUND_SCAN_CURSOR_KEY" + }, + "kinds": [ + "Item" + ], + "value": "serialized ReadinessScanCursor", + "since": 17, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "Where a walk over the reports that did not finish in one block continues. Absent when no walk is open.", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": "votes.readiness.deadlines", + "key": { + "type": "fixed", + "hex": "01", + "label": "Deadlines", + "constant": "READINESS_DEADLINES_TREE_KEY" + }, + "kinds": [ + "Tree" + ], + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "Crossed rounds by the time they activate, so each block can activate the ones that are due.", + "children": [ + { + "id": "votes.readiness.deadlines.deadline", + "key": { + "type": "dynamic", + "name": "deadline_ms", + "matcher": { + "type": "len", + "len": 8 + }, + "encoding": "u64_be", + "description": "The activation deadline in milliseconds, u64 big endian with the sign bit flipped" + }, + "kinds": [ + "Tree" + ], + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The rounds activating at this time. Removed with its last entry.", + "children": [ + { + "id": "votes.readiness.deadlines.deadline.entry", + "key": { + "type": "dynamic", + "name": "contract_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The data contract id" + }, + "kinds": [ + "Item" + ], + "value": "round_id (32 bytes)", + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The round the contract activates at this time. Stale, and dropped without activation, once it is no longer the contract's current round.", + "children": [] + } + ] + } + ] + }, + { + "id": "votes.readiness.evaluation_cursor", + "key": { + "type": "fixed", + "hex": "02", + "label": "EvaluationCursor", + "constant": "READINESS_EVALUATION_CURSOR_KEY" + }, + "kinds": [ + "Item" + ], + "value": "contract_id (32 bytes)", + "since": 17, + "presence": "lazy", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The last contract the block event evaluated, so the next block continues after it.", + "children": [] + }, + { + "id": "votes.readiness.retired_rounds", + "key": { + "type": "fixed", + "hex": "03", + "label": "RetiredRounds", + "constant": "READINESS_RETIRED_ROUNDS_TREE_KEY" + }, + "kinds": [ + "Tree" + ], + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "Rounds replaced, cancelled or activated, waiting for the bounded cleanup that deletes their trees.", + "children": [ + { + "id": "votes.readiness.retired_rounds.retired_round", + "key": { + "type": "dynamic", + "name": "round_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "hash32", + "description": "The round id, a double sha256 of the network, the contract, the version, the bundle digest and the height it was accepted at" + }, + "kinds": [ + "Item" + ], + "value": "contract_id (32 bytes)", + "since": 17, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One retired round and the contract its tree is under.", + "children": [] + } + ] + } + ] } ] }, @@ -5206,7 +5544,7 @@ }, "layer_shapes": { "contract_groups": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "00", "right": { @@ -5215,7 +5553,7 @@ } }, "contract_groups.groups.group": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@17", "tree": { "hex": "02", "left": { @@ -5230,7 +5568,7 @@ } }, "contract_groups.members.contract": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@17", "tree": { "hex": "01", "left": { @@ -5242,7 +5580,7 @@ } }, "contracts.contract": { - "origin": "fixture contracts_with_documents@14", + "origin": "fixture contracts_with_documents@17", "tree": { "hex": "01", "left": { @@ -5254,7 +5592,7 @@ } }, "contracts.contract.other": { - "origin": "fixture moderated_contract@14", + "origin": "fixture moderated_contract@17", "tree": { "hex": "80", "left": { @@ -5272,7 +5610,7 @@ } }, "group_actions.contract.group": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@17", "tree": { "hex": "4d", "left": { @@ -5284,7 +5622,7 @@ } }, "group_actions.contract.group.active.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@17", "tree": { "hex": "53", "left": { @@ -5293,7 +5631,7 @@ } }, "group_actions.contract.group.closed.action": { - "origin": "fixture tokens_and_group_actions@14", + "origin": "fixture tokens_and_group_actions@17", "tree": { "hex": "53", "left": { @@ -5302,7 +5640,7 @@ } }, "identities.identity": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@17", "tree": { "hex": "80", "left": { @@ -5327,7 +5665,7 @@ "states": [ { "state": "created", - "origin": "fixture identities@14", + "origin": "fixture identities@17", "tree": { "hex": "80", "left": { @@ -5346,7 +5684,7 @@ }, { "state": "used_with_a_contract", - "origin": "fixture identities@14", + "origin": "fixture identities@17", "tree": { "hex": "80", "left": { @@ -5368,7 +5706,7 @@ }, { "state": "budgeted_key_and_contract", - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@17", "tree": { "hex": "80", "left": { @@ -5394,7 +5732,7 @@ ] }, "identities.identity.contract_info.bound": { - "origin": "fixture contract_groups_and_bound_keys@14", + "origin": "fixture contract_groups_and_bound_keys@17", "tree": { "hex": "01", "left": { @@ -5403,7 +5741,7 @@ } }, "identities.identity.key_references": { - "origin": "fixture identities@14", + "origin": "fixture identities@17", "tree": { "hex": "03", "left": { @@ -5415,7 +5753,7 @@ } }, "identities.identity.key_references.authentication": { - "origin": "fixture identities@14", + "origin": "fixture identities@17", "tree": { "hex": "02", "left": { @@ -5430,7 +5768,7 @@ } }, "misc": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "45", "left": { @@ -5442,7 +5780,7 @@ } }, "pools.epoch": { - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@17", "tree": { "hex": "6d", "left": { @@ -5470,14 +5808,14 @@ "states": [ { "state": "future", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@17", "tree": { "hex": "73" } }, { "state": "running", - "origin": "fixture current_epoch@14", + "origin": "fixture current_epoch@17", "tree": { "hex": "6d", "left": { @@ -5505,7 +5843,7 @@ }, { "state": "paid", - "origin": "fixture paid_epoch@14", + "origin": "fixture paid_epoch@17", "tree": { "hex": "74", "left": { @@ -5528,19 +5866,22 @@ ] }, "prefunded_balances": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "80", "left": { "hex": "40" }, "right": { - "hex": "c0" + "hex": "81", + "right": { + "hex": "c0" + } } } }, "root": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "40", "left": { @@ -5597,7 +5938,7 @@ } }, "saved_block_transactions": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "65", "left": { @@ -5609,7 +5950,7 @@ } }, "shielded_balances.main_pool": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "80", "left": { @@ -5627,7 +5968,7 @@ } }, "tokens": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "80", "left": { @@ -5648,7 +5989,7 @@ } }, "tokens.distributions": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "80", "left": { @@ -5663,7 +6004,7 @@ } }, "tokens.distributions.perpetual.token": { - "origin": "fixture token_distributions_unclaimed@14", + "origin": "fixture token_distributions_unclaimed@17", "tree": { "hex": "c0", "left": { @@ -5672,7 +6013,7 @@ } }, "tokens.distributions.timed": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "80", "left": { @@ -5684,7 +6025,7 @@ } }, "versions": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "01", "left": { @@ -5693,19 +6034,22 @@ } }, "votes": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { - "hex": "64", + "hex": "65", "left": { - "hex": "63" + "hex": "64", + "left": { + "hex": "63" + } }, "right": { - "hex": "65" + "hex": "72" } } }, "votes.contested_resource": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "70", "left": { @@ -5714,7 +6058,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@17", "tree": { "hex": "01", "left": { @@ -5723,7 +6067,7 @@ } }, "votes.contested_resource.active_polls.contract.document_type.indexes.value.contender": { - "origin": "fixture contested_documents@14", + "origin": "fixture contested_documents@17", "tree": { "hex": "01", "left": { @@ -5731,8 +6075,32 @@ } } }, + "votes.readiness": { + "origin": "genesis@17", + "tree": { + "hex": "01", + "left": { + "hex": "00" + }, + "right": { + "hex": "03" + } + } + }, + "votes.readiness.contracts.contract.round": { + "origin": "fixture readiness_rounds@17", + "tree": { + "hex": "01", + "left": { + "hex": "00" + }, + "right": { + "hex": "02" + } + } + }, "withdrawals": { - "origin": "genesis@14", + "origin": "genesis@17", "tree": { "hex": "03", "left": { 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 393d63e7138..d74242abfc6 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs @@ -27,6 +27,11 @@ use crate::util::batch::GroveDbOpBatch; /// The key for prefunded balances for voting pub const PREFUNDED_BALANCES_FOR_VOTING: u8 = 128; +/// The key for prefunded balances funding compilation readiness rounds. Provisional under the +/// allocation register's prefunded purpose row: one purpose key per fund kind, beside the +/// voting funds, each a sum tree so the root sum keeps covering every fund. +pub const PREFUNDED_BALANCES_FOR_READINESS: u8 = 129; + /// prefunded specialized balances for voting pub fn prefunded_specialized_balances_path() -> [&'static [u8]; 1] { [Into::<&[u8; 1]>::into( @@ -55,6 +60,22 @@ pub fn prefunded_specialized_balances_for_voting_path_vec() -> Vec> { ] } +/// prefunded specialized balances for compilation readiness +pub fn prefunded_specialized_balances_for_readiness_path() -> [&'static [u8]; 2] { + [ + Into::<&[u8; 1]>::into(RootTree::PreFundedSpecializedBalances), + &[PREFUNDED_BALANCES_FOR_READINESS], + ] +} + +/// prefunded specialized balances for compilation readiness vector +pub fn prefunded_specialized_balances_for_readiness_path_vec() -> Vec> { + vec![ + Into::<&[u8; 1]>::into(RootTree::PreFundedSpecializedBalances).to_vec(), + vec![PREFUNDED_BALANCES_FOR_READINESS], + ] +} + impl Drive { #[cfg(feature = "server")] /// Add operations for creating initial prefunded specialized balances state structure @@ -64,6 +85,11 @@ impl Drive { /// For example let's say you make a food delivery app, and you want to pay for when your /// customers make an order, the restaurant or food delivery app might prepay for all documents /// that make an order + /// + /// The readiness fund tree (`129`) is not created here: this helper is unversioned and + /// genesis builds the lower layers in one batch, so the tree is added by the versioned + /// vote setup (`add_initial_vote_tree_main_structure_operations` generation 1) and, on + /// upgrade, by the protocol change hook. pub fn add_initial_prefunded_specialized_balances_operations(batch: &mut GroveDbOpBatch) { batch.add_insert_empty_sum_tree( vec![vec![RootTree::PreFundedSpecializedBalances as u8]], 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 6c0ac23e716..9379785d8c3 100644 --- a/packages/rs-drive/src/drive/prefunded_specialized_balances/structure.rs +++ b/packages/rs-drive/src/drive/prefunded_specialized_balances/structure.rs @@ -1,9 +1,11 @@ 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::prefunded_specialized_balances::{ + PREFUNDED_BALANCES_FOR_READINESS, PREFUNDED_BALANCES_FOR_VOTING, +}; use crate::drive::RootTree; -use crate::structure::{ElementKind, StructureNode}; +use crate::structure::{ElementKind, KeyEncoding, KeyMatcher, StructureNode}; /// Balances set aside to pay for specific later state transitions pub(crate) fn structure() -> StructureNode { @@ -44,6 +46,37 @@ pub(crate) fn structure() -> StructureNode { .describe("What is left to pay for votes on this poll."), ), ) + .child( + StructureNode::fixed( + "for_readiness", + &[PREFUNDED_BALANCES_FOR_READINESS], + "ForReadiness", + "PREFUNDED_BALANCES_FOR_READINESS", + ) + .kind(ElementKind::SumTree) + .since(17) + .source("packages/rs-drive/src/drive/prefunded_specialized_balances/mod.rs") + .book("drive/compilation-readiness.md") + .describe( + "Balances that pay for compilation readiness rounds. The key is \ + provisional.", + ) + .child( + StructureNode::dynamic( + "fund", + "fund_id", + KeyMatcher::Len(32), + KeyEncoding::Hash32, + "A double sha256 of a domain tag and the round id", + ) + .kind(ElementKind::SumItem) + .value("credits") + .describe( + "What is left of one round's funding, the cleanup reserve \ + included.", + ), + ), + ) .child(contract_fee_pots( "owner_fee_pots", PREFUNDED_BALANCES_FOR_CONTRACT_OWNER_FEES, diff --git a/packages/rs-drive/src/drive/votes/mod.rs b/packages/rs-drive/src/drive/votes/mod.rs index 83078aab11b..962f6b62981 100644 --- a/packages/rs-drive/src/drive/votes/mod.rs +++ b/packages/rs-drive/src/drive/votes/mod.rs @@ -40,6 +40,10 @@ pub mod tree_path_storage_form; #[cfg(feature = "server")] mod fetch; +/// Compilation readiness rounds, reports, cursors, deadlines and funds +#[cfg(any(feature = "server", feature = "verify"))] +pub mod readiness; + /// A trait to convert the vote to a tree path usable in grovedb pub trait TreePath { /// The tree path function diff --git a/packages/rs-drive/src/drive/votes/paths.rs b/packages/rs-drive/src/drive/votes/paths.rs index 1b35eecbc58..0af3c0df08b 100644 --- a/packages/rs-drive/src/drive/votes/paths.rs +++ b/packages/rs-drive/src/drive/votes/paths.rs @@ -8,6 +8,20 @@ /// |- Contested Resource [key: "c"] /// |- Active polls [key: "p"] /// |- Identifier Votes Query [key: "i"] +/// |- Compilation Readiness [key: "r"] (from protocol version 17) +/// |- Contracts [key: 0] +/// | |- contract_id +/// | |- Current round pointer [key: "c"] -> Item: round_id +/// | |- round_id +/// | |- Round record [key: 0] -> Item: ReadinessRound +/// | |- Reports [key: 1] (CountTree) -> pro_tx_hash -> Item: ReadinessReportRecord +/// | |- Scan cursor [key: 2] -> Item: ReadinessScanCursor (absent when no walk is open) +/// |- Deadlines [key: 1] +/// | |- encode_u64(deadline_ms) +/// | |- contract_id -> Item: round_id +/// |- Evaluation cursor [key: 2] -> Item: last contract_id visited by the block event +/// |- Retired rounds awaiting cleanup [key: 3] +/// |- round_id -> Item: contract_id /// ``` use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::{ ContestedDocumentResourceVotePollWithContractInfo, @@ -40,6 +54,34 @@ pub const ACTIVE_POLLS_TREE_KEY: char = 'p'; /// A subtree made for being able to query votes that an identity has made pub const IDENTITY_VOTES_TREE_KEY: char = 'i'; +/// A subtree made for compilation readiness rounds. Provisional inner tag under the +/// allocation register (new inner tags are unallocated), chosen beside `d`, `c` and `e`. +pub const READINESS_TREE_KEY: char = 'r'; + +/// Under the readiness tree: the contracts holding a round, keyed by contract id. +pub const READINESS_CONTRACTS_TREE_KEY: u8 = 0; + +/// Under the readiness tree: activation deadlines keyed by `encode_u64(deadline_ms)`. +pub const READINESS_DEADLINES_TREE_KEY: u8 = 1; + +/// Under the readiness tree: the block event's fairness cursor over contracts. +pub const READINESS_EVALUATION_CURSOR_KEY: u8 = 2; + +/// Under the readiness tree: retired rounds awaiting their bounded cleanup, keyed by round id. +pub const READINESS_RETIRED_ROUNDS_TREE_KEY: u8 = 3; + +/// Under a contract's readiness tree: the pointer to the current round id. +pub const READINESS_CURRENT_ROUND_POINTER_KEY: char = 'c'; + +/// Under a round's tree: the serialized round record. +pub const READINESS_ROUND_RECORD_KEY: u8 = 0; + +/// Under a round's tree: the count tree of accepted reports keyed by pro tx hash. +pub const READINESS_ROUND_REPORTS_TREE_KEY: u8 = 1; + +/// Under a round's tree: the persisted position of a paged eligibility walk. +pub const READINESS_ROUND_SCAN_CURSOR_KEY: u8 = 2; + /// The finished info pub const RESOURCE_STORED_INFO_KEY_U8_32: [u8; 32] = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -362,6 +404,155 @@ pub fn vote_end_date_queries_tree_path_vec() -> Vec> { ] } +/// the compilation readiness tree path of the voting branch +pub fn readiness_tree_path<'a>() -> [&'a [u8]; 2] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[READINESS_TREE_KEY as u8], + ] +} + +/// the compilation readiness tree path of the voting branch as a vec +pub fn readiness_tree_path_vec() -> Vec> { + vec![vec![RootTree::Votes as u8], vec![READINESS_TREE_KEY as u8]] +} + +/// the readiness contracts tree path +pub fn readiness_contracts_tree_path<'a>() -> [&'a [u8]; 3] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[READINESS_TREE_KEY as u8], + &[READINESS_CONTRACTS_TREE_KEY], + ] +} + +/// the readiness contracts tree path as a vec +pub fn readiness_contracts_tree_path_vec() -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![READINESS_TREE_KEY as u8], + vec![READINESS_CONTRACTS_TREE_KEY], + ] +} + +/// the readiness tree of one contract +pub fn readiness_contract_tree_path(contract_id: &[u8]) -> [&[u8]; 4] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[READINESS_TREE_KEY as u8], + &[READINESS_CONTRACTS_TREE_KEY], + contract_id, + ] +} + +/// the readiness tree of one contract as a vec +pub fn readiness_contract_tree_path_vec(contract_id: [u8; 32]) -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![READINESS_TREE_KEY as u8], + vec![READINESS_CONTRACTS_TREE_KEY], + contract_id.to_vec(), + ] +} + +/// the tree of one readiness round of a contract +pub fn readiness_round_tree_path<'a>(contract_id: &'a [u8], round_id: &'a [u8]) -> [&'a [u8]; 5] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[READINESS_TREE_KEY as u8], + &[READINESS_CONTRACTS_TREE_KEY], + contract_id, + round_id, + ] +} + +/// the tree of one readiness round of a contract as a vec +pub fn readiness_round_tree_path_vec(contract_id: [u8; 32], round_id: [u8; 32]) -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![READINESS_TREE_KEY as u8], + vec![READINESS_CONTRACTS_TREE_KEY], + contract_id.to_vec(), + round_id.to_vec(), + ] +} + +/// the reports count tree of one readiness round +pub fn readiness_round_reports_tree_path<'a>( + contract_id: &'a [u8], + round_id: &'a [u8], +) -> [&'a [u8]; 6] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[READINESS_TREE_KEY as u8], + &[READINESS_CONTRACTS_TREE_KEY], + contract_id, + round_id, + &[READINESS_ROUND_REPORTS_TREE_KEY], + ] +} + +/// the reports count tree of one readiness round as a vec +pub fn readiness_round_reports_tree_path_vec( + contract_id: [u8; 32], + round_id: [u8; 32], +) -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![READINESS_TREE_KEY as u8], + vec![READINESS_CONTRACTS_TREE_KEY], + contract_id.to_vec(), + round_id.to_vec(), + vec![READINESS_ROUND_REPORTS_TREE_KEY], + ] +} + +/// the readiness deadlines tree path +pub fn readiness_deadlines_tree_path<'a>() -> [&'a [u8]; 3] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[READINESS_TREE_KEY as u8], + &[READINESS_DEADLINES_TREE_KEY], + ] +} + +/// the readiness deadlines tree path as a vec +pub fn readiness_deadlines_tree_path_vec() -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![READINESS_TREE_KEY as u8], + vec![READINESS_DEADLINES_TREE_KEY], + ] +} + +/// the readiness deadlines tree at one deadline as a vec +pub fn readiness_deadline_tree_path_vec(deadline_ms: TimestampMillis) -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![READINESS_TREE_KEY as u8], + vec![READINESS_DEADLINES_TREE_KEY], + encode_u64(deadline_ms), + ] +} + +/// the readiness retired rounds tree path +pub fn readiness_retired_rounds_tree_path<'a>() -> [&'a [u8]; 3] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[READINESS_TREE_KEY as u8], + &[READINESS_RETIRED_ROUNDS_TREE_KEY], + ] +} + +/// the readiness retired rounds tree path as a vec +pub fn readiness_retired_rounds_tree_path_vec() -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![READINESS_TREE_KEY as u8], + vec![READINESS_RETIRED_ROUNDS_TREE_KEY], + ] +} + /// this is the path where votes are actually kept pub fn vote_contested_resource_active_polls_tree_path<'a>() -> [&'a [u8]; 3] { [ diff --git a/packages/rs-drive/src/drive/votes/readiness/activate_readiness_round_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/activate_readiness_round_operations/mod.rs new file mode 100644 index 00000000000..a5f4bd9e179 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/activate_readiness_round_operations/mod.rs @@ -0,0 +1,82 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Activates a crossed round at its deadline: deletes the pointer, retires the round + /// (queued for cleanup, deadline entry dropped, fund emptied with the cleanup reserve + /// charged to the pool) and credits the remainder to the payer as unused preparation + /// funding. Routing the activated bundle into the contract's method tables is the + /// caller's hook; nothing here touches the contract. + /// + /// The refund can repay the payer's debt, which leaves a + /// [`LowLevelDriveOperation::RepaidIdentityDebt`] among the operations that only + /// `apply_drive_operations` routes, so a block applies an activation as + /// `ReadinessOperationType::ActivateRound`. + /// + /// An estimate reads no state and checks nothing: it prices the activation of the largest + /// round shape (crossed, funded, with a payer to refund) and returns that placeholder in + /// place of the stored round. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round the deadline entry names; must be the contract's current round. + /// * `cleanup_reserve` - The credits charged to the pool for the deferred cleanup. + /// * `block_info` - The block the activation happens in. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The activated round (the placeholder when estimating) and the low level operations + /// that perform the writes. + /// * `Err(DriveError::CorruptedDriveState)` when the round is not the contract's current + /// crossed round, or the block is earlier than its activation deadline. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + #[allow(clippy::too_many_arguments)] + pub fn activate_readiness_round_operations( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + cleanup_reserve: Credits, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(ReadinessRound, Vec), Error> { + match platform_version.drive.methods.vote.readiness.activate_round { + Some(0) => self.activate_readiness_round_operations_v0( + contract_id, + round_id, + cleanup_reserve, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "activate_readiness_round_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "activate_readiness_round_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/activate_readiness_round_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/activate_readiness_round_operations/v0/mod.rs new file mode 100644 index 00000000000..2c84a62cbeb --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/activate_readiness_round_operations/v0/mod.rs @@ -0,0 +1,122 @@ +use crate::drive::votes::paths::{ + readiness_contract_tree_path, READINESS_CURRENT_ROUND_POINTER_KEY, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchDeleteApplyType; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + #[allow(clippy::too_many_arguments)] + pub(super) fn activate_readiness_round_operations_v0( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + cleanup_reserve: Credits, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(ReadinessRound, Vec), Error> { + let mut drive_operations = vec![]; + let round = if estimated_costs_only_with_layer_info.is_none() { + let round = self + .fetch_readiness_round_operations( + contract_id, + transaction, + &mut drive_operations, + platform_version, + )? + .ok_or(Error::Drive(DriveError::CorruptedDriveState( + "activating a readiness round of a contract that has none".to_string(), + )))?; + if round.round_id() != round_id { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "activating a readiness round that is not the contract's current round" + .to_string(), + ))); + } + match round.deadline_ms() { + None => { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "activating a readiness round that has not crossed".to_string(), + ))); + } + Some(deadline_ms) if block_info.time_ms < deadline_ms => { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "activating a readiness round before its activation deadline".to_string(), + ))); + } + Some(_) => {} + } + round + } else { + // An estimate reads no state: it prices the pointer and record reads, then the + // activation of the largest round shape so the estimate covers every case. + self.estimate_current_readiness_round_v0( + contract_id, + block_info, + transaction, + &mut drive_operations, + platform_version, + )? + }; + + let (retirement, retire_operations) = self.retire_readiness_round_operations( + &round, + cleanup_reserve, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + drive_operations.extend(retire_operations); + + let contract_path = readiness_contract_tree_path(&contract_id); + let apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + } + } else { + BatchDeleteApplyType::StatelessBatchDelete { + in_tree_type: TreeType::NormalTree, + estimated_key_size: 1, + estimated_value_size: DEFAULT_HASH_SIZE_U32, + } + }; + self.batch_delete( + (&contract_path).into(), + &[READINESS_CURRENT_ROUND_POINTER_KEY as u8], + apply_type, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + + // An estimate always prices the credit: the placeholder's fund reads as empty. + if retirement.refund > 0 || estimated_costs_only_with_layer_info.is_some() { + if let Some(payer_id) = round.payer().identity_id() { + drive_operations.extend(self.add_to_identity_balance_operations( + payer_id.to_buffer(), + retirement.refund, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + } + } + + Ok((round, drive_operations)) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/add_readiness_pool_credit_operation/mod.rs b/packages/rs-drive/src/drive/votes/readiness/add_readiness_pool_credit_operation/mod.rs new file mode 100644 index 00000000000..8db4c69220b --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/add_readiness_pool_credit_operation/mod.rs @@ -0,0 +1,66 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// The operation that adds `amount` to the block epoch's processing fee pool, reading + /// the current pool value in the same transaction. In estimation mode the read is + /// priced without state and the write is priced as an insert of the widest sum item + /// (the estimator bills a plain element by its serialized size, the applied write + /// bills the fixed sum item size); the caller describes the pool layers first. + /// + /// Readiness credits reach the pool this way (the cleanup reserve at retirement, the + /// membership lookup fees of the block event) so that every credit leaving a readiness + /// fund lands in a bucket conservation counts. The write is an absolute rewrite of the + /// pool item, so one such operation per applied batch. + /// + /// # Parameters + /// + /// * `block_info` - The block whose epoch receives the credits. + /// * `amount` - The credits to add. + /// * `apply` - Whether to read state (`true`) or only estimate the cost (`false`). + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The operation that writes the pool item. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn add_readiness_pool_credit_operation( + &self, + block_info: &BlockInfo, + amount: Credits, + apply: bool, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version.drive.methods.vote.readiness.credit_pool { + Some(0) => self.add_readiness_pool_credit_operation_v0( + block_info, + amount, + apply, + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "add_readiness_pool_credit_operation".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_readiness_pool_credit_operation".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/add_readiness_pool_credit_operation/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/add_readiness_pool_credit_operation/v0/mod.rs new file mode 100644 index 00000000000..f7e6568aad9 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/add_readiness_pool_credit_operation/v0/mod.rs @@ -0,0 +1,74 @@ +use crate::drive::credit_pools::epochs::epoch_key_constants::KEY_POOL_PROCESSING_FEES; +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 crate::util::grove_operations::QueryTarget::QueryTargetValue; +use crate::util::type_constants::U64_SIZE_U32; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use grovedb::{Element, TransactionArg, TreeType}; + +impl Drive { + pub(super) fn add_readiness_pool_credit_operation_v0( + &self, + block_info: &BlockInfo, + amount: Credits, + apply: bool, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let epoch_tree_path = block_info.epoch.get_path(); + let direct_query_type = if apply { + DirectQueryType::StatefulDirectQuery + } else { + DirectQueryType::StatelessDirectQuery { + in_tree_type: TreeType::SumTree, + query_target: QueryTargetValue(U64_SIZE_U32), + } + }; + let existing_value = match self.grove_get_raw_optional( + (&epoch_tree_path).into(), + KEY_POOL_PROCESSING_FEES.as_slice(), + direct_query_type, + transaction, + drive_operations, + &platform_version.drive, + )? { + None => 0, + Some(Element::SumItem(existing_value, _)) => existing_value, + Some(_) => { + return Err(Error::Drive(DriveError::UnexpectedElementType( + "epochs processing fee must be a sum item", + ))) + } + }; + if amount > i64::MAX as u64 { + return Err(Error::Protocol(Box::new(ProtocolError::Overflow( + "adding over i64::MAX to the processing fee pool", + )))); + } + let updated_value = if apply { + existing_value + .checked_add(amount as i64) + .ok_or(ProtocolError::Overflow( + "overflow when adding to the processing fee pool", + ))? + } else { + i64::MAX + }; + Ok(LowLevelDriveOperation::insert_for_known_path_key_element( + epoch_tree_path + .iter() + .map(|segment| segment.to_vec()) + .collect(), + KEY_POOL_PROCESSING_FEES.to_vec(), + Element::new_sum_item(updated_value), + )) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/cancel_readiness_round_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/cancel_readiness_round_operations/mod.rs new file mode 100644 index 00000000000..b09efe847c7 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/cancel_readiness_round_operations/mod.rs @@ -0,0 +1,67 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Cancels a contract's current compilation readiness round: deletes the pointer, + /// retires the round (queued for cleanup, deadline entry dropped, fund emptied with the + /// cleanup reserve charged to the pool) and credits the remainder to the payer. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `cleanup_reserve` - The credits charged to the pool for the deferred cleanup. + /// * `block_info` - The block the cancellation happens in. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state: + /// the operations then price the reads and the cancellation of a crossed, funded round. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The cancelled round (`None` when the contract had none, with no operations, and + /// always `None` in estimation mode) and the low level operations that perform the + /// writes. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn cancel_readiness_round_operations( + &self, + contract_id: [u8; 32], + cleanup_reserve: Credits, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Option, Vec), Error> { + match platform_version.drive.methods.vote.readiness.cancel_round { + Some(0) => self.cancel_readiness_round_operations_v0( + contract_id, + cleanup_reserve, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "cancel_readiness_round_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "cancel_readiness_round_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/cancel_readiness_round_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/cancel_readiness_round_operations/v0/mod.rs new file mode 100644 index 00000000000..19a3c89fe87 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/cancel_readiness_round_operations/v0/mod.rs @@ -0,0 +1,101 @@ +use crate::drive::votes::paths::{ + readiness_contract_tree_path, READINESS_CURRENT_ROUND_POINTER_KEY, +}; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchDeleteApplyType; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + pub(super) fn cancel_readiness_round_operations_v0( + &self, + contract_id: [u8; 32], + cleanup_reserve: Credits, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Option, Vec), Error> { + let mut drive_operations = vec![]; + let contract_path = readiness_contract_tree_path(&contract_id); + let round = if estimated_costs_only_with_layer_info.is_none() { + match self.fetch_readiness_round_operations( + contract_id, + transaction, + &mut drive_operations, + platform_version, + )? { + Some(round) => round, + None => return Ok((None, drive_operations)), + } + } else { + // An estimate reads no state: it prices the pointer and record reads, then the + // cancellation of the largest round shape so the estimate covers every case. + self.estimate_current_readiness_round_v0( + contract_id, + block_info, + transaction, + &mut drive_operations, + platform_version, + )? + }; + + let (retirement, retire_operations) = self.retire_readiness_round_operations( + &round, + cleanup_reserve, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + drive_operations.extend(retire_operations); + + let apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + } + } else { + BatchDeleteApplyType::StatelessBatchDelete { + in_tree_type: TreeType::NormalTree, + estimated_key_size: 1, + estimated_value_size: DEFAULT_HASH_SIZE_U32, + } + }; + self.batch_delete( + (&contract_path).into(), + &[READINESS_CURRENT_ROUND_POINTER_KEY as u8], + apply_type, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + + // An estimate always prices the credit: the placeholder's fund reads as empty. + if retirement.refund > 0 || estimated_costs_only_with_layer_info.is_some() { + if let Some(payer_id) = round.payer().identity_id() { + drive_operations.extend(self.add_to_identity_balance_operations( + payer_id.to_buffer(), + retirement.refund, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + } + } + + if estimated_costs_only_with_layer_info.is_some() { + return Ok((None, drive_operations)); + } + Ok((Some(round), drive_operations)) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/cleanup_retired_readiness_round_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/cleanup_retired_readiness_round_operations/mod.rs new file mode 100644 index 00000000000..3bf06b60fff --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/cleanup_retired_readiness_round_operations/mod.rs @@ -0,0 +1,81 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +/// What one cleanup step did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReadinessCleanupOutcome { + /// Reports deleted by this step. + pub reports_deleted: u64, + /// Whether the round is gone: its record, cursor, count tree, round tree and queue entry + /// were removed (and its contract tree when no live round remains). + pub finished: bool, +} + +impl Drive { + /// Runs one bounded cleanup step over a retired round: deletes up to `max_deletes` of + /// its reports and, once none remain, its record, cursor and empty count tree, then the + /// round tree and the contract tree while empty, and finally its queue entry. The + /// round's subtree has been unreachable through the pointer since it retired, so this + /// only reclaims storage; the work is paid by the cleanup reserve charged at retirement. + /// + /// # Parameters + /// + /// * `round_id` - The retired round. + /// * `contract_id` - Its contract. + /// * `max_deletes` - The most reports this step may delete. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state; an + /// estimate prices `max_deletes` report reads, deletes and merk path walks plus the fixed + /// tail, which bounds any applied step of that size. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * What the step did and the low level operations that perform it. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn cleanup_retired_readiness_round_operations( + &self, + round_id: [u8; 32], + contract_id: [u8; 32], + max_deletes: u16, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(ReadinessCleanupOutcome, Vec), Error> { + match platform_version + .drive + .methods + .vote + .readiness + .cleanup_retired_round + { + Some(0) => self.cleanup_retired_readiness_round_operations_v0( + round_id, + contract_id, + max_deletes, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "cleanup_retired_readiness_round_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "cleanup_retired_readiness_round_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/cleanup_retired_readiness_round_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/cleanup_retired_readiness_round_operations/v0/mod.rs new file mode 100644 index 00000000000..e5bc9c83342 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/cleanup_retired_readiness_round_operations/v0/mod.rs @@ -0,0 +1,336 @@ +use crate::drive::votes::paths::{ + readiness_contract_tree_path, readiness_contract_tree_path_vec, readiness_contracts_tree_path, + readiness_contracts_tree_path_vec, readiness_retired_rounds_tree_path, + readiness_retired_rounds_tree_path_vec, readiness_round_reports_tree_path_vec, + readiness_round_tree_path, readiness_round_tree_path_vec, READINESS_CURRENT_ROUND_POINTER_KEY, + READINESS_ROUND_RECORD_KEY, READINESS_ROUND_REPORTS_TREE_KEY, READINESS_ROUND_SCAN_CURSOR_KEY, +}; +use crate::drive::votes::readiness::cleanup_retired_readiness_round_operations::ReadinessCleanupOutcome; +use crate::drive::votes::readiness::estimation_costs::ESTIMATED_READINESS_REPORT_RECORD_SIZE; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::grove_operations::QueryTarget::QueryTargetValue; +use crate::util::grove_operations::{BatchDeleteApplyType, DirectQueryType}; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; +use dpp::version::PlatformVersion; +use grovedb::batch::key_info::KeyInfo; +use grovedb::batch::SubelementsDeletionBehavior; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::query_result_type::QueryResultType; +use grovedb::{ + EstimatedLayerInformation, MaybeTree, PathQuery, Query, SizedQuery, TransactionArg, TreeType, +}; +use std::collections::HashMap; + +impl Drive { + pub(super) fn cleanup_retired_readiness_round_operations_v0( + &self, + round_id: [u8; 32], + contract_id: [u8; 32], + max_deletes: u16, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(ReadinessCleanupOutcome, Vec), 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_readiness( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + // An estimate prices the worst step: `max_deletes` report deletes plus the + // fixed tail that removes the round, so it is at or above any applied step. + let reports_path_vec = readiness_round_reports_tree_path_vec(contract_id, round_id); + let reports_path = KeyInfoPath::from_known_owned_path(reports_path_vec.clone()); + let read_report = DirectQueryType::StatelessDirectQuery { + in_tree_type: TreeType::CountTree, + query_target: QueryTargetValue(ESTIMATED_READINESS_REPORT_RECORD_SIZE), + }; + for index in 0..max_deletes { + // The applied step reads each report twice before the batch: once through + // the bounded query and once to build its delete. + for _ in 0..2 { + self.grove_get_raw_optional( + reports_path_vec.as_slice().into(), + &[0u8; 32], + read_report, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + } + let key = KeyInfo::MaxKeySize { + unique_id: index.to_be_bytes().to_vec(), + max_size: DEFAULT_HASH_SIZE_U32 as u8, + }; + // The per-key walk the batch estimate leaves out; its margin also covers + // the step's handful of fixed reads. + Self::add_estimated_readiness_report_delete_walk_v0( + contract_id, + round_id, + &key, + estimated_costs_only_with_layer_info, + &mut drive_operations, + platform_version, + )?; + drive_operations.push(GroveOperation(QualifiedGroveDbOp::delete_estimated_op( + reports_path.clone(), + key, + ))); + } + Self::add_readiness_cleanup_tail_estimated_operations( + contract_id, + round_id, + &mut drive_operations, + ); + return Ok(( + ReadinessCleanupOutcome { + reports_deleted: max_deletes as u64, + finished: true, + }, + drive_operations, + )); + } + + // Does the round tree still exist? A queue entry may outlive it only if a previous + // step removed the tree but the batch failed before the entry; then just drop the + // entry. + let round_path = readiness_round_tree_path(&contract_id, &round_id); + let contract_path = readiness_contract_tree_path(&contract_id); + let round_tree_exists = self.grove_has_raw( + (&contract_path).into(), + &round_id, + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + + let mut reports_deleted = 0u64; + let mut remaining = 0u64; + if round_tree_exists { + // Delete up to `max_deletes` reports through the bounded path-query delete. + if max_deletes > 0 { + let mut query = Query::new_with_direction(true); + query.insert_all(); + let path_query = PathQuery::new( + readiness_round_reports_tree_path_vec(contract_id, round_id), + SizedQuery::new(query, Some(max_deletes), None), + ); + let before = drive_operations.len(); + self.batch_delete_items_in_path_query( + &path_query, + false, + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + reports_deleted = drive_operations[before..] + .iter() + .filter(|operation| matches!(operation, GroveOperation(_))) + .count() as u64; + } + let raw_count = self.fetch_readiness_round_raw_count_operations( + contract_id, + round_id, + transaction, + &mut drive_operations, + platform_version, + )?; + remaining = raw_count.saturating_sub(reports_deleted); + } + + if remaining > 0 { + return Ok(( + ReadinessCleanupOutcome { + reports_deleted, + finished: false, + }, + drive_operations, + )); + } + + let delete_item = BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }; + + if round_tree_exists { + // Record, cursor (if any) and the now-empty count tree. + self.batch_delete( + (&round_path).into(), + &[READINESS_ROUND_RECORD_KEY], + delete_item, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + if self.grove_has_raw( + (&round_path).into(), + &[READINESS_ROUND_SCAN_CURSOR_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )? { + self.batch_delete( + (&round_path).into(), + &[READINESS_ROUND_SCAN_CURSOR_KEY], + delete_item, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + } + // The count tree: its reports were all deleted in this batch or earlier ones, + // so the emptiness check of the delete (which counts same-batch deletes as gone) + // passes and the tree element goes with no cleanup. + self.batch_delete( + (&round_path).into(), + &[READINESS_ROUND_REPORTS_TREE_KEY], + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::Tree(TreeType::CountTree)), + }, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + // The round tree itself, emptied by the three deletes above. + self.batch_delete( + (&contract_path).into(), + &round_id, + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::Tree(TreeType::NormalTree)), + }, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + // The contract tree when nothing else lives there: no pointer (a live round + // would have one) and no other round tree. + let pointer_present = self.grove_has_raw( + (&contract_path).into(), + &[READINESS_CURRENT_ROUND_POINTER_KEY as u8], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + if !pointer_present { + let mut query = Query::new_with_direction(true); + query.insert_all(); + let path_query = PathQuery::new( + readiness_contract_tree_path_vec(contract_id), + SizedQuery::new(query, Some(2), None), + ); + let siblings = self + .grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + &mut drive_operations, + &platform_version.drive, + )? + .0 + .to_keys(); + let only_this_round = siblings.iter().all(|key| key.as_slice() == round_id); + if only_this_round { + let contracts_path = readiness_contracts_tree_path(); + self.batch_delete( + (&contracts_path).into(), + &contract_id, + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::Tree( + TreeType::NormalTree, + )), + }, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + } + } + } + + // The queue entry. + let retired_path = readiness_retired_rounds_tree_path(); + self.batch_delete( + (&retired_path).into(), + &round_id, + delete_item, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + + Ok(( + ReadinessCleanupOutcome { + reports_deleted, + finished: true, + }, + drive_operations, + )) + } + + /// The estimated operations of the tail of a cleanup step: the record, cursor, count + /// tree, round tree, contract tree and queue entry deletes. + fn add_readiness_cleanup_tail_estimated_operations( + contract_id: [u8; 32], + round_id: [u8; 32], + drive_operations: &mut Vec, + ) { + let round_path = KeyInfoPath::from_known_owned_path(readiness_round_tree_path_vec( + contract_id, + round_id, + )); + for key in [READINESS_ROUND_RECORD_KEY, READINESS_ROUND_SCAN_CURSOR_KEY] { + drive_operations.push(GroveOperation(QualifiedGroveDbOp::delete_estimated_op( + round_path.clone(), + KeyInfo::KnownKey(vec![key]), + ))); + } + drive_operations.push(GroveOperation( + QualifiedGroveDbOp::delete_estimated_tree_op( + round_path, + KeyInfo::KnownKey(vec![READINESS_ROUND_REPORTS_TREE_KEY]), + TreeType::CountTree, + SubelementsDeletionBehavior::DontCheckWithNoCleanup, + ), + )); + let contract_path = + KeyInfoPath::from_known_owned_path(readiness_contract_tree_path_vec(contract_id)); + drive_operations.push(GroveOperation( + QualifiedGroveDbOp::delete_estimated_tree_op( + contract_path, + KeyInfo::KnownKey(round_id.to_vec()), + TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheckWithNoCleanup, + ), + )); + let contracts_path = + KeyInfoPath::from_known_owned_path(readiness_contracts_tree_path_vec()); + drive_operations.push(GroveOperation( + QualifiedGroveDbOp::delete_estimated_tree_op( + contracts_path, + KeyInfo::KnownKey(contract_id.to_vec()), + TreeType::NormalTree, + SubelementsDeletionBehavior::DontCheckWithNoCleanup, + ), + )); + let retired_path = + KeyInfoPath::from_known_owned_path(readiness_retired_rounds_tree_path_vec()); + drive_operations.push(GroveOperation(QualifiedGroveDbOp::delete_estimated_op( + retired_path, + KeyInfo::KnownKey(round_id.to_vec()), + ))); + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/clear_readiness_scan_cursor_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/clear_readiness_scan_cursor_operations/mod.rs new file mode 100644 index 00000000000..2416d3b3942 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/clear_readiness_scan_cursor_operations/mod.rs @@ -0,0 +1,62 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Removes the persisted position of a round's paged eligibility walk if one is open. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The low level operations that perform the delete (empty when no cursor is open). + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn clear_readiness_scan_cursor_operations( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .clear_scan_cursor + { + Some(0) => self.clear_readiness_scan_cursor_operations_v0( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "clear_readiness_scan_cursor_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "clear_readiness_scan_cursor_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/clear_readiness_scan_cursor_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/clear_readiness_scan_cursor_operations/v0/mod.rs new file mode 100644 index 00000000000..bd36b77b42c --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/clear_readiness_scan_cursor_operations/v0/mod.rs @@ -0,0 +1,66 @@ +use crate::drive::votes::paths::{readiness_round_tree_path, READINESS_ROUND_SCAN_CURSOR_KEY}; +use crate::drive::votes::readiness::estimation_costs::ESTIMATED_READINESS_SCAN_CURSOR_SIZE; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::{BatchDeleteApplyType, DirectQueryType}; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + pub(super) fn clear_readiness_scan_cursor_operations_v0( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations = vec![]; + let path = readiness_round_tree_path(&contract_id, &round_id); + let apply_type = match estimated_costs_only_with_layer_info { + None => { + let present = self.grove_has_raw( + (&path).into(), + &[READINESS_ROUND_SCAN_CURSOR_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + if !present { + return Ok(drive_operations); + } + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + } + } + Some(estimated_costs_only_with_layer_info) => { + Self::add_estimation_costs_for_readiness( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + BatchDeleteApplyType::StatelessBatchDelete { + in_tree_type: TreeType::NormalTree, + estimated_key_size: 1, + estimated_value_size: ESTIMATED_READINESS_SCAN_CURSOR_SIZE, + } + } + }; + self.batch_delete( + (&path).into(), + &[READINESS_ROUND_SCAN_CURSOR_KEY], + apply_type, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/estimation_costs/mod.rs b/packages/rs-drive/src/drive/votes/readiness/estimation_costs/mod.rs new file mode 100644 index 00000000000..ff8c1972a2e --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/estimation_costs/mod.rs @@ -0,0 +1,188 @@ +mod v0; + +pub(crate) use v0::{ESTIMATED_READINESS_REPORT_RECORD_SIZE, ESTIMATED_READINESS_SCAN_CURSOR_SIZE}; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::epoch::Epoch; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerInformation; +use std::collections::HashMap; + +impl Drive { + /// Adds the layer estimation for writes under the readiness subtrees of one contract and + /// round, and under the readiness fund tree. + /// + /// # Parameters + /// + /// * `contract_id` - The contract whose readiness tree is written. + /// * `round_id` - The round whose tree is written. + /// * `estimated_costs_only_with_layer_info` - The estimation map to fill. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(())` on success. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub(crate) fn add_estimation_costs_for_readiness( + contract_id: [u8; 32], + round_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut HashMap, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .readiness + .estimation_costs + { + Some(0) => { + Self::add_estimation_costs_for_readiness_v0( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + ); + Ok(()) + } + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "add_estimation_costs_for_readiness".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_estimation_costs_for_readiness".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Adds the layer estimation for the per-time tree of one activation deadline. + /// + /// # Parameters + /// + /// * `deadline_ms` - The deadline whose tree is written. + /// * `estimated_costs_only_with_layer_info` - The estimation map to fill. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(())` on success. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub(crate) fn add_estimation_costs_for_readiness_deadline( + deadline_ms: u64, + estimated_costs_only_with_layer_info: &mut HashMap, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .readiness + .estimation_costs + { + Some(0) => { + Self::add_estimation_costs_for_readiness_deadline_v0( + deadline_ms, + estimated_costs_only_with_layer_info, + ); + Ok(()) + } + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "add_estimation_costs_for_readiness_deadline".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_estimation_costs_for_readiness_deadline".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Adds the layer estimation for a readiness fund write. + /// + /// # Parameters + /// + /// * `estimated_costs_only_with_layer_info` - The estimation map to fill. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(())` on success. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub(crate) fn add_estimation_costs_for_readiness_fund_update( + estimated_costs_only_with_layer_info: &mut HashMap, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .readiness + .estimation_costs + { + Some(0) => { + Self::add_estimation_costs_for_readiness_fund_update_v0( + estimated_costs_only_with_layer_info, + ); + Ok(()) + } + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "add_estimation_costs_for_readiness_fund_update".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_estimation_costs_for_readiness_fund_update".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Adds the layer estimation for a readiness credit to an epoch's processing fee pool: + /// the pools tree and the epoch's tree. + /// + /// # Parameters + /// + /// * `epoch` - The epoch whose pool is written. + /// * `estimated_costs_only_with_layer_info` - The estimation map to fill. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(())` on success. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub(crate) fn add_estimation_costs_for_readiness_pool_credit( + epoch: &Epoch, + estimated_costs_only_with_layer_info: &mut HashMap, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .readiness + .estimation_costs + { + Some(0) => { + Self::add_estimation_costs_for_readiness_pool_credit_v0( + epoch, + estimated_costs_only_with_layer_info, + ); + Ok(()) + } + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "add_estimation_costs_for_readiness_pool_credit".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_estimation_costs_for_readiness_pool_credit".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/estimation_costs/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/estimation_costs/v0/mod.rs new file mode 100644 index 00000000000..5891c4ec5d2 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/estimation_costs/v0/mod.rs @@ -0,0 +1,487 @@ +use crate::drive::constants::AVERAGE_BALANCE_SIZE; +use crate::drive::credit_pools::epochs::paths::EpochProposers; +use crate::drive::credit_pools::pools_vec_path; +use crate::drive::prefunded_specialized_balances::{ + prefunded_specialized_balances_for_readiness_path_vec, prefunded_specialized_balances_path, +}; +use crate::drive::votes::paths::{ + readiness_contract_tree_path, readiness_round_tree_path, READINESS_CURRENT_ROUND_POINTER_KEY, + READINESS_ROUND_RECORD_KEY, +}; +use crate::drive::votes::paths::{ + readiness_contract_tree_path_vec, readiness_contracts_tree_path_vec, + readiness_deadline_tree_path_vec, readiness_deadlines_tree_path_vec, + readiness_retired_rounds_tree_path_vec, readiness_round_reports_tree_path_vec, + readiness_round_tree_path_vec, readiness_tree_path_vec, vote_root_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::CalculatedCostOperation; +use crate::util::grove_operations::DirectQueryType; +use crate::util::grove_operations::QueryTarget::QueryTargetValue; +use crate::util::type_constants::{ + DEFAULT_HASH_SIZE_U32, DEFAULT_HASH_SIZE_U8, U64_SIZE_U8, U8_SIZE_U8, +}; +use dpp::block::block_info::BlockInfo; +use dpp::block::epoch::Epoch; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::payer::ReadinessPayer; +use dpp::voting::readiness::round::{ReadinessRound, ReadinessRoundOpening}; +use grovedb::batch::key_info::KeyInfo; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::{ApproximateElements, EstimatedLevel, PotentiallyAtMaxElements}; +use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees, Mix}; +use grovedb::EstimatedSumTrees::{AllSumTrees, NoSumTrees, SomeSumTrees}; +use grovedb::{EstimatedLayerInformation, GroveDb, TransactionArg, TreeType}; +use grovedb_costs::CostContext; +use std::collections::HashMap; + +/// The largest serialized size of a readiness round record (223 bytes), rounded up: a +/// versioned enum around two identifiers, two 32-byte digests, a handful of integers, the +/// crossed status with two timestamps, the evaluation mark and the typed payer, every integer +/// at its widest varint. +pub(crate) const ESTIMATED_READINESS_ROUND_RECORD_SIZE: u32 = 224; + +/// The largest serialized size of a readiness report record (13 bytes), rounded up: a +/// versioned enum around a height and a profile. +pub(crate) const ESTIMATED_READINESS_REPORT_RECORD_SIZE: u32 = 16; + +/// The largest serialized size of a readiness scan cursor (59 bytes), rounded up: a versioned +/// enum around two counters, the pagination key and three counts. +pub(crate) const ESTIMATED_READINESS_SCAN_CURSOR_SIZE: u32 = 64; + +/// The number of readiness rounds we expect to be pending at once (one per contract with a +/// pending bundle). +const ESTIMATED_PENDING_ROUNDS: u32 = 1_024; + +/// The number of reports we expect a round to hold at most: every evonode of the network. +const ESTIMATED_REPORTS_PER_ROUND: u32 = 4_096; + +/// The number of entries of the pools tree: one epoch tree per epoch of the perpetual +/// storage window (50 eras of 40 epochs at the default era length) beside the pool items. +const ESTIMATED_POOL_ENTRIES: u32 = 2_048; + +/// The number of entries of an epoch's tree: the proposers tree beside the pool, start and +/// multiplier items. +const ESTIMATED_EPOCH_ENTRIES: u32 = 9; + +impl Drive { + /// Describes every layer a readiness round write can touch: the root, the votes tree, the + /// readiness tree, the contracts tree, the contract's tree (pointer item beside round + /// trees), the round tree (record item, count tree, cursor item), the reports count tree, + /// the deadlines tree and the retired rounds tree. + pub(super) fn add_estimation_costs_for_readiness_v0( + contract_id: [u8; 32], + round_id: [u8; 32], + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path([]), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(3, false), + estimated_layer_sizes: AllSubtrees(1, NoSumTrees, None), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(vote_root_path_vec()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(2, false), + estimated_layer_sizes: AllSubtrees(1, NoSumTrees, None), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(readiness_tree_path_vec()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(2, false), + estimated_layer_sizes: Mix { + subtrees_size: Some((1, NoSumTrees, None, 3)), + items_size: Some((1, DEFAULT_HASH_SIZE_U8 as u32, None, 1)), + references_size: None, + items_with_sum_item_size: None, + references_with_sum_item_size: None, + }, + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(readiness_contracts_tree_path_vec()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(ESTIMATED_PENDING_ROUNDS), + estimated_layer_sizes: AllSubtrees(DEFAULT_HASH_SIZE_U8, NoSumTrees, None), + }, + ); + + // A contract's tree holds the pointer item and normally one round tree, two during + // the batch that replaces a round. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(readiness_contract_tree_path_vec(contract_id)), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(3), + estimated_layer_sizes: Mix { + subtrees_size: Some((DEFAULT_HASH_SIZE_U8, NoSumTrees, None, 2)), + items_size: Some((U8_SIZE_U8, DEFAULT_HASH_SIZE_U8 as u32, None, 1)), + references_size: None, + items_with_sum_item_size: None, + references_with_sum_item_size: None, + }, + }, + ); + + // A round's tree holds the record item, the reports count tree and possibly the + // cursor item. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(readiness_round_tree_path_vec( + contract_id, + round_id, + )), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(3), + estimated_layer_sizes: Mix { + subtrees_size: Some(( + U8_SIZE_U8, + SomeSumTrees { + sum_trees_weight: 0, + big_sum_trees_weight: 0, + count_trees_weight: 1, + count_sum_trees_weight: 0, + non_sum_trees_weight: 0, + provable_sum_trees_weight: 0, + provable_count_trees_weight: 0, + provable_count_sum_trees_weight: 0, + provable_count_provable_sum_trees_weight: 0, + }, + None, + 1, + )), + items_size: Some((U8_SIZE_U8, ESTIMATED_READINESS_ROUND_RECORD_SIZE, None, 2)), + references_size: None, + items_with_sum_item_size: None, + references_with_sum_item_size: None, + }, + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(readiness_round_reports_tree_path_vec( + contract_id, + round_id, + )), + EstimatedLayerInformation { + tree_type: TreeType::CountTree, + estimated_layer_count: ApproximateElements(ESTIMATED_REPORTS_PER_ROUND), + estimated_layer_sizes: AllItems( + DEFAULT_HASH_SIZE_U8, + ESTIMATED_READINESS_REPORT_RECORD_SIZE, + None, + ), + }, + ); + + // Deadlines: one subtree per distinct deadline time holding contract id -> round id + // items. The block time granularity makes distinct times plentiful. + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(readiness_deadlines_tree_path_vec()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(ESTIMATED_PENDING_ROUNDS), + estimated_layer_sizes: AllSubtrees(U64_SIZE_U8, NoSumTrees, None), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(readiness_retired_rounds_tree_path_vec()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(ESTIMATED_PENDING_ROUNDS), + estimated_layer_sizes: AllItems( + DEFAULT_HASH_SIZE_U8, + DEFAULT_HASH_SIZE_U8 as u32, + None, + ), + }, + ); + } + + /// Describes the per-time tree of one activation deadline: contract id keys with round + /// id items, a few per distinct time. + pub(super) fn add_estimation_costs_for_readiness_deadline_v0( + deadline_ms: u64, + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(readiness_deadline_tree_path_vec(deadline_ms)), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(2), + estimated_layer_sizes: AllItems( + DEFAULT_HASH_SIZE_U8, + DEFAULT_HASH_SIZE_U8 as u32, + None, + ), + }, + ); + } + + /// Describes the layers a readiness fund write touches, mirroring the voting fund + /// estimation on the sibling tree. + pub(super) fn add_estimation_costs_for_readiness_fund_update_v0( + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_path([]), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + 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, + 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_readiness_path_vec(), + ), + EstimatedLayerInformation { + tree_type: TreeType::SumTree, + estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_sizes: AllItems(DEFAULT_HASH_SIZE_U8, AVERAGE_BALANCE_SIZE, None), + }, + ); + } + + /// Describes the layers a credit to an epoch's processing fee pool touches: the root + /// (only when no earlier estimation described it), the pools sum tree of epoch trees and + /// the epoch's sum tree, whose pool is a sum item beside the other epoch items. + pub(super) fn add_estimation_costs_for_readiness_pool_credit_v0( + epoch: &Epoch, + estimated_costs_only_with_layer_info: &mut HashMap, + ) { + estimated_costs_only_with_layer_info + .entry(KeyInfoPath::from_known_path([])) + .or_insert(EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + 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_owned_path(pools_vec_path()), + EstimatedLayerInformation { + tree_type: TreeType::SumTree, + estimated_layer_count: ApproximateElements(ESTIMATED_POOL_ENTRIES), + estimated_layer_sizes: AllSubtrees(2, AllSumTrees, None), + }, + ); + + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(epoch.get_path_vec()), + EstimatedLayerInformation { + tree_type: TreeType::SumTree, + estimated_layer_count: ApproximateElements(ESTIMATED_EPOCH_ENTRIES), + estimated_layer_sizes: Mix { + subtrees_size: Some((U8_SIZE_U8, NoSumTrees, None, 1)), + items_size: Some((U8_SIZE_U8, U64_SIZE_U8 as u32, None, 8)), + references_size: None, + items_with_sum_item_size: None, + references_with_sum_item_size: None, + }, + }, + ); + } + + /// Prices the reads of a contract's current readiness round without state and returns a + /// placeholder for the largest round shape it could be: a crossed round (with a deadline + /// entry and time tree to drop) funded by an identity (with a payer to refund). An + /// estimate that retires the current round prices that placeholder's retirement so it + /// covers every stored round. + pub(in crate::drive::votes::readiness) fn estimate_current_readiness_round_v0( + &self, + contract_id: [u8; 32], + block_info: &BlockInfo, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let mut placeholder = ReadinessRound::new( + self.config.network.magic(), + ReadinessRoundOpening { + contract_id: Identifier::new(contract_id), + version: 0, + bundle_digest: [0u8; 32], + preparation_profile: 0, + accepted_at_ms: block_info.time_ms, + accepted_at_height: block_info.height, + payer: ReadinessPayer::Identity(Identifier::new([0u8; 32])), + }, + platform_version, + )?; + placeholder.record_crossing(block_info.time_ms, 0, u64::MAX)?; + let contract_path = readiness_contract_tree_path(&contract_id); + self.grove_get_raw_optional( + (&contract_path).into(), + &[READINESS_CURRENT_ROUND_POINTER_KEY as u8], + DirectQueryType::StatelessDirectQuery { + in_tree_type: TreeType::NormalTree, + query_target: QueryTargetValue(DEFAULT_HASH_SIZE_U32), + }, + transaction, + drive_operations, + &platform_version.drive, + )?; + let round_id = placeholder.round_id(); + let round_path = readiness_round_tree_path(&contract_id, &round_id); + self.grove_get_raw_optional( + (&round_path).into(), + &[READINESS_ROUND_RECORD_KEY], + DirectQueryType::StatelessDirectQuery { + in_tree_type: TreeType::NormalTree, + query_target: QueryTargetValue(ESTIMATED_READINESS_ROUND_RECORD_SIZE), + }, + transaction, + drive_operations, + &platform_version.drive, + )?; + Ok(placeholder) + } + + /// Prices the merk walk of one estimated report delete from a round's count tree. + /// + /// The batch estimate charges the propagation of a layer once, while the merk walks and + /// rehashes the path of every deleted key: this prices that walk per key, with + /// propagation. Applied deletes in one batch share ancestors they rewrite once, so the + /// sum over a batch is an upper bound (about ten times a 512-delete batch); its margin + /// also covers a caller's handful of fixed reads. The caller describes the readiness + /// layers first. + pub(in crate::drive::votes::readiness) fn add_estimated_readiness_report_delete_walk_v0( + contract_id: [u8; 32], + round_id: [u8; 32], + key: &KeyInfo, + estimated_costs_only_with_layer_info: &HashMap, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let reports_path = KeyInfoPath::from_known_owned_path( + readiness_round_reports_tree_path_vec(contract_id, round_id), + ); + let reports_layer = estimated_costs_only_with_layer_info + .get(&reports_path) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "the readiness estimation describes the reports tree", + )))?; + let CostContext { value, cost } = GroveDb::average_case_merk_delete_element( + key, + reports_layer, + true, + &platform_version.drive.grove_version, + ); + value?; + drive_operations.push(CalculatedCostOperation(cost)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::serialization::PlatformSerializable; + use dpp::voting::readiness::report_record::ReadinessReportRecord; + use dpp::voting::readiness::round::ReadinessEvaluation; + use dpp::voting::readiness::scan_cursor::ReadinessScanCursor; + + #[test] + fn should_cover_the_widest_serialized_readiness_records() { + let platform_version = PlatformVersion::latest(); + + // Every integer past u32::MAX takes the widest varint; the round is crossed and + // evaluated so that every optional field is present. + let mut round = ReadinessRound::new( + u32::MAX, + ReadinessRoundOpening { + contract_id: Identifier::new([0xFFu8; 32]), + version: u32::MAX, + bundle_digest: [0xFFu8; 32], + preparation_profile: u16::MAX, + accepted_at_ms: 1 << 40, + accepted_at_height: u64::MAX, + payer: ReadinessPayer::Identity(Identifier::new([0xFFu8; 32])), + }, + platform_version, + ) + .expect("round"); + round.set_evaluation( + ReadinessEvaluation { + core_height: u32::MAX, + raw_count: u64::MAX, + }, + true, + ); + round + .record_crossing(1 << 41, 1 << 40, 1 << 40) + .expect("crossing"); + let round_size = round.serialize_to_bytes().expect("round bytes").len(); + assert_eq!(round_size, 223); + assert!(ESTIMATED_READINESS_ROUND_RECORD_SIZE as usize >= round_size); + + let report = + ReadinessReportRecord::new(u64::MAX, u16::MAX, platform_version).expect("report"); + let report_size = report.serialize_to_bytes().expect("report bytes").len(); + assert_eq!(report_size, 13); + assert!(ESTIMATED_READINESS_REPORT_RECORD_SIZE as usize >= report_size); + + let mut cursor = + ReadinessScanCursor::new(u32::MAX, u32::MAX, platform_version).expect("cursor"); + cursor + .advance([0xFFu8; 32], u32::MAX, u32::MAX / 2 + 1, u32::MAX / 2) + .expect("advance"); + let cursor_size = cursor.serialize_to_bytes().expect("cursor bytes").len(); + assert_eq!(cursor_size, 59); + assert!(ESTIMATED_READINESS_SCAN_CURSOR_SIZE as usize >= cursor_size); + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_evaluation_cursor/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_evaluation_cursor/mod.rs new file mode 100644 index 00000000000..348197ae04f --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_evaluation_cursor/mod.rs @@ -0,0 +1,74 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Fetches the block event's fairness cursor: the last contract id it visited. + /// + /// # Parameters + /// + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` before the first sweep. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn fetch_readiness_evaluation_cursor_operations( + &self, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .fetch_evaluation_cursor + { + Some(0) => self.fetch_readiness_evaluation_cursor_operations_v0( + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_readiness_evaluation_cursor_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_readiness_evaluation_cursor_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Fetches the block event's fairness cursor. + /// + /// # Parameters + /// + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` before the first sweep. + pub fn fetch_readiness_evaluation_cursor( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + self.fetch_readiness_evaluation_cursor_operations( + transaction, + &mut vec![], + platform_version, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_evaluation_cursor/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_evaluation_cursor/v0/mod.rs new file mode 100644 index 00000000000..0c6418d785e --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_evaluation_cursor/v0/mod.rs @@ -0,0 +1,40 @@ +use crate::drive::votes::paths::{readiness_tree_path, READINESS_EVALUATION_CURSOR_KEY}; +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::version::PlatformVersion; +use grovedb::{Element, TransactionArg}; + +impl Drive { + pub(super) fn fetch_readiness_evaluation_cursor_operations_v0( + &self, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = readiness_tree_path(); + match self.grove_get_raw_optional( + (&path).into(), + &[READINESS_EVALUATION_CURSOR_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + )? { + None => Ok(None), + Some(Element::Item(bytes, _)) => { + let contract_id: [u8; 32] = bytes.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "readiness evaluation cursor is not 32 bytes".to_string(), + )) + })?; + Ok(Some(contract_id)) + } + Some(_) => Err(Error::Drive(DriveError::CorruptedElementType( + "readiness evaluation cursor was present but was not an item", + ))), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_reports_page/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_reports_page/mod.rs new file mode 100644 index 00000000000..dfc78e6d170 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_reports_page/mod.rs @@ -0,0 +1,101 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::report_record::ReadinessReportRecord; +use grovedb::TransactionArg; + +impl Drive { + /// Fetches a page of a round's accepted reports in pro tx hash order, starting after + /// `after` when given. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `after` - The last key of the previous page; `None` for the first page. + /// * `limit` - The maximum number of reports to return. + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The reports of the page, in key order. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + #[allow(clippy::too_many_arguments)] + pub fn fetch_readiness_reports_page_operations( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + after: Option<[u8; 32]>, + limit: u16, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .fetch_reports_page + { + Some(0) => self.fetch_readiness_reports_page_operations_v0( + contract_id, + round_id, + after, + limit, + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_readiness_reports_page_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_readiness_reports_page_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Fetches a page of a round's accepted reports in pro tx hash order. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `after` - The last key of the previous page; `None` for the first page. + /// * `limit` - The maximum number of reports to return. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The reports of the page, in key order. + pub fn fetch_readiness_reports_page( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + after: Option<[u8; 32]>, + limit: u16, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + self.fetch_readiness_reports_page_operations( + contract_id, + round_id, + after, + limit, + transaction, + &mut vec![], + platform_version, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_reports_page/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_reports_page/v0/mod.rs new file mode 100644 index 00000000000..beeafe9104e --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_reports_page/v0/mod.rs @@ -0,0 +1,77 @@ +use crate::drive::votes::paths::readiness_round_reports_tree_path_vec; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::serialization::PlatformDeserializableTrusted; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::report_record::ReadinessReportRecord; +use grovedb::query_result_type::QueryResultType; +use grovedb::{Element, PathQuery, Query, SizedQuery, TransactionArg}; + +impl Drive { + #[allow(clippy::too_many_arguments)] + pub(super) fn fetch_readiness_reports_page_operations_v0( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + after: Option<[u8; 32]>, + limit: u16, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if limit == 0 { + return Ok(vec![]); + } + let mut query = Query::new_with_direction(true); + match after { + Some(after) => query.insert_range_after(after.to_vec()..), + None => query.insert_all(), + } + let path_query = PathQuery::new( + readiness_round_reports_tree_path_vec(contract_id, round_id), + SizedQuery::new(query, Some(limit), None), + ); + let (elements, _) = match self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + drive_operations, + &platform_version.drive, + ) { + Ok(result) => result, + Err(Error::GroveDB(e)) + if matches!( + e.as_ref(), + grovedb::Error::PathParentLayerNotFound(_) + | grovedb::Error::PathKeyNotFound(_) + | grovedb::Error::InvalidParentLayerPath(_) + ) => + { + return Ok(vec![]); + } + Err(e) => return Err(e), + }; + elements + .to_key_elements() + .into_iter() + .map(|(key, element)| { + let pro_tx_hash: [u8; 32] = key.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "readiness report key is not 32 bytes".to_string(), + )) + })?; + match element { + Element::Item(bytes, _) => { + let record = ReadinessReportRecord::deserialize_from_bytes_trusted(&bytes)?; + Ok((pro_tx_hash, record)) + } + _ => Err(Error::Drive(DriveError::CorruptedElementType( + "readiness report was present but was not an item", + ))), + } + }) + .collect() + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round/mod.rs new file mode 100644 index 00000000000..047038bf630 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round/mod.rs @@ -0,0 +1,158 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::TransactionArg; + +impl Drive { + /// Fetches a contract's current compilation readiness round: the pointer, then the record + /// under the round it names. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` when the contract has no round. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn fetch_readiness_round( + &self, + contract_id: [u8; 32], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations = vec![]; + self.fetch_readiness_round_operations( + contract_id, + transaction, + &mut drive_operations, + platform_version, + ) + } + + /// Fetches a contract's current round, accumulating the cost of the reads. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read costs are appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` when the contract has no round. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn fetch_readiness_round_operations( + &self, + contract_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version.drive.methods.vote.readiness.fetch_round { + Some(0) => self.fetch_readiness_round_operations_v0( + contract_id, + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_readiness_round_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_readiness_round_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Fetches the round id a contract's pointer names, accumulating the cost of the read. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` when the contract has no round. + pub fn fetch_readiness_current_round_id_operations( + &self, + contract_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version.drive.methods.vote.readiness.fetch_round { + Some(0) => self.fetch_readiness_current_round_id_operations_v0( + contract_id, + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_readiness_current_round_id_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_readiness_current_round_id_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Fetches the record of a round by contract id and round id, accumulating the cost of + /// the read. Used for retired rounds that the pointer no longer names. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` when no such round tree or record exists. + pub fn fetch_readiness_round_record_operations( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version.drive.methods.vote.readiness.fetch_round { + Some(0) => self.fetch_readiness_round_record_operations_v0( + contract_id, + round_id, + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_readiness_round_record_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_readiness_round_record_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round/v0/mod.rs new file mode 100644 index 00000000000..6f3d987e74c --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round/v0/mod.rs @@ -0,0 +1,133 @@ +use crate::drive::votes::paths::{ + readiness_contract_tree_path, readiness_round_tree_path, READINESS_CURRENT_ROUND_POINTER_KEY, + READINESS_ROUND_RECORD_KEY, +}; +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::serialization::PlatformDeserializableTrusted; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::{Element, TransactionArg}; + +impl Drive { + pub(super) fn fetch_readiness_round_operations_v0( + &self, + contract_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(round_id) = self.fetch_readiness_current_round_id_operations_v0( + contract_id, + transaction, + drive_operations, + platform_version, + )? + else { + return Ok(None); + }; + let round = self.fetch_readiness_round_record_operations_v0( + contract_id, + round_id, + transaction, + drive_operations, + platform_version, + )?; + // The pointer names a round; its record must exist. + round + .map(Some) + .ok_or(Error::Drive(DriveError::CorruptedDriveState( + "readiness round pointer names a round with no record".to_string(), + ))) + } + + pub(super) fn fetch_readiness_current_round_id_operations_v0( + &self, + contract_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = readiness_contract_tree_path(&contract_id); + let element = match self.grove_get_raw_optional( + (&path).into(), + &[READINESS_CURRENT_ROUND_POINTER_KEY as u8], + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + ) { + Ok(element) => element, + // The contract has no readiness tree at all. + Err(Error::GroveDB(e)) + if matches!( + e.as_ref(), + grovedb::Error::PathParentLayerNotFound(_) + | grovedb::Error::PathKeyNotFound(_) + | grovedb::Error::InvalidParentLayerPath(_) + ) => + { + None + } + Err(e) => return Err(e), + }; + match element { + None => Ok(None), + Some(Element::Item(bytes, _)) => { + let round_id: [u8; 32] = bytes.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "readiness round pointer is not 32 bytes".to_string(), + )) + })?; + Ok(Some(round_id)) + } + Some(_) => Err(Error::Drive(DriveError::CorruptedElementType( + "readiness round pointer was present but was not an item", + ))), + } + } + + pub(super) fn fetch_readiness_round_record_operations_v0( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = readiness_round_tree_path(&contract_id, &round_id); + let element = match self.grove_get_raw_optional( + (&path).into(), + &[READINESS_ROUND_RECORD_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + ) { + Ok(element) => element, + Err(Error::GroveDB(e)) + if matches!( + e.as_ref(), + grovedb::Error::PathParentLayerNotFound(_) + | grovedb::Error::PathKeyNotFound(_) + | grovedb::Error::InvalidParentLayerPath(_) + ) => + { + None + } + Err(e) => return Err(e), + }; + match element { + None => Ok(None), + Some(Element::Item(bytes, _)) => Ok(Some( + ReadinessRound::deserialize_from_bytes_trusted(&bytes)?, + )), + Some(_) => Err(Error::Drive(DriveError::CorruptedElementType( + "readiness round record was present but was not an item", + ))), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round_raw_count/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round_raw_count/mod.rs new file mode 100644 index 00000000000..e02eb45635c --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round_raw_count/mod.rs @@ -0,0 +1,89 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Fetches the raw distinct report count of a round: the count of its reports count tree. + /// One key per pro tx hash, so a retransmission cannot inflate it. The count is not an + /// eligibility proof; the block event validates the reporters against the membership + /// view before a crossing. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The count, `0` when the round has no reports tree. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn fetch_readiness_round_raw_count_operations( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .vote + .readiness + .fetch_raw_count + { + Some(0) => self.fetch_readiness_round_raw_count_operations_v0( + contract_id, + round_id, + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_readiness_round_raw_count_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_readiness_round_raw_count_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Fetches the raw distinct report count of a round. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The count, `0` when the round has no reports tree. + pub fn fetch_readiness_round_raw_count( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + self.fetch_readiness_round_raw_count_operations( + contract_id, + round_id, + transaction, + &mut vec![], + platform_version, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round_raw_count/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round_raw_count/v0/mod.rs new file mode 100644 index 00000000000..a54e97a2a49 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_round_raw_count/v0/mod.rs @@ -0,0 +1,49 @@ +use crate::drive::votes::paths::{readiness_round_tree_path, READINESS_ROUND_REPORTS_TREE_KEY}; +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::version::PlatformVersion; +use grovedb::{Element, TransactionArg}; + +impl Drive { + pub(super) fn fetch_readiness_round_raw_count_operations_v0( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + let path = readiness_round_tree_path(&contract_id, &round_id); + let element = match self.grove_get_raw_optional( + (&path).into(), + &[READINESS_ROUND_REPORTS_TREE_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + ) { + Ok(element) => element, + Err(Error::GroveDB(e)) + if matches!( + e.as_ref(), + grovedb::Error::PathParentLayerNotFound(_) + | grovedb::Error::PathKeyNotFound(_) + | grovedb::Error::InvalidParentLayerPath(_) + ) => + { + None + } + Err(e) => return Err(e), + }; + match element { + None => Ok(0), + Some(Element::CountTree(_, count, _)) => Ok(count), + Some(_) => Err(Error::Drive(DriveError::CorruptedElementType( + "readiness reports tree was present but was not a count tree", + ))), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_due/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_due/mod.rs new file mode 100644 index 00000000000..dc65b0afc30 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_due/mod.rs @@ -0,0 +1,99 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +/// One entry of the activation deadline queue. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReadinessRoundDue { + /// The deadline the entry sits under. + pub deadline_ms: u64, + /// The contract. + pub contract_id: [u8; 32], + /// The round the entry was queued for. The block event checks it is still the + /// contract's current round before activating; a stale entry is dropped. + pub round_id: [u8; 32], +} + +impl Drive { + /// Fetches up to `limit` deadline queue entries whose deadline is at or before `at_ms`, + /// oldest first. + /// + /// # Parameters + /// + /// * `at_ms` - The committed block time. + /// * `limit` - The maximum number of entries to return. + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The due entries, oldest first. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn fetch_readiness_rounds_due_operations( + &self, + at_ms: u64, + limit: u16, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .fetch_rounds_due + { + Some(0) => self.fetch_readiness_rounds_due_operations_v0( + at_ms, + limit, + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_readiness_rounds_due_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_readiness_rounds_due_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Fetches up to `limit` deadline queue entries whose deadline is at or before `at_ms`. + /// + /// # Parameters + /// + /// * `at_ms` - The committed block time. + /// * `limit` - The maximum number of entries to return. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The due entries, oldest first. + pub fn fetch_readiness_rounds_due( + &self, + at_ms: u64, + limit: u16, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + self.fetch_readiness_rounds_due_operations( + at_ms, + limit, + transaction, + &mut vec![], + platform_version, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_due/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_due/v0/mod.rs new file mode 100644 index 00000000000..b9158a74ba5 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_due/v0/mod.rs @@ -0,0 +1,81 @@ +use crate::drive::votes::paths::readiness_deadlines_tree_path_vec; +use crate::drive::votes::readiness::fetch_readiness_rounds_due::ReadinessRoundDue; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::common::encode::{decode_u64, encode_u64}; +use dpp::version::PlatformVersion; +use grovedb::query_result_type::QueryResultType; +use grovedb::{Element, PathQuery, Query, SizedQuery, TransactionArg}; + +impl Drive { + pub(super) fn fetch_readiness_rounds_due_operations_v0( + &self, + at_ms: u64, + limit: u16, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if limit == 0 { + return Ok(vec![]); + } + // Same shape as the vote poll end-date queue: a range over the time keys with a + // subquery over every entry beneath each of them. + let mut query = Query::new_with_direction(true); + query.insert_range_to_inclusive(..=encode_u64(at_ms)); + let mut sub_query = Query::new(); + sub_query.insert_all(); + query.default_subquery_branch.subquery = Some(sub_query.into()); + let path_query = PathQuery { + path: readiness_deadlines_tree_path_vec(), + query: SizedQuery { + query, + limit: Some(limit), + offset: None, + }, + }; + let (elements, _) = self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryPathKeyElementTrioResultType, + drive_operations, + &platform_version.drive, + )?; + elements + .to_path_key_elements() + .into_iter() + .map(|(path, key, element)| { + let time_key = path + .last() + .ok_or(Error::Drive(DriveError::CorruptedDriveState( + "readiness deadline entry has no time key".to_string(), + )))?; + let deadline_ms = decode_u64(time_key)?; + let contract_id: [u8; 32] = key.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "readiness deadline entry key is not 32 bytes".to_string(), + )) + })?; + let round_id: [u8; 32] = match element { + Element::Item(bytes, _) => bytes.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "readiness deadline entry value is not 32 bytes".to_string(), + )) + })?, + _ => { + return Err(Error::Drive(DriveError::CorruptedElementType( + "readiness deadline entry was present but was not an item", + ))) + } + }; + Ok(ReadinessRoundDue { + deadline_ms, + contract_id, + round_id, + }) + }) + .collect() + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_page/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_page/mod.rs new file mode 100644 index 00000000000..fdeb9b452f8 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_page/mod.rs @@ -0,0 +1,88 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Fetches a page of the contracts holding a readiness tree, in contract id order, + /// starting after `after` when given. The block event walks rounds with it, wrapping to + /// the start when a page comes back short. + /// + /// # Parameters + /// + /// * `after` - The last contract id of the previous page; `None` for the first page. + /// * `limit` - The maximum number of contract ids to return. + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The contract ids of the page, in key order. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn fetch_readiness_rounds_page_operations( + &self, + after: Option<[u8; 32]>, + limit: u16, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .fetch_rounds_page + { + Some(0) => self.fetch_readiness_rounds_page_operations_v0( + after, + limit, + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_readiness_rounds_page_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_readiness_rounds_page_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Fetches a page of the contracts holding a readiness tree, in contract id order. + /// + /// # Parameters + /// + /// * `after` - The last contract id of the previous page; `None` for the first page. + /// * `limit` - The maximum number of contract ids to return. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The contract ids of the page, in key order. + pub fn fetch_readiness_rounds_page( + &self, + after: Option<[u8; 32]>, + limit: u16, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + self.fetch_readiness_rounds_page_operations( + after, + limit, + transaction, + &mut vec![], + platform_version, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_page/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_page/v0/mod.rs new file mode 100644 index 00000000000..6882ce951b5 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_rounds_page/v0/mod.rs @@ -0,0 +1,50 @@ +use crate::drive::votes::paths::readiness_contracts_tree_path_vec; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::query_result_type::QueryResultType; +use grovedb::{PathQuery, Query, SizedQuery, TransactionArg}; + +impl Drive { + pub(super) fn fetch_readiness_rounds_page_operations_v0( + &self, + after: Option<[u8; 32]>, + limit: u16, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if limit == 0 { + return Ok(vec![]); + } + let mut query = Query::new_with_direction(true); + match after { + Some(after) => query.insert_range_after(after.to_vec()..), + None => query.insert_all(), + } + let path_query = PathQuery::new( + readiness_contracts_tree_path_vec(), + SizedQuery::new(query, Some(limit), None), + ); + let (elements, _) = self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + drive_operations, + &platform_version.drive, + )?; + elements + .to_keys() + .into_iter() + .map(|key| { + key.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "readiness contract key is not 32 bytes".to_string(), + )) + }) + }) + .collect() + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_scan_cursor/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_scan_cursor/mod.rs new file mode 100644 index 00000000000..471612561ab --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_scan_cursor/mod.rs @@ -0,0 +1,87 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::scan_cursor::ReadinessScanCursor; +use grovedb::TransactionArg; + +impl Drive { + /// Fetches the persisted position of a round's paged eligibility walk. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` when no walk is open. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn fetch_readiness_scan_cursor_operations( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .fetch_scan_cursor + { + Some(0) => self.fetch_readiness_scan_cursor_operations_v0( + contract_id, + round_id, + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_readiness_scan_cursor_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_readiness_scan_cursor_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Fetches the persisted position of a round's paged eligibility walk. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` when no walk is open. + pub fn fetch_readiness_scan_cursor( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + self.fetch_readiness_scan_cursor_operations( + contract_id, + round_id, + transaction, + &mut vec![], + platform_version, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_scan_cursor/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_scan_cursor/v0/mod.rs new file mode 100644 index 00000000000..d4e1d31190b --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_readiness_scan_cursor/v0/mod.rs @@ -0,0 +1,53 @@ +use crate::drive::votes::paths::{readiness_round_tree_path, READINESS_ROUND_SCAN_CURSOR_KEY}; +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::serialization::PlatformDeserializableTrusted; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::scan_cursor::ReadinessScanCursor; +use grovedb::{Element, TransactionArg}; + +impl Drive { + pub(super) fn fetch_readiness_scan_cursor_operations_v0( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = readiness_round_tree_path(&contract_id, &round_id); + let element = match self.grove_get_raw_optional( + (&path).into(), + &[READINESS_ROUND_SCAN_CURSOR_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + ) { + Ok(element) => element, + Err(Error::GroveDB(e)) + if matches!( + e.as_ref(), + grovedb::Error::PathParentLayerNotFound(_) + | grovedb::Error::PathKeyNotFound(_) + | grovedb::Error::InvalidParentLayerPath(_) + ) => + { + None + } + Err(e) => return Err(e), + }; + match element { + None => Ok(None), + Some(Element::Item(bytes, _)) => Ok(Some( + ReadinessScanCursor::deserialize_from_bytes_trusted(&bytes)?, + )), + Some(_) => Err(Error::Drive(DriveError::CorruptedElementType( + "readiness scan cursor was present but was not an item", + ))), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_retired_readiness_round/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_retired_readiness_round/mod.rs new file mode 100644 index 00000000000..561acd1ea97 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_retired_readiness_round/mod.rs @@ -0,0 +1,79 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +/// One entry of the retired-round cleanup queue. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RetiredReadinessRound { + /// The retired round. + pub round_id: [u8; 32], + /// Its contract. + pub contract_id: [u8; 32], +} + +impl Drive { + /// Fetches the first retired round awaiting cleanup: its round id and contract id. + /// + /// # Parameters + /// + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` when the queue is empty. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn fetch_retired_readiness_round_operations( + &self, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .fetch_retired_round + { + Some(0) => self.fetch_retired_readiness_round_operations_v0( + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_retired_readiness_round_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_retired_readiness_round_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Fetches the first retired round awaiting cleanup. + /// + /// # Parameters + /// + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` when the queue is empty. + pub fn fetch_retired_readiness_round( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + self.fetch_retired_readiness_round_operations(transaction, &mut vec![], platform_version) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fetch_retired_readiness_round/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fetch_retired_readiness_round/v0/mod.rs new file mode 100644 index 00000000000..2d5ac179a9f --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fetch_retired_readiness_round/v0/mod.rs @@ -0,0 +1,56 @@ +use crate::drive::votes::paths::readiness_retired_rounds_tree_path_vec; +use crate::drive::votes::readiness::fetch_retired_readiness_round::RetiredReadinessRound; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::query_result_type::QueryResultType; +use grovedb::{Element, PathQuery, Query, SizedQuery, TransactionArg}; + +impl Drive { + pub(super) fn fetch_retired_readiness_round_operations_v0( + &self, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut query = Query::new_with_direction(true); + query.insert_all(); + let path_query = PathQuery::new( + readiness_retired_rounds_tree_path_vec(), + SizedQuery::new(query, Some(1), None), + ); + let (elements, _) = self.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + drive_operations, + &platform_version.drive, + )?; + let Some((key, element)) = elements.to_key_elements().into_iter().next() else { + return Ok(None); + }; + let round_id: [u8; 32] = key.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "retired readiness round key is not 32 bytes".to_string(), + )) + })?; + let contract_id: [u8; 32] = match element { + Element::Item(bytes, _) => bytes.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState( + "retired readiness round value is not 32 bytes".to_string(), + )) + })?, + _ => { + return Err(Error::Drive(DriveError::CorruptedElementType( + "retired readiness round entry was present but was not an item", + ))) + } + }; + Ok(Some(RetiredReadinessRound { + round_id, + contract_id, + })) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/add_readiness_fund_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/add_readiness_fund_operations/mod.rs new file mode 100644 index 00000000000..14ba6f3ce11 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/add_readiness_fund_operations/mod.rs @@ -0,0 +1,97 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Creates a readiness fund or adds to an existing one. + /// + /// In estimation mode the balance read is priced without state and the write is priced + /// as an insert of the widest sum item, which covers the applied write of any balance. + /// + /// # Parameters + /// + /// * `fund_id` - The fund, derived from the round id. + /// * `amount` - The credits to add. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The low level operations that perform the write. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn add_readiness_fund_operations( + &self, + fund_id: Identifier, + amount: u64, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version.drive.methods.vote.readiness.add_fund { + Some(0) => self.add_readiness_fund_operations_v0( + fund_id, + amount, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "add_readiness_fund_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "add_readiness_fund_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Creates a readiness fund or adds to an existing one and applies the write. + /// + /// # Parameters + /// + /// * `fund_id` - The fund, derived from the round id. + /// * `amount` - The credits to add. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(())` once the write is applied. + pub fn add_readiness_fund( + &self, + fund_id: Identifier, + amount: u64, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let batch_operations = self.add_readiness_fund_operations( + fund_id, + amount, + &mut None, + transaction, + platform_version, + )?; + let grove_db_operations = + LowLevelDriveOperation::grovedb_operations_batch_consume(batch_operations); + self.grove_apply_batch_with_add_costs( + grove_db_operations, + false, + transaction, + &mut vec![], + &platform_version.drive, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/add_readiness_fund_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/add_readiness_fund_operations/v0/mod.rs new file mode 100644 index 00000000000..934186323dd --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/add_readiness_fund_operations/v0/mod.rs @@ -0,0 +1,88 @@ +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_for_readiness_path_vec; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::identity::IdentityError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use dpp::balances::credits::MAX_CREDITS; +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_readiness_fund_operations_v0( + &self, + fund_id: Identifier, + amount: u64, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + // No stored balance makes this amount valid, so an estimate refuses it as execution does. + if amount >= MAX_CREDITS { + return Err(Error::Identity(IdentityError::CriticalBalanceOverflow( + "trying to set a readiness fund to over max credits amount (i64::MAX)", + ))); + } + 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_readiness_fund_update( + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + + // The checked read: a stored fund must be a nonnegative sum item. + let previous_credits = self.fetch_readiness_fund_operations( + fund_id.to_buffer(), + estimated_costs_only_with_layer_info.is_none(), + transaction, + &mut drive_operations, + platform_version, + )?; + let path_vec = prefunded_specialized_balances_for_readiness_path_vec(); + if estimated_costs_only_with_layer_info.is_some() { + // The estimator bills a plain insert by the element's serialized size while the + // applied write bills the fixed sum item size, so price the widest sum item. + drive_operations.push(GroveOperation(QualifiedGroveDbOp::insert_or_replace_op( + path_vec, + fund_id.to_vec(), + Element::new_sum_item(i64::MAX), + ))); + return Ok(drive_operations); + } + let had_previous_balance = previous_credits.is_some(); + let new_total = previous_credits + .unwrap_or_default() + .checked_add(amount) + .ok_or(Error::Drive(DriveError::CriticalCorruptedState( + "trying to add an amount that would overflow credits", + )))?; + if new_total >= MAX_CREDITS { + return Err(Error::Identity(IdentityError::CriticalBalanceOverflow( + "trying to set a readiness fund to over max credits amount (i64::MAX)", + ))); + }; + let op = if had_previous_balance { + QualifiedGroveDbOp::replace_op( + path_vec, + fund_id.to_vec(), + Element::new_sum_item(new_total as i64), + ) + } else { + QualifiedGroveDbOp::insert_or_replace_op( + path_vec, + fund_id.to_vec(), + Element::new_sum_item(new_total as i64), + ) + }; + drive_operations.push(GroveOperation(op)); + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/deduct_from_readiness_fund_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/deduct_from_readiness_fund_operations/mod.rs new file mode 100644 index 00000000000..9714d9dd121 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/deduct_from_readiness_fund_operations/mod.rs @@ -0,0 +1,73 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Deducts from a readiness fund, keeping `reserve` untouchable. + /// + /// The reserve is the cleanup reserve charged when the round retires; reports and + /// membership pages may only spend what lies above it, so a round can always pay for its + /// own deferred deletion. + /// + /// # Parameters + /// + /// * `fund_id` - The fund, derived from the round id. + /// * `amount` - The credits to deduct. + /// * `reserve` - The credits that must remain after the deduction. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The low level operations that perform the write. + /// * `Err(DriveError::PrefundedSpecializedBalanceNotEnough)` when the fund minus the + /// reserve cannot cover the amount. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn deduct_from_readiness_fund_operations( + &self, + fund_id: Identifier, + amount: Credits, + reserve: Credits, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .deduct_from_fund + { + Some(0) => self.deduct_from_readiness_fund_operations_v0( + fund_id, + amount, + reserve, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "deduct_from_readiness_fund_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "deduct_from_readiness_fund_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/deduct_from_readiness_fund_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/deduct_from_readiness_fund_operations/v0/mod.rs new file mode 100644 index 00000000000..7f16aebefd8 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/deduct_from_readiness_fund_operations/v0/mod.rs @@ -0,0 +1,73 @@ +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_for_readiness_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 dpp::fee::Credits; +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_readiness_fund_operations_v0( + &self, + fund_id: Identifier, + amount: Credits, + reserve: 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_readiness_fund_update( + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + + // The checked read: a stored fund must be a nonnegative sum item. + let fetched = self.fetch_readiness_fund_operations( + fund_id.to_buffer(), + estimated_costs_only_with_layer_info.is_none(), + transaction, + &mut drive_operations, + platform_version, + )?; + let previous_credits = if estimated_costs_only_with_layer_info.is_some() { + // Priced without state; the widest balance lets any deduction through. + i64::MAX as u64 + } else { + fetched.ok_or_else(|| { + Error::Drive(DriveError::PrefundedSpecializedBalanceDoesNotExist( + format!( + "trying to deduct from a readiness fund {} that does not exist", + fund_id + ), + )) + })? + }; + let spendable = previous_credits.saturating_sub(reserve); + if amount > spendable { + return Err(Error::Drive( + DriveError::PrefundedSpecializedBalanceNotEnough(spendable, amount), + )); + } + let new_total = previous_credits.checked_sub(amount).ok_or(Error::Drive( + DriveError::PrefundedSpecializedBalanceNotEnough(previous_credits, amount), + ))?; + let replace_op = QualifiedGroveDbOp::replace_op( + prefunded_specialized_balances_for_readiness_path_vec(), + fund_id.to_vec(), + Element::new_sum_item(new_total as i64), + ); + drive_operations.push(GroveOperation(replace_op)); + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/empty_readiness_fund_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/empty_readiness_fund_operations/mod.rs new file mode 100644 index 00000000000..aec46e8dd33 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/empty_readiness_fund_operations/mod.rs @@ -0,0 +1,58 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Empties a readiness fund, deleting its entry. + /// + /// # Parameters + /// + /// * `fund_id` - The fund, derived from the round id. + /// * `error_if_does_not_exist` - Whether a missing fund is an error or an empty result. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The credits the fund held and the low level operations that delete it. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn empty_readiness_fund_operations( + &self, + fund_id: Identifier, + error_if_does_not_exist: bool, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Credits, Vec), Error> { + match platform_version.drive.methods.vote.readiness.empty_fund { + Some(0) => self.empty_readiness_fund_operations_v0( + fund_id, + error_if_does_not_exist, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "empty_readiness_fund_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "empty_readiness_fund_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/empty_readiness_fund_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/empty_readiness_fund_operations/v0/mod.rs new file mode 100644 index 00000000000..90828093ec2 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/empty_readiness_fund_operations/v0/mod.rs @@ -0,0 +1,67 @@ +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_for_readiness_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 dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + pub(super) fn empty_readiness_fund_operations_v0( + &self, + fund_id: Identifier, + error_if_does_not_exist: bool, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Credits, Vec), 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_readiness_fund_update( + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + // The checked read: a stored fund must be a nonnegative sum item. An estimate prices + // the read without state and sees an empty fund. + let previous_credits = match self.fetch_readiness_fund_operations( + fund_id.to_buffer(), + estimated_costs_only_with_layer_info.is_none(), + transaction, + &mut drive_operations, + platform_version, + )? { + None => { + if estimated_costs_only_with_layer_info.is_none() { + return if error_if_does_not_exist { + Err(Error::Drive( + DriveError::PrefundedSpecializedBalanceDoesNotExist(format!( + "trying to empty a readiness fund {} that does not exist", + fund_id + )), + )) + } else { + Ok((0, drive_operations)) + }; + } else { + 0 + } + } + Some(value) => value, + }; + let delete_op = QualifiedGroveDbOp::delete_op( + prefunded_specialized_balances_for_readiness_path_vec(), + fund_id.to_vec(), + ); + drive_operations.push(GroveOperation(delete_op)); + Ok((previous_credits, drive_operations)) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/fetch_readiness_fund/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/fetch_readiness_fund/mod.rs new file mode 100644 index 00000000000..d2c41ab6c8c --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/fetch_readiness_fund/mod.rs @@ -0,0 +1,82 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Fetches a readiness fund balance. + /// + /// # Parameters + /// + /// * `fund_id` - The fund, derived from the round id. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` when no such fund exists. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn fetch_readiness_fund( + &self, + fund_id: [u8; 32], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations = vec![]; + self.fetch_readiness_fund_operations( + fund_id, + true, + transaction, + &mut drive_operations, + platform_version, + ) + } + + /// Fetches a readiness fund balance, accumulating the cost of the read. With + /// `apply = false` the read is priced without touching state and returns `Some(0)`. + /// + /// # Parameters + /// + /// * `fund_id` - The fund, derived from the round id. + /// * `apply` - Whether to read state (`true`) or only estimate the cost (`false`). + /// * `transaction` - The current transaction. + /// * `drive_operations` - The accumulator the read cost is appended to. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * `Ok(None)` when no such fund exists. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn fetch_readiness_fund_operations( + &self, + fund_id: [u8; 32], + apply: bool, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version.drive.methods.vote.readiness.fetch_fund { + Some(0) => self.fetch_readiness_fund_operations_v0( + fund_id, + apply, + transaction, + drive_operations, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_readiness_fund_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_readiness_fund_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/fetch_readiness_fund/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/fetch_readiness_fund/v0/mod.rs new file mode 100644 index 00000000000..39474706ba3 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/fetch_readiness_fund/v0/mod.rs @@ -0,0 +1,66 @@ +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_for_readiness_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 crate::util::grove_operations::QueryTarget::QueryTargetValue; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use grovedb::Element::SumItem; +use grovedb::{TransactionArg, TreeType}; + +impl Drive { + pub(super) fn fetch_readiness_fund_operations_v0( + &self, + fund_id: [u8; 32], + apply: bool, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let direct_query_type = if apply { + DirectQueryType::StatefulDirectQuery + } else { + // 8 is the size of a i64 used in sum trees + DirectQueryType::StatelessDirectQuery { + in_tree_type: TreeType::SumTree, + query_target: QueryTargetValue(8), + } + }; + + let path = prefunded_specialized_balances_for_readiness_path(); + + match self.grove_get_raw_optional( + (&path).into(), + fund_id.as_slice(), + direct_query_type, + transaction, + drive_operations, + &platform_version.drive, + ) { + Ok(Some(SumItem(balance, _))) if balance >= 0 => Ok(Some(balance as Credits)), + Ok(None) => { + if apply { + Ok(None) + } else { + Ok(Some(0)) + } + } + Err(Error::GroveDB(e)) if matches!(e.as_ref(), grovedb::Error::PathKeyNotFound(_)) => { + if apply { + Ok(None) + } else { + Ok(Some(0)) + } + } + Ok(Some(SumItem(..))) => Err(Error::Drive(DriveError::CorruptedElementType( + "readiness fund was present but was negative", + ))), + Ok(Some(_)) => Err(Error::Drive(DriveError::CorruptedElementType( + "readiness fund was present but was not identified as a sum item", + ))), + Err(e) => Err(e), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/mod.rs new file mode 100644 index 00000000000..b28a854d8cb --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/mod.rs @@ -0,0 +1,10 @@ +//! Readiness funds: one sum item per round under `[PreFundedSpecializedBalances] / 129`, +//! keyed by the fund id the round derives. Each method is a copy of its voting sibling on +//! `[40, 128]`; the two families stay separate rather than sharing a path flag so that the +//! shipped voting generations remain byte-identical. + +mod add_readiness_fund_operations; +mod deduct_from_readiness_fund_operations; +mod empty_readiness_fund_operations; +mod fetch_readiness_fund; +mod prove_readiness_fund; diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/prove_readiness_fund/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/prove_readiness_fund/mod.rs new file mode 100644 index 00000000000..47198460b71 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/prove_readiness_fund/mod.rs @@ -0,0 +1,41 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Proves a readiness fund balance. + /// + /// # Parameters + /// + /// * `fund_id` - The fund, derived from the round id. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The proof bytes. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn prove_readiness_fund( + &self, + fund_id: [u8; 32], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version.drive.methods.vote.readiness.prove_fund { + Some(0) => self.prove_readiness_fund_v0(fund_id, transaction, platform_version), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "prove_readiness_fund".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "prove_readiness_fund".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/fund/prove_readiness_fund/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/fund/prove_readiness_fund/v0/mod.rs new file mode 100644 index 00000000000..fcaf75f018c --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/fund/prove_readiness_fund/v0/mod.rs @@ -0,0 +1,22 @@ +use crate::drive::votes::readiness::queries::readiness_fund_path_query; +use crate::drive::Drive; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + pub(super) fn prove_readiness_fund_v0( + &self, + fund_id: [u8; 32], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path_query = readiness_fund_path_query(fund_id); + self.grove_get_proved_path_query( + &path_query, + transaction, + &mut vec![], + &platform_version.drive, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/insert_readiness_report_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/insert_readiness_report_operations/mod.rs new file mode 100644 index 00000000000..8038933132a --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/insert_readiness_report_operations/mod.rs @@ -0,0 +1,66 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::report_record::ReadinessReportRecord; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Inserts one accepted readiness report into a round's count tree if absent. A report + /// already present (a retransmission) is a no-op that returns `false`; the count tree's + /// count therefore stays the number of distinct reporters. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round; the caller has checked it is the contract's current round. + /// * `pro_tx_hash` - The reporting evonode. + /// * `record` - What to store for the report. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * Whether the report was new and the low level operations that perform the insert. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + #[allow(clippy::too_many_arguments)] + pub fn insert_readiness_report_operations( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + pro_tx_hash: [u8; 32], + record: &ReadinessReportRecord, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(bool, Vec), Error> { + match platform_version.drive.methods.vote.readiness.insert_report { + Some(0) => self.insert_readiness_report_operations_v0( + contract_id, + round_id, + pro_tx_hash, + record, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "insert_readiness_report_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "insert_readiness_report_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/insert_readiness_report_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/insert_readiness_report_operations/v0/mod.rs new file mode 100644 index 00000000000..62dccb303b9 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/insert_readiness_report_operations/v0/mod.rs @@ -0,0 +1,74 @@ +use crate::drive::votes::paths::readiness_round_reports_tree_path_vec; +use crate::drive::votes::readiness::estimation_costs::ESTIMATED_READINESS_REPORT_RECORD_SIZE; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchInsertApplyType; +use crate::util::grove_operations::QueryTarget::QueryTargetValue; +use crate::util::object_size_info::PathKeyElementInfo; +use crate::util::object_size_info::PathKeyElementInfo::{PathKeyElementSize, PathKeyRefElement}; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; +use dpp::serialization::PlatformSerializable; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::report_record::ReadinessReportRecord; +use grovedb::batch::key_info::KeyInfo; +use grovedb::batch::KeyInfoPath; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + #[allow(clippy::too_many_arguments)] + pub(super) fn insert_readiness_report_operations_v0( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + pro_tx_hash: [u8; 32], + record: &ReadinessReportRecord, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(bool, Vec), 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_readiness( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + let element = Element::new_item(record.serialize_to_bytes()?); + let reports_path = readiness_round_reports_tree_path_vec(contract_id, round_id); + let apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertApplyType::StatefulBatchInsert + } else { + BatchInsertApplyType::StatelessBatchInsert { + in_tree_type: TreeType::CountTree, + target: QueryTargetValue(ESTIMATED_READINESS_REPORT_RECORD_SIZE), + } + }; + let path_key_element_info: PathKeyElementInfo<'_, 0> = + if estimated_costs_only_with_layer_info.is_none() { + PathKeyRefElement((reports_path, pro_tx_hash.as_slice(), element)) + } else { + PathKeyElementSize(( + KeyInfoPath::from_known_owned_path(reports_path), + KeyInfo::MaxKeySize { + unique_id: pro_tx_hash.to_vec(), + max_size: DEFAULT_HASH_SIZE_U8, + }, + element, + )) + }; + let inserted = self.batch_insert_if_not_exists( + path_key_element_info, + apply_type, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + Ok((inserted, drive_operations)) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/mod.rs b/packages/rs-drive/src/drive/votes/readiness/mod.rs new file mode 100644 index 00000000000..d628b0b688e --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/mod.rs @@ -0,0 +1,74 @@ +//! Compilation readiness storage: the rounds, reports, cursors and deadlines kept under +//! `[Votes] / r` and the readiness funds kept under `[PreFundedSpecializedBalances] / 129`. +//! +//! A round is stored under its own key beneath its contract so that replacing it is a pointer +//! swap plus fixed-size inserts and deletes, never a walk of its reports; a retired round is +//! unlinked from the pointer in one batch and drained later by the bounded cleanup step. See +//! the book chapter on compilation readiness for the layout and the reasons. + +#[cfg(feature = "server")] +mod activate_readiness_round_operations; +#[cfg(feature = "server")] +mod add_readiness_pool_credit_operation; +#[cfg(feature = "server")] +mod cancel_readiness_round_operations; +#[cfg(feature = "server")] +mod cleanup_retired_readiness_round_operations; +#[cfg(feature = "server")] +mod clear_readiness_scan_cursor_operations; +#[cfg(feature = "server")] +mod estimation_costs; +#[cfg(feature = "server")] +mod fetch_readiness_evaluation_cursor; +#[cfg(feature = "server")] +mod fetch_readiness_reports_page; +#[cfg(feature = "server")] +mod fetch_readiness_round; +#[cfg(feature = "server")] +mod fetch_readiness_round_raw_count; +#[cfg(feature = "server")] +mod fetch_readiness_rounds_due; +#[cfg(feature = "server")] +mod fetch_readiness_rounds_page; +#[cfg(feature = "server")] +mod fetch_readiness_scan_cursor; +#[cfg(feature = "server")] +mod fetch_retired_readiness_round; +/// Readiness funds under `[PreFundedSpecializedBalances] / 129` +#[cfg(feature = "server")] +pub mod fund; +#[cfg(feature = "server")] +mod insert_readiness_report_operations; +#[cfg(feature = "server")] +mod open_readiness_round_operations; +#[cfg(feature = "server")] +mod prove_readiness_round; +#[cfg(feature = "server")] +mod prune_readiness_reports_operations; +/// Path queries shared by the prover and the verifier +#[cfg(any(feature = "server", feature = "verify"))] +pub mod queries; +#[cfg(feature = "server")] +mod record_readiness_crossing_operations; +#[cfg(feature = "server")] +mod retire_readiness_round_operations; +#[cfg(feature = "server")] +mod store_readiness_evaluation_cursor_operations; +#[cfg(feature = "server")] +mod store_readiness_scan_cursor_operations; +#[cfg(feature = "server")] +mod update_readiness_round_evaluation_operations; + +#[cfg(feature = "server")] +pub use cleanup_retired_readiness_round_operations::ReadinessCleanupOutcome; +#[cfg(feature = "server")] +pub use fetch_readiness_rounds_due::ReadinessRoundDue; +#[cfg(feature = "server")] +pub use fetch_retired_readiness_round::RetiredReadinessRound; +#[cfg(feature = "server")] +pub use open_readiness_round_operations::ReadinessRoundFunding; +#[cfg(feature = "server")] +pub use retire_readiness_round_operations::ReadinessRetirement; + +#[cfg(all(feature = "server", test))] +mod tests; diff --git a/packages/rs-drive/src/drive/votes/readiness/open_readiness_round_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/open_readiness_round_operations/mod.rs new file mode 100644 index 00000000000..4bb64744380 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/open_readiness_round_operations/mod.rs @@ -0,0 +1,86 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::{ReadinessRound, ReadinessRoundOpening}; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +/// The credit amounts an opening moves. They are parameters rather than fee table reads so +/// that the storage layer carries no fee policy; the state transition that opens a round +/// passes the schedule's values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReadinessRoundFunding { + /// The credits the payer puts into the new round's fund. + pub initial_funding: Credits, + /// The credits charged to the pool when the previous round retires. + pub cleanup_reserve: Credits, +} + +impl Drive { + /// Opens a contract's compilation readiness round, replacing any current one. + /// + /// In one batch: the pointer is written to the new round id, the new round tree is + /// created with its record and empty reports count tree, the new fund is created, and + /// when a round was current it is retired (queued for cleanup, its deadline entry + /// dropped, its fund emptied with the cleanup reserve charged to the pool). The retired + /// fund's refund goes to the payer that funded it: netted against the new funding into + /// one balance write when the same payer opens the replacement, credited to the previous + /// payer beside the new payer's debit otherwise. + /// Old reports cannot count for the new round: they live under the old round's key, + /// which the pointer no longer names. + /// + /// # Parameters + /// + /// * `opening` - What identifies the round. + /// * `funding` - The credits the opening moves. + /// * `block_info` - The block the round opens in. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The new round and the low level operations that perform the writes. + /// * `Err(IdentityError::IdentityInsufficientBalance)` when the payer cannot fund it. + /// * `Err(DriveError::CorruptedCodeExecution)` when the opening derives the id of the + /// contract's current round. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn open_readiness_round_operations( + &self, + opening: ReadinessRoundOpening, + funding: ReadinessRoundFunding, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(ReadinessRound, Vec), Error> { + match platform_version.drive.methods.vote.readiness.open_round { + Some(0) => self.open_readiness_round_operations_v0( + opening, + funding, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "open_readiness_round_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "open_readiness_round_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/open_readiness_round_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/open_readiness_round_operations/v0/mod.rs new file mode 100644 index 00000000000..78244d6d1da --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/open_readiness_round_operations/v0/mod.rs @@ -0,0 +1,246 @@ +use crate::drive::votes::paths::{ + readiness_contract_tree_path, readiness_contract_tree_path_vec, readiness_contracts_tree_path, + readiness_contracts_tree_path_vec, readiness_round_tree_path_vec, + READINESS_CURRENT_ROUND_POINTER_KEY, READINESS_ROUND_RECORD_KEY, + READINESS_ROUND_REPORTS_TREE_KEY, +}; +use crate::drive::votes::readiness::open_readiness_round_operations::ReadinessRoundFunding; +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 crate::util::grove_operations::QueryTarget::QueryTargetTree; +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::serialization::PlatformSerializable; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::payer::ReadinessPayer; +use dpp::voting::readiness::round::{ReadinessRound, ReadinessRoundOpening}; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + pub(super) fn open_readiness_round_operations_v0( + &self, + opening: ReadinessRoundOpening, + funding: ReadinessRoundFunding, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(ReadinessRound, Vec), Error> { + let round = ReadinessRound::new(self.config.network.magic(), opening, platform_version)?; + let contract_id = round.contract_id().to_buffer(); + let round_id = round.round_id(); + 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_readiness( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + + // Is there a round to replace? Only a real run reads; an estimate prices the + // replacement shape (the largest one: a crossed round with a deadline entry and a + // fund to settle, paid by another identity so the refund is a second balance write) + // so the estimate covers every case. + let previous = if estimated_costs_only_with_layer_info.is_none() { + self.fetch_readiness_round_operations( + contract_id, + transaction, + &mut drive_operations, + platform_version, + )? + } else { + let mut placeholder = ReadinessRound::new( + self.config.network.magic(), + ReadinessRoundOpening { + accepted_at_height: opening.accepted_at_height.wrapping_sub(1), + payer: ReadinessPayer::Identity(Identifier::new( + round.payer().identity_id().map_or([0u8; 32], |payer| { + let mut other = payer.to_buffer(); + other[0] ^= 0xFF; + other + }), + )), + ..opening + }, + platform_version, + )?; + placeholder.record_crossing(opening.accepted_at_ms, 0, u64::MAX)?; + Some(placeholder) + }; + let contract_tree_exists = if estimated_costs_only_with_layer_info.is_none() { + let contracts_path = readiness_contracts_tree_path(); + self.grove_has_raw( + (&contracts_path).into(), + &contract_id, + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )? + } else { + false + }; + // The same opening derives the same id, and a retired round keeps its tree until + // cleanup: recreating either under its own key would leave the live round queued for + // cleanup. An estimate prices the read. + let round_tree_exists = if estimated_costs_only_with_layer_info.is_some() { + let contract_path = readiness_contract_tree_path(&contract_id); + self.grove_has_raw( + (&contract_path).into(), + &round_id, + DirectQueryType::StatelessDirectQuery { + in_tree_type: TreeType::NormalTree, + query_target: QueryTargetTree(0, TreeType::NormalTree), + }, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + false + } else if contract_tree_exists { + let contract_path = readiness_contract_tree_path(&contract_id); + self.grove_has_raw( + (&contract_path).into(), + &round_id, + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )? + } else { + false + }; + if round_tree_exists { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "opening a readiness round whose id is the contract's current round or a retired \ + round awaiting cleanup", + ))); + } + + // The retired round's refund belongs to whoever funded it. + let mut previous_refund = None; + if let Some(previous) = previous.as_ref() { + let (retirement, retire_operations) = self.retire_readiness_round_operations( + previous, + funding.cleanup_reserve, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + previous_refund = Some((previous.payer(), retirement.refund)); + drive_operations.extend(retire_operations); + } + + // The contract's tree and its pointer. The pointer is one operation on its path and + // key whatever happened before: an insert when the contract had no tree, a replace + // when a round is being replaced. + if !contract_tree_exists { + drive_operations.push(LowLevelDriveOperation::for_known_path_key_empty_tree( + readiness_contracts_tree_path_vec(), + contract_id.to_vec(), + None, + )); + } + let pointer = Element::new_item(round_id.to_vec()); + let pointer_op = if contract_tree_exists { + QualifiedGroveDbOp::insert_or_replace_op( + readiness_contract_tree_path_vec(contract_id), + vec![READINESS_CURRENT_ROUND_POINTER_KEY as u8], + pointer, + ) + } else { + QualifiedGroveDbOp::insert_only_known_to_not_already_exist_op( + readiness_contract_tree_path_vec(contract_id), + vec![READINESS_CURRENT_ROUND_POINTER_KEY as u8], + pointer, + ) + }; + drive_operations.push(GroveOperation(pointer_op)); + + // The new round tree under its own key: record, empty reports count tree, no cursor. + drive_operations.push(LowLevelDriveOperation::for_known_path_key_empty_tree( + readiness_contract_tree_path_vec(contract_id), + round_id.to_vec(), + None, + )); + drive_operations.push(LowLevelDriveOperation::insert_for_known_path_key_element( + readiness_round_tree_path_vec(contract_id, round_id), + vec![READINESS_ROUND_RECORD_KEY], + Element::new_item(round.serialize_to_bytes()?), + )); + drive_operations.push(LowLevelDriveOperation::for_known_path_key_empty_count_tree( + readiness_round_tree_path_vec(contract_id, round_id), + vec![READINESS_ROUND_REPORTS_TREE_KEY], + None, + )); + + // The new fund. + if funding.initial_funding > 0 { + drive_operations.extend(self.add_readiness_fund_operations( + round.funding_id(), + funding.initial_funding, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + } + + // Settle the balances. When the same payer funded the retired round the refund is + // netted against the new funding into one write: two absolute writes on one balance + // key in one batch would collapse. Another payer is debited the full funding and the + // retired round's payer is credited its own refund. + let (same_payer_refund, other_payer_refund) = match previous_refund { + Some((previous_payer, refund)) if previous_payer == round.payer() => (refund, None), + Some((previous_payer, refund)) => (0, Some((previous_payer, refund))), + None => (0, None), + }; + if let Some(payer_id) = round.payer().identity_id() { + let payer = payer_id.to_buffer(); + if funding.initial_funding > same_payer_refund { + drive_operations.extend(self.remove_from_identity_balance_operations( + payer, + funding.initial_funding - same_payer_refund, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + } else if same_payer_refund > funding.initial_funding { + drive_operations.extend(self.add_to_identity_balance_operations( + payer, + same_payer_refund - funding.initial_funding, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + } + } + if let Some((previous_payer, refund)) = other_payer_refund { + // An estimate always prices the credit: the placeholder's fund reads as empty. + if refund > 0 || estimated_costs_only_with_layer_info.is_some() { + if let Some(previous_payer_id) = previous_payer.identity_id() { + drive_operations.extend(self.add_to_identity_balance_operations( + previous_payer_id.to_buffer(), + refund, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + } + } + } + + Ok((round, drive_operations)) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/prove_readiness_round/mod.rs b/packages/rs-drive/src/drive/votes/readiness/prove_readiness_round/mod.rs new file mode 100644 index 00000000000..402e7a189fa --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/prove_readiness_round/mod.rs @@ -0,0 +1,91 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Proves a contract's current compilation readiness round: the pointer and, when it + /// names a round, that round's record and reports count tree element, in one merged + /// proof `verify_readiness_round` decodes. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The proof bytes. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn prove_readiness_round( + &self, + contract_id: [u8; 32], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version.drive.methods.vote.readiness.fetch_round { + Some(0) => self.prove_readiness_round_v0(contract_id, transaction, platform_version), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "prove_readiness_round".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "prove_readiness_round".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Proves one accepted report of a round. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `pro_tx_hash` - The reporting evonode. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The proof bytes. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn prove_readiness_report( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + pro_tx_hash: [u8; 32], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .fetch_reports_page + { + Some(0) => self.prove_readiness_report_v0( + contract_id, + round_id, + pro_tx_hash, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "prove_readiness_report".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "prove_readiness_report".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/prove_readiness_round/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/prove_readiness_round/v0/mod.rs new file mode 100644 index 00000000000..0dbc5778aaa --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/prove_readiness_round/v0/mod.rs @@ -0,0 +1,56 @@ +use crate::drive::votes::readiness::queries::{ + readiness_report_path_query, readiness_round_path_query, readiness_round_pointer_path_query, +}; +use crate::drive::Drive; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::{PathQuery, TransactionArg}; + +impl Drive { + pub(super) fn prove_readiness_round_v0( + &self, + contract_id: [u8; 32], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let pointer_query = readiness_round_pointer_path_query(contract_id); + let path_query = match self.fetch_readiness_current_round_id_operations( + contract_id, + transaction, + &mut vec![], + platform_version, + )? { + Some(round_id) => { + let round_query = readiness_round_path_query(contract_id, round_id); + PathQuery::merge( + vec![&pointer_query, &round_query], + &platform_version.drive.grove_version, + )? + } + None => pointer_query, + }; + self.grove_get_proved_path_query( + &path_query, + transaction, + &mut vec![], + &platform_version.drive, + ) + } + + pub(super) fn prove_readiness_report_v0( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + pro_tx_hash: [u8; 32], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path_query = readiness_report_path_query(contract_id, round_id, pro_tx_hash); + self.grove_get_proved_path_query( + &path_query, + transaction, + &mut vec![], + &platform_version.drive, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/prune_readiness_reports_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/prune_readiness_reports_operations/mod.rs new file mode 100644 index 00000000000..6ca78a42476 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/prune_readiness_reports_operations/mod.rs @@ -0,0 +1,61 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Deletes the named reports from a round's count tree: the reporters the block event + /// found ineligible under the block's membership view. The count tree's count drops by + /// the number deleted. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `pro_tx_hashes` - The reports to delete; each must exist. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The low level operations that perform the deletes. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn prune_readiness_reports_operations( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + pro_tx_hashes: &[[u8; 32]], + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version.drive.methods.vote.readiness.prune_reports { + Some(0) => self.prune_readiness_reports_operations_v0( + contract_id, + round_id, + pro_tx_hashes, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "prune_readiness_reports_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "prune_readiness_reports_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/prune_readiness_reports_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/prune_readiness_reports_operations/v0/mod.rs new file mode 100644 index 00000000000..e2dae9ce9f0 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/prune_readiness_reports_operations/v0/mod.rs @@ -0,0 +1,71 @@ +use crate::drive::votes::paths::readiness_round_reports_tree_path; +use crate::drive::votes::readiness::estimation_costs::ESTIMATED_READINESS_REPORT_RECORD_SIZE; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchDeleteApplyType; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; +use dpp::version::PlatformVersion; +use grovedb::batch::key_info::KeyInfo; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + pub(super) fn prune_readiness_reports_operations_v0( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + pro_tx_hashes: &[[u8; 32]], + 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_readiness( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + let apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + } + } else { + BatchDeleteApplyType::StatelessBatchDelete { + in_tree_type: TreeType::CountTree, + estimated_key_size: DEFAULT_HASH_SIZE_U32, + estimated_value_size: ESTIMATED_READINESS_REPORT_RECORD_SIZE, + } + }; + let path = readiness_round_reports_tree_path(&contract_id, &round_id); + for pro_tx_hash in pro_tx_hashes { + self.batch_delete( + (&path).into(), + pro_tx_hash, + apply_type, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info + { + // The per-key walk the batch estimate leaves out. + Self::add_estimated_readiness_report_delete_walk_v0( + contract_id, + round_id, + &KeyInfo::KnownKey(pro_tx_hash.to_vec()), + estimated_costs_only_with_layer_info, + &mut drive_operations, + platform_version, + )?; + } + } + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/queries.rs b/packages/rs-drive/src/drive/votes/readiness/queries.rs new file mode 100644 index 00000000000..03295ca3cf4 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/queries.rs @@ -0,0 +1,54 @@ +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_for_readiness_path_vec; +use crate::drive::votes::paths::{ + readiness_contract_tree_path_vec, readiness_round_reports_tree_path_vec, + readiness_round_tree_path_vec, READINESS_CURRENT_ROUND_POINTER_KEY, READINESS_ROUND_RECORD_KEY, + READINESS_ROUND_REPORTS_TREE_KEY, +}; +use grovedb::{PathQuery, Query, SizedQuery}; + +/// The path query proving one readiness fund. +pub fn readiness_fund_path_query(fund_id: [u8; 32]) -> PathQuery { + let mut path_query = PathQuery::new_single_key( + prefunded_specialized_balances_for_readiness_path_vec(), + fund_id.to_vec(), + ); + path_query.query.limit = Some(1); + path_query +} + +/// The path query proving a contract's current round pointer. Unlimited on purpose: the +/// prover merges it with the round query beneath it, and a limited query landing at the +/// merged root is refused; a single key bounds it anyway. +pub fn readiness_round_pointer_path_query(contract_id: [u8; 32]) -> PathQuery { + PathQuery::new_single_key( + readiness_contract_tree_path_vec(contract_id), + vec![READINESS_CURRENT_ROUND_POINTER_KEY as u8], + ) +} + +/// The path query proving a round's record and its reports count tree element (whose count +/// is the raw distinct report count). Unlimited for the same reason as the pointer query; +/// two keys bound it. +pub fn readiness_round_path_query(contract_id: [u8; 32], round_id: [u8; 32]) -> PathQuery { + let mut query = Query::new(); + query.insert_key(vec![READINESS_ROUND_RECORD_KEY]); + query.insert_key(vec![READINESS_ROUND_REPORTS_TREE_KEY]); + PathQuery::new( + readiness_round_tree_path_vec(contract_id, round_id), + SizedQuery::new(query, None, None), + ) +} + +/// The path query proving one report of a round. +pub fn readiness_report_path_query( + contract_id: [u8; 32], + round_id: [u8; 32], + pro_tx_hash: [u8; 32], +) -> PathQuery { + let mut path_query = PathQuery::new_single_key( + readiness_round_reports_tree_path_vec(contract_id, round_id), + pro_tx_hash.to_vec(), + ); + path_query.query.limit = Some(1); + path_query +} diff --git a/packages/rs-drive/src/drive/votes/readiness/record_readiness_crossing_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/record_readiness_crossing_operations/mod.rs new file mode 100644 index 00000000000..d54a2807b4e --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/record_readiness_crossing_operations/mod.rs @@ -0,0 +1,74 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Records a fully validated crossing: marks the round crossed at `crossing_ms` with a + /// deadline of `crossing_ms + clamp(crossing_ms - accepted_at_ms, min_wait_ms, + /// max_wait_ms)`, rewrites the record and queues the deadline. Any open scan cursor is + /// removed. The caller has already done the eligibility walk; nothing here checks it. + /// + /// # Parameters + /// + /// * `round` - The pending round; updated to its crossed state only once every operation + /// is built, and left untouched by an estimate or a failure. + /// * `crossing_ms` - The committed block time of the crossing. + /// * `min_wait_ms` - The lower bound of the additional wait (from `SystemLimits`). + /// * `max_wait_ms` - The upper bound of the additional wait (from `SystemLimits`). + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The deadline and the low level operations that perform the writes. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + #[allow(clippy::too_many_arguments)] + pub fn record_readiness_crossing_operations( + &self, + round: &mut ReadinessRound, + crossing_ms: u64, + min_wait_ms: u64, + max_wait_ms: u64, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(u64, Vec), Error> { + match platform_version + .drive + .methods + .vote + .readiness + .record_crossing + { + Some(0) => self.record_readiness_crossing_operations_v0( + round, + crossing_ms, + min_wait_ms, + max_wait_ms, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "record_readiness_crossing_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "record_readiness_crossing_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/record_readiness_crossing_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/record_readiness_crossing_operations/v0/mod.rs new file mode 100644 index 00000000000..c97821ca2a6 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/record_readiness_crossing_operations/v0/mod.rs @@ -0,0 +1,130 @@ +use crate::drive::votes::paths::{ + readiness_deadline_tree_path_vec, readiness_deadlines_tree_path_vec, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::common::encode::encode_u64; +use crate::util::grove_operations::QueryTarget::QueryTargetValue; +use crate::util::grove_operations::{BatchInsertApplyType, BatchInsertTreeApplyType}; +use crate::util::object_size_info::PathKeyElementInfo::{PathKeyElementSize, PathKeyRefElement}; +use crate::util::object_size_info::{DriveKeyInfo, PathInfo, PathKeyElementInfo}; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::batch::key_info::KeyInfo; +use grovedb::batch::KeyInfoPath; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + #[allow(clippy::too_many_arguments)] + pub(super) fn record_readiness_crossing_operations_v0( + &self, + round: &mut ReadinessRound, + crossing_ms: u64, + min_wait_ms: u64, + max_wait_ms: u64, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(u64, Vec), Error> { + if !round.is_pending() { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "recording a crossing on a round that already crossed", + ))); + } + let contract_id = round.contract_id().to_buffer(); + let round_id = round.round_id(); + // Build on a copy so that a failure below leaves the caller's round pending. + let mut crossed = round.clone(); + let deadline_ms = crossed.record_crossing(crossing_ms, min_wait_ms, max_wait_ms)?; + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_readiness_deadline( + deadline_ms, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + + let mut drive_operations = self.update_readiness_round_evaluation_operations( + &crossed, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + drive_operations.extend(self.clear_readiness_scan_cursor_operations( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + + // The deadline tree at this time may already exist (another round crossed with the + // same deadline in this or an earlier block), so insert it only if absent. + let deadline_key = DriveKeyInfo::Key(encode_u64(deadline_ms)); + let path_key_info = deadline_key + .add_path_info::<0>(PathInfo::PathAsVec(readiness_deadlines_tree_path_vec())); + let tree_apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertTreeApplyType::StatefulBatchInsertTree + } else { + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type: TreeType::NormalTree, + tree_type: TreeType::NormalTree, + flags_len: 0, + } + }; + self.batch_insert_empty_tree_if_not_exists( + path_key_info, + TreeType::NormalTree, + None, + tree_apply_type, + transaction, + &mut None, + &mut drive_operations, + &platform_version.drive, + )?; + + let entry = Element::new_item(round_id.to_vec()); + let apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertApplyType::StatefulBatchInsert + } else { + BatchInsertApplyType::StatelessBatchInsert { + in_tree_type: TreeType::NormalTree, + target: QueryTargetValue( + entry.serialized_size(&platform_version.drive.grove_version)? as u32, + ), + } + }; + let deadline_path = readiness_deadline_tree_path_vec(deadline_ms); + let path_key_element_info: PathKeyElementInfo<'_, 0> = + if estimated_costs_only_with_layer_info.is_none() { + PathKeyRefElement((deadline_path, contract_id.as_slice(), entry)) + } else { + PathKeyElementSize(( + KeyInfoPath::from_known_owned_path(deadline_path), + KeyInfo::MaxKeySize { + unique_id: contract_id.to_vec(), + max_size: DEFAULT_HASH_SIZE_U8, + }, + entry, + )) + }; + self.batch_insert_if_not_exists( + path_key_element_info, + apply_type, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + if estimated_costs_only_with_layer_info.is_none() { + *round = crossed; + } + Ok((deadline_ms, drive_operations)) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/retire_readiness_round_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/retire_readiness_round_operations/mod.rs new file mode 100644 index 00000000000..d2a8b6c676a --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/retire_readiness_round_operations/mod.rs @@ -0,0 +1,91 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +/// What retiring a round settled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadinessRetirement { + /// The credits the fund held when the round retired. + pub fund_balance: Credits, + /// The credits charged to the epoch's processing pool for the deferred cleanup. + pub cleanup_charged: Credits, + /// The credits owed to the retired round's payer; the caller settles them (netted + /// against new funding by the same payer into a single balance write). + pub refund: Credits, +} + +impl Drive { + /// Retires a round the caller has already unlinked from its contract's pointer: queues + /// it for the bounded cleanup, removes its deadline entry when it had crossed, empties + /// its fund, charges the cleanup reserve to the epoch's processing pool and reports the + /// remainder owed to the payer. + /// + /// Never opens the reports tree, so the batch is a fixed number of operations whatever + /// the round accumulated. The refund is returned rather than written because a batch + /// may both refund the old payer and debit the new funding on the same identity, and + /// two absolute balance writes on one key in one batch collapse; the caller nets them + /// when the payers match and credits the round's own payer otherwise. + /// + /// One retirement per applied batch: the pool credit is an absolute rewrite of the + /// epoch's processing pool item, so two retirements in one batch would collapse too. + /// `apply_drive_operations` refuses a batch holding more than one opening, cancellation + /// or activation for that reason, and one that pairs any of them with another identity + /// balance or readiness fund write, which the settlement would overwrite. + /// + /// # Parameters + /// + /// * `round` - The round being retired. + /// * `cleanup_reserve` - The credits charged to the pool for the deferred cleanup, capped + /// at what the fund holds. + /// * `block_info` - The block the retirement happens in (its epoch receives the reserve). + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * What was settled and the low level operations that perform the writes. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + #[allow(clippy::too_many_arguments)] + pub fn retire_readiness_round_operations( + &self, + round: &ReadinessRound, + cleanup_reserve: Credits, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(ReadinessRetirement, Vec), Error> { + match platform_version.drive.methods.vote.readiness.retire_round { + Some(0) => self.retire_readiness_round_operations_v0( + round, + cleanup_reserve, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "retire_readiness_round_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "retire_readiness_round_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/retire_readiness_round_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/retire_readiness_round_operations/v0/mod.rs new file mode 100644 index 00000000000..45d5a75cf67 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/retire_readiness_round_operations/v0/mod.rs @@ -0,0 +1,187 @@ +use crate::drive::votes::paths::{ + readiness_deadline_tree_path_vec, readiness_retired_rounds_tree_path_vec, +}; +use crate::drive::votes::readiness::retire_readiness_round_operations::ReadinessRetirement; +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::{BatchDeleteUpTreeApplyType, DirectQueryType}; +use crate::util::type_constants::{DEFAULT_HASH_SIZE_U32, DEFAULT_HASH_SIZE_U8, U64_SIZE_U8}; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::EstimatedLayerCount::ApproximateElements; +use grovedb::EstimatedLayerSizes::{AllItems, AllSubtrees}; +use grovedb::EstimatedSumTrees::NoSumTrees; +use grovedb::{Element, EstimatedLayerInformation, MaybeTree, TransactionArg, TreeType}; +use intmap::IntMap; +use std::collections::HashMap; + +impl Drive { + #[allow(clippy::too_many_arguments)] + pub(super) fn retire_readiness_round_operations_v0( + &self, + round: &ReadinessRound, + cleanup_reserve: Credits, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(ReadinessRetirement, Vec), Error> { + let contract_id = round.contract_id().to_buffer(); + let round_id = round.round_id(); + 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_readiness( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + if let Some(deadline_ms) = round.deadline_ms() { + Self::add_estimation_costs_for_readiness_deadline( + deadline_ms, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + } + + // 1. Queue the round for the bounded cleanup. Its subtree stays under its own key. + drive_operations.push(GroveOperation(QualifiedGroveDbOp::insert_or_replace_op( + readiness_retired_rounds_tree_path_vec(), + round_id.to_vec(), + Element::new_item(contract_id.to_vec()), + ))); + + // 2. Drop the deadline entry of a crossed round, and its per-time tree when that + // entry was the last one: the due query walks the time keys in order under a + // limit, and an empty time tree still spends it, so a run of emptied trees ahead + // of a live deadline would hide that deadline from every block. The walk up stops + // at the deadlines tree and deletes the time tree only when nothing else is in it + // (another round sharing the time keeps it). It sees this batch's own operations + // only, which the one retirement per batch rule already requires. + if let Some(deadline_ms) = round.deadline_ms() { + let deadline_path = readiness_deadline_tree_path_vec(deadline_ms); + let apply_type = if estimated_costs_only_with_layer_info.is_none() { + let present = self.grove_has_raw( + deadline_path.as_slice().into(), + &contract_id, + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + if present { + Some(BatchDeleteUpTreeApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }) + } else { + None + } + } else { + // Price both levels, keyed by the index of the deleted key in the path: the + // entry in the time tree (3), then the time key in the deadlines tree (2). + Some(BatchDeleteUpTreeApplyType::StatelessBatchDelete { + estimated_layer_info: IntMap::from_iter([ + ( + 3, + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(2), + estimated_layer_sizes: AllItems( + DEFAULT_HASH_SIZE_U8, + DEFAULT_HASH_SIZE_U32, + None, + ), + }, + ), + ( + 2, + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(1_024), + estimated_layer_sizes: AllSubtrees(U64_SIZE_U8, NoSumTrees, None), + }, + ), + ]), + }) + }; + if let Some(apply_type) = apply_type { + self.batch_delete_up_tree_while_empty( + KeyInfoPath::from_known_owned_path(deadline_path), + &contract_id, + // Stop at the readiness tree's path (length 2): the deadlines tree stays. + Some(2), + apply_type, + transaction, + &None, + &mut drive_operations, + &platform_version.drive, + )?; + } + } + + // 3. Empty the fund. In estimation mode the helper prices the delete and reports a + // zero balance; the settlement below is then zero as well. + let (fund_balance, fund_operations) = self.empty_readiness_fund_operations( + round.funding_id(), + false, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + drive_operations.extend(fund_operations); + + // 4. Charge the cleanup reserve to the epoch's processing pool, capped at the fund. + // An estimate read no fund, so it prices the charge of the whole reserve. + let cleanup_charged = cleanup_reserve.min(fund_balance); + let refund = fund_balance + .checked_sub(cleanup_charged) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "cleanup charge exceeds the fund it was capped at", + )))?; + let priced_charge = if estimated_costs_only_with_layer_info.is_none() { + cleanup_charged + } else { + cleanup_reserve + }; + if priced_charge > 0 { + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_readiness_pool_credit( + &block_info.epoch, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + let mut read_operations = vec![]; + let pool_operation = self.add_readiness_pool_credit_operation( + block_info, + priced_charge, + estimated_costs_only_with_layer_info.is_none(), + transaction, + &mut read_operations, + platform_version, + )?; + drive_operations.extend(read_operations); + drive_operations.push(pool_operation); + } + + Ok(( + ReadinessRetirement { + fund_balance, + cleanup_charged, + refund, + }, + drive_operations, + )) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/store_readiness_evaluation_cursor_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/store_readiness_evaluation_cursor_operations/mod.rs new file mode 100644 index 00000000000..6f4661ec568 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/store_readiness_evaluation_cursor_operations/mod.rs @@ -0,0 +1,60 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Stores the block event's fairness cursor: the last contract id it visited, or clears + /// it when the sweep wrapped. + /// + /// # Parameters + /// + /// * `last_contract_id` - The contract to resume after; `None` clears the cursor. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The low level operations that perform the write. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn store_readiness_evaluation_cursor_operations( + &self, + last_contract_id: Option<[u8; 32]>, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .store_evaluation_cursor + { + Some(0) => self.store_readiness_evaluation_cursor_operations_v0( + last_contract_id, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "store_readiness_evaluation_cursor_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "store_readiness_evaluation_cursor_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/store_readiness_evaluation_cursor_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/store_readiness_evaluation_cursor_operations/v0/mod.rs new file mode 100644 index 00000000000..dc2e4331930 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/store_readiness_evaluation_cursor_operations/v0/mod.rs @@ -0,0 +1,78 @@ +use crate::drive::votes::paths::{ + readiness_tree_path, readiness_tree_path_vec, READINESS_EVALUATION_CURSOR_KEY, +}; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use crate::util::grove_operations::{BatchDeleteApplyType, DirectQueryType}; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U32; +use dpp::version::PlatformVersion; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{Element, EstimatedLayerInformation, MaybeTree, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + pub(super) fn store_readiness_evaluation_cursor_operations_v0( + &self, + last_contract_id: Option<[u8; 32]>, + 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_readiness( + [0u8; 32], + [0u8; 32], + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + match last_contract_id { + Some(contract_id) => { + drive_operations.push(GroveOperation(QualifiedGroveDbOp::insert_or_replace_op( + readiness_tree_path_vec(), + vec![READINESS_EVALUATION_CURSOR_KEY], + Element::new_item(contract_id.to_vec()), + ))); + } + None => { + let path = readiness_tree_path(); + let apply_type = if estimated_costs_only_with_layer_info.is_none() { + let present = self.grove_has_raw( + (&path).into(), + &[READINESS_EVALUATION_CURSOR_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + if !present { + return Ok(drive_operations); + } + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + } + } else { + BatchDeleteApplyType::StatelessBatchDelete { + in_tree_type: TreeType::NormalTree, + estimated_key_size: 1, + estimated_value_size: DEFAULT_HASH_SIZE_U32, + } + }; + self.batch_delete( + (&path).into(), + &[READINESS_EVALUATION_CURSOR_KEY], + apply_type, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + } + } + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/store_readiness_scan_cursor_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/store_readiness_scan_cursor_operations/mod.rs new file mode 100644 index 00000000000..b0b7e563f65 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/store_readiness_scan_cursor_operations/mod.rs @@ -0,0 +1,66 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::scan_cursor::ReadinessScanCursor; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Stores the position of a round's paged eligibility walk, replacing any previous one. + /// + /// # Parameters + /// + /// * `contract_id` - The contract. + /// * `round_id` - The round. + /// * `cursor` - The position to persist. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The low level operations that perform the write. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn store_readiness_scan_cursor_operations( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + cursor: &ReadinessScanCursor, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .store_scan_cursor + { + Some(0) => self.store_readiness_scan_cursor_operations_v0( + contract_id, + round_id, + cursor, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "store_readiness_scan_cursor_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "store_readiness_scan_cursor_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/store_readiness_scan_cursor_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/store_readiness_scan_cursor_operations/v0/mod.rs new file mode 100644 index 00000000000..bd2e4ed44b0 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/store_readiness_scan_cursor_operations/v0/mod.rs @@ -0,0 +1,42 @@ +use crate::drive::votes::paths::{readiness_round_tree_path_vec, READINESS_ROUND_SCAN_CURSOR_KEY}; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use dpp::serialization::PlatformSerializable; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::scan_cursor::ReadinessScanCursor; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + pub(super) fn store_readiness_scan_cursor_operations_v0( + &self, + contract_id: [u8; 32], + round_id: [u8; 32], + cursor: &ReadinessScanCursor, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + _transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_readiness( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + // Insert-or-replace: the cursor grows as the walk advances (the optional key + // becomes present), so this is priced as an insert, never as a same-size replace. + let op = QualifiedGroveDbOp::insert_or_replace_op( + readiness_round_tree_path_vec(contract_id, round_id), + vec![READINESS_ROUND_SCAN_CURSOR_KEY], + Element::new_item(cursor.serialize_to_bytes()?), + ); + Ok(vec![GroveOperation(op)]) + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/tests.rs b/packages/rs-drive/src/drive/votes/readiness/tests.rs new file mode 100644 index 00000000000..b96d8bb6e34 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/tests.rs @@ -0,0 +1,3018 @@ +//! Tests of the compilation readiness storage, all through the dispatchers at the latest +//! platform version. + +use crate::drive::prefunded_specialized_balances::{ + prefunded_specialized_balances_for_readiness_path, prefunded_specialized_balances_path, + PREFUNDED_BALANCES_FOR_READINESS, PREFUNDED_BALANCES_FOR_VOTING, +}; +use crate::drive::votes::paths::{ + readiness_contract_tree_path, readiness_deadline_tree_path_vec, + readiness_deadlines_tree_path_vec, readiness_round_tree_path, vote_root_path, + READINESS_CURRENT_ROUND_POINTER_KEY, READINESS_ROUND_RECORD_KEY, + READINESS_ROUND_REPORTS_TREE_KEY, READINESS_ROUND_SCAN_CURSOR_KEY, READINESS_TREE_KEY, +}; +use crate::drive::votes::readiness::{ + ReadinessCleanupOutcome, ReadinessRoundFunding, RetiredReadinessRound, +}; +use crate::drive::{Drive, RootTree}; +use crate::error::drive::DriveError; +use crate::error::identity::IdentityError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::batch::drive_op_batch::{ + IdentityOperationType, PrefundedSpecializedBalanceOperationType, ReadinessOperationType, +}; +use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; +use crate::util::batch::{DriveOperation, GroveDbOpBatch}; +use crate::util::common::encode::encode_u64; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use crate::util::test_helpers::test_utils::identities::create_test_identity_with_rng; +use dpp::balances::credits::MAX_CREDITS; +use dpp::block::block_info::BlockInfo; +use dpp::block::epoch::Epoch; +use dpp::fee::fee_result::FeeResult; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::payer::ReadinessPayer; +use dpp::voting::readiness::report_record::ReadinessReportRecord; +use dpp::voting::readiness::round::{ReadinessEvaluation, ReadinessRound, ReadinessRoundOpening}; +use dpp::voting::readiness::scan_cursor::ReadinessScanCursor; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use rand::rngs::StdRng; +use rand::SeedableRng; +use std::collections::HashMap; + +const INITIAL_FUNDING: Credits = 20_000_000; +const CLEANUP_RESERVE: Credits = 1_000_000; +const PAYER_BALANCE: Credits = 100_000_000; +const MIN_WAIT_MS: u64 = 120_000; +const MAX_WAIT_MS: u64 = 3_600_000; + +const FUNDING: ReadinessRoundFunding = ReadinessRoundFunding { + initial_funding: INITIAL_FUNDING, + cleanup_reserve: CLEANUP_RESERVE, +}; + +fn block_info(time_ms: u64, height: u64, core_height: u32) -> BlockInfo { + BlockInfo { + time_ms, + height, + core_height, + epoch: Epoch::new(0).expect("epoch"), + } +} + +fn opening(contract_id: [u8; 32], payer: Identifier, height: u64) -> ReadinessRoundOpening { + ReadinessRoundOpening { + contract_id: Identifier::from(contract_id), + version: 1, + bundle_digest: [0xD1u8; 32], + preparation_profile: 1, + accepted_at_ms: 1_000_000, + accepted_at_height: height, + payer: ReadinessPayer::Identity(payer), + } +} + +/// A drive at the latest version with one funded payer identity. +fn setup() -> (Drive, Identifier) { + let drive = setup_drive_with_initial_state_structure(None); + let payer = funded_identity(&drive, 7, [0xAAu8; 32], PAYER_BALANCE); + (drive, payer) +} + +/// Creates an identity holding `balance` credits. +fn funded_identity(drive: &Drive, seed: u64, id: [u8; 32], balance: Credits) -> Identifier { + let platform_version = PlatformVersion::latest(); + let mut rng = StdRng::seed_from_u64(seed); + let identity = create_test_identity_with_rng(drive, id, &mut rng, None, platform_version) + .expect("identity") + .id(); + drive + .add_to_identity_balance( + identity.to_buffer(), + balance, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("fund identity"); + identity +} + +fn apply( + drive: &Drive, + operations: Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> FeeResult { + let mut drive_operations = vec![]; + drive + .apply_batch_low_level_drive_operations( + None, + transaction, + operations, + &mut drive_operations, + &platform_version.drive, + ) + .expect("apply"); + Drive::calculate_fee( + None, + Some(drive_operations), + &Epoch::new(0).expect("epoch"), + drive.config.epochs_per_era, + platform_version, + None, + ) + .expect("fee") +} + +fn estimate( + drive: &Drive, + layer_info: HashMap, + operations: Vec, + platform_version: &PlatformVersion, +) -> FeeResult { + let mut drive_operations = vec![]; + drive + .apply_batch_low_level_drive_operations( + Some(layer_info), + None, + operations, + &mut drive_operations, + &platform_version.drive, + ) + .expect("estimate"); + Drive::calculate_fee( + None, + Some(drive_operations), + &Epoch::new(0).expect("epoch"), + drive.config.epochs_per_era, + platform_version, + None, + ) + .expect("fee") +} + +fn open_round( + drive: &Drive, + contract_id: [u8; 32], + payer: Identifier, + height: u64, + transaction: TransactionArg, +) -> (ReadinessRound, FeeResult) { + let platform_version = PlatformVersion::latest(); + let (round, operations) = drive + .open_readiness_round_operations( + opening(contract_id, payer, height), + FUNDING, + &block_info(1_000_000, height, 100), + &mut None, + transaction, + platform_version, + ) + .expect("open"); + let fee = apply(drive, operations, transaction, platform_version); + (round, fee) +} + +fn insert_reports( + drive: &Drive, + round: &ReadinessRound, + from: u32, + to: u32, + transaction: TransactionArg, +) { + let platform_version = PlatformVersion::latest(); + let record = ReadinessReportRecord::new(round.accepted_at_height() + 1, 1, platform_version) + .expect("record"); + let contract_id = round.contract_id().to_buffer(); + let mut operations = vec![]; + for i in from..to { + let (inserted, ops) = drive + .insert_readiness_report_operations( + contract_id, + round.round_id(), + pro_tx_hash(i), + &record, + &mut None, + transaction, + platform_version, + ) + .expect("insert"); + assert!(inserted); + operations.extend(ops); + if operations.len() >= 256 { + apply( + drive, + std::mem::take(&mut operations), + transaction, + platform_version, + ); + } + } + if !operations.is_empty() { + apply(drive, operations, transaction, platform_version); + } +} + +fn pro_tx_hash(i: u32) -> [u8; 32] { + let mut hash = [0u8; 32]; + hash[..4].copy_from_slice(&i.to_be_bytes()); + hash[31] = 0x77; + hash +} + +fn payer_balance(drive: &Drive, payer: Identifier, transaction: TransactionArg) -> Credits { + drive + .fetch_identity_balance(payer.to_buffer(), transaction, PlatformVersion::latest()) + .expect("balance") + .expect("payer exists") +} + +fn pool_credits(drive: &Drive, transaction: TransactionArg) -> Credits { + drive + .get_epoch_processing_credits_for_distribution( + &Epoch::new(0).expect("epoch"), + transaction, + PlatformVersion::latest(), + ) + .expect("pool") +} + +fn conservation_holds(drive: &Drive, transaction: TransactionArg) -> bool { + let totals = drive + .calculate_total_credits_balance(transaction, &PlatformVersion::latest().drive) + .expect("totals"); + totals.ok().expect("verdict") +} + +/// Whether the per-time deadline tree at `deadline_ms` exists. +fn deadline_bucket_exists(drive: &Drive, deadline_ms: u64) -> bool { + let grove_version = &PlatformVersion::latest().drive.grove_version; + let deadlines_path = readiness_deadlines_tree_path_vec(); + drive + .grove + .get( + deadlines_path.as_slice(), + &encode_u64(deadline_ms), + None, + grove_version, + ) + .unwrap() + .is_ok() +} + +/// Records a crossing at `crossing_ms` on `round` and returns its deadline. +fn cross(drive: &Drive, round: &mut ReadinessRound, crossing_ms: u64) -> u64 { + let platform_version = PlatformVersion::latest(); + let (deadline_ms, operations) = drive + .record_readiness_crossing_operations( + round, + crossing_ms, + MIN_WAIT_MS, + MAX_WAIT_MS, + &mut None, + None, + platform_version, + ) + .expect("crossing"); + apply(drive, operations, None, platform_version); + deadline_ms +} + +mod genesis { + use super::*; + + #[test] + fn should_not_create_the_readiness_structures_at_protocol_version_14() { + let platform_version = PlatformVersion::get(14).expect("v14"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let grove_version = &platform_version.drive.grove_version; + assert!(drive + .grove + .get( + &vote_root_path(), + &[READINESS_TREE_KEY as u8], + None, + grove_version + ) + .unwrap() + .is_err()); + assert!(drive + .grove + .get( + &prefunded_specialized_balances_path(), + &[PREFUNDED_BALANCES_FOR_READINESS], + None, + grove_version + ) + .unwrap() + .is_err()); + assert!(drive + .grove + .get( + &prefunded_specialized_balances_path(), + &[PREFUNDED_BALANCES_FOR_VOTING], + None, + grove_version + ) + .unwrap() + .is_ok()); + } + + #[test] + fn should_create_the_readiness_structures_at_the_latest_protocol_version() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let grove_version = &platform_version.drive.grove_version; + let readiness = drive + .grove + .get( + &vote_root_path(), + &[READINESS_TREE_KEY as u8], + None, + grove_version, + ) + .unwrap() + .expect("readiness tree"); + assert!(matches!(readiness, Element::Tree(..))); + let funds = drive + .grove + .get( + &prefunded_specialized_balances_path(), + &[PREFUNDED_BALANCES_FOR_READINESS], + None, + grove_version, + ) + .unwrap() + .expect("readiness fund tree"); + assert!(matches!(funds, Element::SumTree(..))); + assert_eq!( + drive + .fetch_readiness_rounds_page(None, 10, None, platform_version) + .expect("page"), + Vec::<[u8; 32]>::new() + ); + assert_eq!( + drive + .fetch_retired_readiness_round(None, platform_version) + .expect("retired"), + None + ); + assert_eq!( + drive + .fetch_readiness_rounds_due(u64::MAX, 10, None, platform_version) + .expect("due"), + vec![] + ); + } + + /// The readiness batch guard is edited into the shipped apply and convert generation 0; a + /// batch without readiness writes, raw GroveDB writes included, applies as before. + #[test] + fn should_apply_raw_and_balance_writes_in_one_batch_at_protocol_version_13_as_before() { + let platform_version = PlatformVersion::get(13).expect("v13"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let block_info = BlockInfo::default(); + let mut rng = StdRng::seed_from_u64(13); + let identity_id = + create_test_identity_with_rng(&drive, [0xABu8; 32], &mut rng, None, platform_version) + .expect("identity") + .id() + .to_buffer(); + let balance_before = drive + .fetch_identity_balance(identity_id, None, platform_version) + .expect("fetch balance") + .expect("balance"); + let operations = || { + vec![ + DriveOperation::GroveDBOperation(QualifiedGroveDbOp::insert_or_replace_op( + vec![vec![RootTree::Misc as u8]], + b"readiness-guard-probe".to_vec(), + Element::new_item(vec![1]), + )), + DriveOperation::IdentityOperation(IdentityOperationType::AddToIdentityBalance { + identity_id, + added_balance: 5, + }), + ] + }; + + drive + .convert_drive_operations_to_grove_operations( + operations(), + &block_info, + None, + platform_version, + ) + .expect("convert"); + drive + .apply_drive_operations( + operations(), + true, + &block_info, + None, + platform_version, + None, + ) + .expect("apply"); + assert_eq!( + drive + .fetch_identity_balance(identity_id, None, platform_version) + .expect("fetch balance"), + Some(balance_before + 5) + ); + assert_eq!( + drive + .grove + .get( + &[&[RootTree::Misc as u8][..]], + b"readiness-guard-probe", + None, + &platform_version.drive.grove_version + ) + .unwrap() + .expect("probe"), + Element::new_item(vec![1]) + ); + } + + #[test] + fn should_report_the_readiness_methods_inactive_at_protocol_version_14() { + let platform_version = PlatformVersion::get(14).expect("v14"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let result = drive.fetch_readiness_round([1u8; 32], None, platform_version); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::VersionNotActive { .. })) + ), + "{result:?}" + ); + let result = + drive.add_readiness_fund(Identifier::from([1u8; 32]), 1, None, platform_version); + assert!(matches!( + result, + Err(Error::Drive(DriveError::VersionNotActive { .. })) + )); + let result = drive.add_readiness_pool_credit_operation( + &BlockInfo::default(), + 1, + true, + None, + &mut vec![], + platform_version, + ); + assert!(matches!( + result, + Err(Error::Drive(DriveError::VersionNotActive { .. })) + )); + } +} + +/// Asserts that `operations` is refused whether estimated, applied or converted. +fn assert_batch_refused<'a>( + drive: &Drive, + operations: impl Fn() -> Vec>, + block_info: &BlockInfo, +) { + let platform_version = PlatformVersion::latest(); + for apply in [false, true] { + let result = drive.apply_drive_operations( + operations(), + apply, + block_info, + None, + platform_version, + None, + ); + assert!( + matches!(result, Err(Error::Drive(DriveError::NotSupported(_)))), + "{result:?}" + ); + } + let result = drive.convert_drive_operations_to_grove_operations( + operations(), + block_info, + None, + platform_version, + ); + assert!( + matches!(result, Err(Error::Drive(DriveError::NotSupported(_)))), + "{result:?}" + ); +} + +mod funds { + use super::*; + + #[test] + fn should_add_deduct_and_empty_a_readiness_fund_on_its_own_tree() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let fund_id = Identifier::from([0xF1u8; 32]); + + drive + .add_readiness_fund(fund_id, 5_000, None, platform_version) + .expect("add"); + drive + .add_readiness_fund(fund_id, 500, None, platform_version) + .expect("top up"); + assert_eq!( + drive + .fetch_readiness_fund(fund_id.to_buffer(), None, platform_version) + .expect("fetch"), + Some(5_500) + ); + // The voting family does not see it. + assert_eq!( + drive + .fetch_prefunded_specialized_balance(fund_id.to_buffer(), None, platform_version) + .expect("fetch voting"), + None + ); + + let operations = drive + .deduct_from_readiness_fund_operations( + fund_id, + 1_500, + 0, + &mut None, + None, + platform_version, + ) + .expect("deduct"); + apply(&drive, operations, None, platform_version); + assert_eq!( + drive + .fetch_readiness_fund(fund_id.to_buffer(), None, platform_version) + .expect("fetch"), + Some(4_000) + ); + + let (credits, operations) = drive + .empty_readiness_fund_operations(fund_id, true, &mut None, None, platform_version) + .expect("empty"); + assert_eq!(credits, 4_000); + apply(&drive, operations, None, platform_version); + assert_eq!( + drive + .fetch_readiness_fund(fund_id.to_buffer(), None, platform_version) + .expect("fetch"), + None + ); + } + + #[test] + fn should_refuse_a_deduction_below_zero_or_into_the_cleanup_reserve() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let fund_id = Identifier::from([0xF2u8; 32]); + drive + .add_readiness_fund(fund_id, 2_000, None, platform_version) + .expect("add"); + + let result = drive.deduct_from_readiness_fund_operations( + fund_id, + 2_001, + 0, + &mut None, + None, + platform_version, + ); + assert!(matches!( + result, + Err(Error::Drive( + DriveError::PrefundedSpecializedBalanceNotEnough(2_000, 2_001) + )) + )); + + let result = drive.deduct_from_readiness_fund_operations( + fund_id, + 1_001, + 1_000, + &mut None, + None, + platform_version, + ); + assert!( + matches!( + result, + Err(Error::Drive( + DriveError::PrefundedSpecializedBalanceNotEnough(1_000, 1_001) + )) + ), + "{result:?}" + ); + + let result = drive.deduct_from_readiness_fund_operations( + Identifier::from([0xF3u8; 32]), + 1, + 0, + &mut None, + None, + platform_version, + ); + assert!(matches!( + result, + Err(Error::Drive( + DriveError::PrefundedSpecializedBalanceDoesNotExist(_) + )) + )); + } + + #[test] + fn should_refuse_to_add_deduct_or_empty_a_negative_or_mistyped_stored_fund() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let grove_version = &platform_version.drive.grove_version; + for (fund_byte, stored) in [ + (0xE1u8, Element::new_sum_item(-1)), + (0xE2u8, Element::new_item(vec![5])), + (0xE3u8, Element::empty_sum_tree()), + ] { + let fund_id = Identifier::from([fund_byte; 32]); + drive + .grove + .insert( + &prefunded_specialized_balances_for_readiness_path(), + fund_id.as_slice(), + stored, + None, + None, + grove_version, + ) + .unwrap() + .expect("store a corrupted fund"); + let refused = |result: Result<(), Error>| { + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedElementType(_))) + ), + "{result:?}" + ); + }; + refused( + drive + .add_readiness_fund_operations(fund_id, 1, &mut None, None, platform_version) + .map(|_| ()), + ); + refused( + drive + .deduct_from_readiness_fund_operations( + fund_id, + 1, + 0, + &mut None, + None, + platform_version, + ) + .map(|_| ()), + ); + refused( + drive + .empty_readiness_fund_operations( + fund_id, + true, + &mut None, + None, + platform_version, + ) + .map(|_| ()), + ); + } + } + + #[test] + fn should_prove_and_verify_a_readiness_fund_present_and_absent() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let fund_id = Identifier::from([0xF4u8; 32]); + drive + .add_readiness_fund(fund_id, 777, None, platform_version) + .expect("add"); + + let proof = drive + .prove_readiness_fund(fund_id.to_buffer(), None, platform_version) + .expect("prove"); + let (_, balance) = + Drive::verify_readiness_fund(&proof, fund_id.to_buffer(), false, platform_version) + .expect("verify"); + assert_eq!(balance, Some(777)); + + let absent = [0xF5u8; 32]; + let proof = drive + .prove_readiness_fund(absent, None, platform_version) + .expect("prove absent"); + let (_, balance) = + Drive::verify_readiness_fund(&proof, absent, false, platform_version).expect("verify"); + assert_eq!(balance, None); + } + + #[test] + fn should_count_readiness_funds_in_credit_conservation() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + drive + .add_to_system_credits(PAYER_BALANCE, None, platform_version) + .expect("system credits"); + assert!(conservation_holds(&drive, None)); + + let (round, _) = open_round(&drive, [1u8; 32], payer, 10, None); + assert_eq!( + drive + .fetch_readiness_fund(round.funding_id().to_buffer(), None, platform_version) + .expect("fund"), + Some(INITIAL_FUNDING) + ); + assert_eq!( + payer_balance(&drive, payer, None), + PAYER_BALANCE - INITIAL_FUNDING + ); + assert!(conservation_holds(&drive, None)); + + // Spend part of it into the pool, as a report verification fee would. + let mut operations = drive + .deduct_from_readiness_fund_operations( + round.funding_id(), + 10_000, + CLEANUP_RESERVE, + &mut None, + None, + platform_version, + ) + .expect("deduct"); + let mut reads = vec![]; + operations.push( + drive + .add_readiness_pool_credit_operation( + &block_info(1, 1, 1), + 10_000, + true, + None, + &mut reads, + platform_version, + ) + .expect("pool"), + ); + apply(&drive, operations, None, platform_version); + assert_eq!(pool_credits(&drive, None), 10_000); + assert!(conservation_holds(&drive, None)); + } + + #[test] + fn should_estimate_a_new_readiness_fund_at_least_as_high_as_its_write() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let fund_id = Identifier::from([0xF6u8; 32]); + + let mut layer_info = Some(HashMap::new()); + let operations = drive + .add_readiness_fund_operations(fund_id, 1, &mut layer_info, None, platform_version) + .expect("estimate"); + let estimated = estimate( + &drive, + layer_info.expect("layer info"), + operations, + platform_version, + ); + let operations = drive + .add_readiness_fund_operations(fund_id, 1, &mut None, None, platform_version) + .expect("add"); + let applied = apply(&drive, operations, None, platform_version); + assert!( + estimated.storage_fee >= applied.storage_fee, + "{estimated:?} < {applied:?}" + ); + assert!( + estimated.processing_fee >= applied.processing_fee, + "{estimated:?} < {applied:?}" + ); + } + + #[test] + fn should_estimate_a_batch_of_new_readiness_funds_at_least_as_high_as_its_write() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = block_info(1, 1, 1); + let funds = || { + (0..16u8) + .map(|i| { + DriveOperation::PrefundedSpecializedBalanceOperation( + PrefundedSpecializedBalanceOperationType::CreateNewReadinessFund { + fund_id: Identifier::from([i; 32]), + add_balance: 1, + }, + ) + }) + .collect::>() + }; + + let estimated = drive + .apply_drive_operations(funds(), false, &block_info, None, platform_version, None) + .expect("estimate"); + let applied = drive + .apply_drive_operations(funds(), true, &block_info, None, platform_version, None) + .expect("apply"); + assert!( + estimated.storage_fee >= applied.storage_fee, + "{estimated:?} < {applied:?}" + ); + assert!( + estimated.processing_fee >= applied.processing_fee, + "{estimated:?} < {applied:?}" + ); + } + + #[test] + fn should_refuse_a_readiness_fund_written_twice_in_one_batch_and_sum_writes_batched_apart() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = block_info(1, 1, 1); + let fund_id = Identifier::from([0xF3u8; 32]); + drive + .add_readiness_fund(fund_id, 1_000, None, platform_version) + .expect("fund"); + let add = |add_balance| { + DriveOperation::PrefundedSpecializedBalanceOperation( + PrefundedSpecializedBalanceOperationType::CreateNewReadinessFund { + fund_id, + add_balance, + }, + ) + }; + let deduct = || { + DriveOperation::PrefundedSpecializedBalanceOperation( + PrefundedSpecializedBalanceOperationType::DeductFromReadinessFund { + fund_id, + remove_balance: 600, + reserve: 200, + }, + ) + }; + + // Each write is computed from the committed balance, so the second would replace the + // first: two adds would keep only one, two deductions would both pass the reserve. + assert_batch_refused(&drive, || vec![add(20), add(30)], &block_info); + assert_batch_refused(&drive, || vec![deduct(), deduct()], &block_info); + assert_batch_refused(&drive, || vec![add(20), deduct()], &block_info); + assert_eq!( + drive + .fetch_readiness_fund(fund_id.to_buffer(), None, platform_version) + .expect("fetch"), + Some(1_000) + ); + + // Batched apart, both adds land and the reserve holds against the second deduction. + for added in [20, 30] { + drive + .apply_drive_operations( + vec![add(added)], + true, + &block_info, + None, + platform_version, + None, + ) + .expect("add"); + } + assert_eq!( + drive + .fetch_readiness_fund(fund_id.to_buffer(), None, platform_version) + .expect("fetch"), + Some(1_050) + ); + drive + .apply_drive_operations( + vec![deduct()], + true, + &block_info, + None, + platform_version, + None, + ) + .expect("deduct"); + assert!(drive + .apply_drive_operations( + vec![deduct()], + true, + &block_info, + None, + platform_version, + None + ) + .is_err()); + assert_eq!( + drive + .fetch_readiness_fund(fund_id.to_buffer(), None, platform_version) + .expect("fetch"), + Some(450) + ); + } + + #[test] + fn should_refuse_a_fund_amount_no_balance_can_hold_when_estimating_as_when_applying() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = block_info(1, 1, 1); + let add = || { + vec![DriveOperation::PrefundedSpecializedBalanceOperation( + PrefundedSpecializedBalanceOperationType::CreateNewReadinessFund { + fund_id: Identifier::from([0xF5u8; 32]), + add_balance: MAX_CREDITS, + }, + )] + }; + + for apply in [false, true] { + let result = drive.apply_drive_operations( + add(), + apply, + &block_info, + None, + platform_version, + None, + ); + assert!( + matches!( + result, + Err(Error::Identity(IdentityError::CriticalBalanceOverflow(_))) + ), + "{result:?}" + ); + } + } +} + +mod rounds { + use super::*; + + #[test] + fn should_open_a_round_with_its_pointer_record_count_tree_and_fund() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + + let (round, _) = open_round(&drive, contract_id, payer, 10, None); + + let fetched = drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch") + .expect("round"); + assert_eq!(fetched, round); + assert!(fetched.is_pending()); + assert_eq!(fetched.last_evaluated(), None); + assert_eq!( + drive + .fetch_readiness_round_raw_count( + contract_id, + round.round_id(), + None, + platform_version + ) + .expect("count"), + 0 + ); + assert_eq!( + drive + .fetch_readiness_fund(round.funding_id().to_buffer(), None, platform_version) + .expect("fund"), + Some(INITIAL_FUNDING) + ); + assert_eq!( + payer_balance(&drive, payer, None), + PAYER_BALANCE - INITIAL_FUNDING + ); + assert_eq!( + drive + .fetch_readiness_rounds_page(None, 10, None, platform_version) + .expect("page"), + vec![contract_id] + ); + assert_eq!( + drive + .fetch_readiness_scan_cursor(contract_id, round.round_id(), None, platform_version) + .expect("cursor"), + None + ); + } + + #[test] + fn should_refuse_to_open_a_round_the_payer_cannot_fund() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let mut rng = StdRng::seed_from_u64(8); + let payer = + create_test_identity_with_rng(&drive, [0xABu8; 32], &mut rng, None, platform_version) + .expect("payer") + .id(); + let result = drive.open_readiness_round_operations( + opening([1u8; 32], payer, 10), + FUNDING, + &block_info(1_000_000, 10, 100), + &mut None, + None, + platform_version, + ); + assert!( + matches!( + result, + Err(Error::Identity( + crate::error::identity::IdentityError::IdentityInsufficientBalance(_) + )) + ), + "{result:?}" + ); + } + + #[test] + fn should_insert_a_report_once_and_count_distinct_reporters() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (round, _) = open_round(&drive, contract_id, payer, 10, None); + let record = ReadinessReportRecord::new(11, 1, platform_version).expect("record"); + + let (inserted, operations) = drive + .insert_readiness_report_operations( + contract_id, + round.round_id(), + pro_tx_hash(1), + &record, + &mut None, + None, + platform_version, + ) + .expect("insert"); + assert!(inserted); + apply(&drive, operations, None, platform_version); + + // A retransmission: not new, no write. + let (inserted, operations) = drive + .insert_readiness_report_operations( + contract_id, + round.round_id(), + pro_tx_hash(1), + &record, + &mut None, + None, + platform_version, + ) + .expect("insert again"); + assert!(!inserted); + assert!(operations + .iter() + .all(|operation| !matches!(operation, LowLevelDriveOperation::GroveOperation(_)))); + + insert_reports(&drive, &round, 2, 5, None); + assert_eq!( + drive + .fetch_readiness_round_raw_count( + contract_id, + round.round_id(), + None, + platform_version + ) + .expect("count"), + 4 + ); + + // Pages come back in key order and `after` continues from the last key. + let page = drive + .fetch_readiness_reports_page( + contract_id, + round.round_id(), + None, + 3, + None, + platform_version, + ) + .expect("page"); + assert_eq!( + page.iter().map(|(key, _)| *key).collect::>(), + vec![pro_tx_hash(1), pro_tx_hash(2), pro_tx_hash(3)] + ); + assert_eq!(page[0].1, record); + let page = drive + .fetch_readiness_reports_page( + contract_id, + round.round_id(), + Some(pro_tx_hash(3)), + 3, + None, + platform_version, + ) + .expect("page"); + assert_eq!( + page.iter().map(|(key, _)| *key).collect::>(), + vec![pro_tx_hash(4)] + ); + + // Proof of one report. + let proof = drive + .prove_readiness_report( + contract_id, + round.round_id(), + pro_tx_hash(2), + None, + platform_version, + ) + .expect("prove"); + let (_, proved) = Drive::verify_readiness_report( + &proof, + contract_id, + round.round_id(), + pro_tx_hash(2), + false, + platform_version, + ) + .expect("verify"); + assert_eq!(proved, Some(record.clone())); + let proof = drive + .prove_readiness_report( + contract_id, + round.round_id(), + pro_tx_hash(9), + None, + platform_version, + ) + .expect("prove absent"); + let (_, proved) = Drive::verify_readiness_report( + &proof, + contract_id, + round.round_id(), + pro_tx_hash(9), + false, + platform_version, + ) + .expect("verify absent"); + assert_eq!(proved, None); + } + + #[test] + fn should_estimate_pruning_a_full_page_of_reports_at_no_less_than_it_costs() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (round, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &round, 0, 2_000, None); + let pruned: Vec<[u8; 32]> = (0..512).map(pro_tx_hash).collect(); + + let mut estimated_layer_info = Some(HashMap::new()); + let operations = drive + .prune_readiness_reports_operations( + contract_id, + round.round_id(), + &pruned, + &mut estimated_layer_info, + None, + platform_version, + ) + .expect("estimate"); + let estimated = estimate( + &drive, + estimated_layer_info.expect("layer info"), + operations, + platform_version, + ); + let operations = drive + .prune_readiness_reports_operations( + contract_id, + round.round_id(), + &pruned, + &mut None, + None, + platform_version, + ) + .expect("prune"); + let applied = apply(&drive, operations, None, platform_version); + + assert_eq!( + drive + .fetch_readiness_round_raw_count( + contract_id, + round.round_id(), + None, + platform_version + ) + .expect("count"), + 1_488 + ); + assert!( + estimated.processing_fee >= applied.processing_fee, + "estimated processing {} < applied {}", + estimated.processing_fee, + applied.processing_fee + ); + assert!(estimated.storage_fee >= applied.storage_fee); + } + + #[test] + fn should_prune_named_reports_and_decrement_the_count() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (round, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &round, 0, 6, None); + + let operations = drive + .prune_readiness_reports_operations( + contract_id, + round.round_id(), + &[pro_tx_hash(1), pro_tx_hash(4)], + &mut None, + None, + platform_version, + ) + .expect("prune"); + apply(&drive, operations, None, platform_version); + assert_eq!( + drive + .fetch_readiness_round_raw_count( + contract_id, + round.round_id(), + None, + platform_version + ) + .expect("count"), + 4 + ); + let page = drive + .fetch_readiness_reports_page( + contract_id, + round.round_id(), + None, + 10, + None, + platform_version, + ) + .expect("page"); + assert_eq!( + page.iter().map(|(key, _)| *key).collect::>(), + vec![ + pro_tx_hash(0), + pro_tx_hash(2), + pro_tx_hash(3), + pro_tx_hash(5) + ] + ); + } + + #[test] + fn should_store_advance_and_clear_a_scan_cursor() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (round, _) = open_round(&drive, contract_id, payer, 10, None); + + let mut cursor = ReadinessScanCursor::new(100, 400, platform_version).expect("cursor"); + let operations = drive + .store_readiness_scan_cursor_operations( + contract_id, + round.round_id(), + &cursor, + &mut None, + None, + platform_version, + ) + .expect("store"); + apply(&drive, operations, None, platform_version); + cursor + .advance(pro_tx_hash(511), 512, 500, 12) + .expect("advance"); + let operations = drive + .store_readiness_scan_cursor_operations( + contract_id, + round.round_id(), + &cursor, + &mut None, + None, + platform_version, + ) + .expect("store again"); + apply(&drive, operations, None, platform_version); + assert_eq!( + drive + .fetch_readiness_scan_cursor(contract_id, round.round_id(), None, platform_version) + .expect("fetch"), + Some(cursor) + ); + + let operations = drive + .clear_readiness_scan_cursor_operations( + contract_id, + round.round_id(), + &mut None, + None, + platform_version, + ) + .expect("clear"); + apply(&drive, operations, None, platform_version); + assert_eq!( + drive + .fetch_readiness_scan_cursor(contract_id, round.round_id(), None, platform_version) + .expect("fetch"), + None + ); + // Clearing an absent cursor writes nothing. + let operations = drive + .clear_readiness_scan_cursor_operations( + contract_id, + round.round_id(), + &mut None, + None, + platform_version, + ) + .expect("clear absent"); + assert!(operations + .iter() + .all(|operation| !matches!(operation, LowLevelDriveOperation::GroveOperation(_)))); + } + + #[test] + fn should_persist_the_evaluation_mark_and_the_fairness_cursor() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (mut round, _) = open_round(&drive, contract_id, payer, 10, None); + + round.set_evaluation( + ReadinessEvaluation { + core_height: 100, + raw_count: 4, + }, + true, + ); + let operations = drive + .update_readiness_round_evaluation_operations(&round, &mut None, None, platform_version) + .expect("update"); + apply(&drive, operations, None, platform_version); + let fetched = drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch") + .expect("round"); + assert_eq!(fetched, round); + assert!(fetched.awaiting_funding()); + + assert_eq!( + drive + .fetch_readiness_evaluation_cursor(None, platform_version) + .expect("cursor"), + None + ); + let operations = drive + .store_readiness_evaluation_cursor_operations( + Some(contract_id), + &mut None, + None, + platform_version, + ) + .expect("store"); + apply(&drive, operations, None, platform_version); + assert_eq!( + drive + .fetch_readiness_evaluation_cursor(None, platform_version) + .expect("cursor"), + Some(contract_id) + ); + let operations = drive + .store_readiness_evaluation_cursor_operations(None, &mut None, None, platform_version) + .expect("clear"); + apply(&drive, operations, None, platform_version); + assert_eq!( + drive + .fetch_readiness_evaluation_cursor(None, platform_version) + .expect("cursor"), + None + ); + } + + #[test] + fn should_record_a_crossing_queue_its_deadline_and_find_it_when_due() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (mut round, _) = open_round(&drive, contract_id, payer, 10, None); + let cursor = ReadinessScanCursor::new(100, 5, platform_version).expect("cursor"); + let operations = drive + .store_readiness_scan_cursor_operations( + contract_id, + round.round_id(), + &cursor, + &mut None, + None, + platform_version, + ) + .expect("store"); + apply(&drive, operations, None, platform_version); + + let crossing_ms = 1_000_000 + 600_000; + let (deadline_ms, operations) = drive + .record_readiness_crossing_operations( + &mut round, + crossing_ms, + MIN_WAIT_MS, + MAX_WAIT_MS, + &mut None, + None, + platform_version, + ) + .expect("crossing"); + assert_eq!(deadline_ms, crossing_ms + 600_000); + apply(&drive, operations, None, platform_version); + + let fetched = drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch") + .expect("round"); + assert!(!fetched.is_pending()); + assert_eq!(fetched.deadline_ms(), Some(deadline_ms)); + assert_eq!( + drive + .fetch_readiness_scan_cursor(contract_id, round.round_id(), None, platform_version) + .expect("cursor"), + None + ); + assert_eq!( + drive + .fetch_readiness_rounds_due(deadline_ms - 1, 10, None, platform_version) + .expect("due"), + vec![] + ); + let due = drive + .fetch_readiness_rounds_due(deadline_ms, 10, None, platform_version) + .expect("due"); + assert_eq!(due.len(), 1); + assert_eq!(due[0].deadline_ms, deadline_ms); + assert_eq!(due[0].contract_id, contract_id); + assert_eq!(due[0].round_id, round.round_id()); + + // A second crossing on the same round is a code error. + let result = drive.record_readiness_crossing_operations( + &mut round, + crossing_ms + 1, + MIN_WAIT_MS, + MAX_WAIT_MS, + &mut None, + None, + platform_version, + ); + assert!(matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + )); + } + + #[test] + fn should_refuse_to_reopen_a_cancelled_round_awaiting_cleanup() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (round, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &round, 0, 1, None); + let block_info = block_info(1_100_000, 11, 100); + drive + .apply_drive_operations( + vec![DriveOperation::ReadinessOperation( + ReadinessOperationType::CancelRound { + contract_id, + cleanup_reserve: CLEANUP_RESERVE, + }, + )], + true, + &block_info, + None, + platform_version, + None, + ) + .expect("cancel"); + let balance_before = payer_balance(&drive, payer, None); + let reopen = |height| { + vec![DriveOperation::ReadinessOperation( + ReadinessOperationType::OpenRound { + opening: opening(contract_id, payer, height), + funding: FUNDING, + }, + )] + }; + + // The cancelled round keeps its tree until cleanup, and the same opening derives its + // id again: recreating that tree would leave the new round queued for cleanup. + let result = drive.apply_drive_operations( + reopen(10), + true, + &block_info, + None, + platform_version, + None, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "{result:?}" + ); + assert_eq!( + drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch"), + None + ); + let retired = drive + .fetch_retired_readiness_round(None, platform_version) + .expect("retired") + .expect("queued"); + assert_eq!( + (retired.contract_id, retired.round_id), + (contract_id, round.round_id()) + ); + assert_eq!(payer_balance(&drive, payer, None), balance_before); + + // An opening accepted at another height derives another id and opens. + drive + .apply_drive_operations(reopen(11), true, &block_info, None, platform_version, None) + .expect("open"); + let current = drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch") + .expect("current"); + assert_ne!(current.round_id(), round.round_id()); + assert_eq!( + payer_balance(&drive, payer, None), + balance_before - INITIAL_FUNDING + ); + } + + #[test] + fn should_refuse_to_reopen_the_current_round() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (round, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &round, 0, 1, None); + + // The same opening derives the same round id. + let result = drive.open_readiness_round_operations( + opening(contract_id, payer, 10), + FUNDING, + &block_info(1_000_000, 10, 100), + &mut None, + None, + platform_version, + ); + assert!( + matches!( + result, + Err(Error::Drive(DriveError::CorruptedCodeExecution(_))) + ), + "{result:?}" + ); + assert_eq!( + drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch"), + Some(round.clone()) + ); + assert_eq!( + drive + .fetch_readiness_round_raw_count( + contract_id, + round.round_id(), + None, + platform_version + ) + .expect("count"), + 1 + ); + assert_eq!( + drive + .fetch_retired_readiness_round(None, platform_version) + .expect("retired"), + None + ); + } + + #[test] + fn should_leave_the_round_pending_when_a_crossing_is_estimated_or_fails() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (mut round, _) = open_round(&drive, contract_id, payer, 10, None); + let pending = round.clone(); + + // An estimate prices the crossing without publishing it. + let mut layer_info = Some(HashMap::new()); + drive + .record_readiness_crossing_operations( + &mut round, + 1_500_000, + MIN_WAIT_MS, + MAX_WAIT_MS, + &mut layer_info, + None, + platform_version, + ) + .expect("estimate"); + assert_eq!(round, pending); + + // A failure after the crossing is computed (here the cursor removal is not active) + // leaves the round as it was. + let mut no_cursor_removal = platform_version.clone(); + no_cursor_removal + .drive + .methods + .vote + .readiness + .clear_scan_cursor = None; + let result = drive.record_readiness_crossing_operations( + &mut round, + 1_500_000, + MIN_WAIT_MS, + MAX_WAIT_MS, + &mut None, + None, + &no_cursor_removal, + ); + assert!(matches!( + result, + Err(Error::Drive(DriveError::VersionNotActive { .. })) + )); + assert_eq!(round, pending); + + // The same round then crosses. + let deadline_ms = cross(&drive, &mut round, 1_500_000); + assert_eq!(round.deadline_ms(), Some(deadline_ms)); + assert_eq!( + drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch"), + Some(round) + ); + } + + #[test] + fn should_prove_and_verify_a_round_present_and_absent() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + + let proof = drive + .prove_readiness_round(contract_id, None, platform_version) + .expect("prove absent"); + let (_, verified) = + Drive::verify_readiness_round(&proof, contract_id, false, platform_version) + .expect("verify absent"); + assert_eq!(verified, None); + + let (round, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &round, 0, 3, None); + let proof = drive + .prove_readiness_round(contract_id, None, platform_version) + .expect("prove"); + let (_, verified) = + Drive::verify_readiness_round(&proof, contract_id, false, platform_version) + .expect("verify"); + let verified = verified.expect("round"); + assert_eq!(verified.round, round); + assert_eq!(verified.raw_count, 3); + } +} + +mod retirement { + use super::*; + + #[test] + fn should_replace_a_round_in_one_batch_and_net_the_payer_settlement() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (mut first, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &first, 0, 5, None); + let (_, operations) = drive + .record_readiness_crossing_operations( + &mut first, + 1_500_000, + MIN_WAIT_MS, + MAX_WAIT_MS, + &mut None, + None, + platform_version, + ) + .expect("crossing"); + apply(&drive, operations, None, platform_version); + let first_deadline = first.deadline_ms().expect("deadline"); + let balance_before = payer_balance(&drive, payer, None); + + // Replacement at a later height: a different round id for the same bundle. + let (second, _) = open_round(&drive, contract_id, payer, 20, None); + assert_ne!(second.round_id(), first.round_id()); + + // Pointer swapped, new round empty, old round unreachable through the pointer. + let current = drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch") + .expect("round"); + assert_eq!(current, second); + assert_eq!( + drive + .fetch_readiness_round_raw_count( + contract_id, + second.round_id(), + None, + platform_version + ) + .expect("count"), + 0 + ); + // The old subtree is still on disk under its own key until the cleanup drains it. + assert_eq!( + drive + .fetch_readiness_round_raw_count( + contract_id, + first.round_id(), + None, + platform_version + ) + .expect("count"), + 5 + ); + assert_eq!( + drive + .fetch_retired_readiness_round(None, platform_version) + .expect("retired"), + Some(RetiredReadinessRound { + round_id: first.round_id(), + contract_id + }) + ); + // The old deadline entry is gone, with the time tree it was alone in. + assert_eq!( + drive + .fetch_readiness_rounds_due(first_deadline, 10, None, platform_version) + .expect("due"), + vec![] + ); + assert!(!deadline_bucket_exists(&drive, first_deadline)); + // Old fund settled: cleanup reserve to the pool, the remainder netted against the new + // funding in one balance write. + assert_eq!( + drive + .fetch_readiness_fund(first.funding_id().to_buffer(), None, platform_version) + .expect("old fund"), + None + ); + assert_eq!( + drive + .fetch_readiness_fund(second.funding_id().to_buffer(), None, platform_version) + .expect("new fund"), + Some(INITIAL_FUNDING) + ); + assert_eq!(pool_credits(&drive, None), CLEANUP_RESERVE); + assert_eq!( + payer_balance(&drive, payer, None), + balance_before + (INITIAL_FUNDING - CLEANUP_RESERVE) - INITIAL_FUNDING + ); + // Old reports cannot be inserted through the pointer: the pointer names the new round. + let record = ReadinessReportRecord::new(21, 1, platform_version).expect("record"); + let current_round_id = drive + .fetch_readiness_current_round_id_operations( + contract_id, + None, + &mut vec![], + platform_version, + ) + .expect("pointer") + .expect("pointer present"); + assert_eq!(current_round_id, second.round_id()); + let (inserted, operations) = drive + .insert_readiness_report_operations( + contract_id, + current_round_id, + pro_tx_hash(0), + &record, + &mut None, + None, + platform_version, + ) + .expect("insert"); + assert!( + inserted, + "a reporter of the old round starts over in the new one" + ); + apply(&drive, operations, None, platform_version); + } + + /// Retiring a round never opens its reports tree, so replacing a round holding 2,000 + /// reports costs the same as replacing an empty one up to one bounded read: fetching + /// the retiring round's record loads its merk root node, and the reports count tree + /// element in that node carries a 32-byte root key and a wider count only once reports + /// exist. The storage fee is identical; the processing fee differs by that read alone. + #[test] + fn should_charge_the_same_for_replacing_a_full_round_and_an_empty_one() { + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + + let (drive_full, payer_full) = setup(); + let (full, _) = open_round(&drive_full, contract_id, payer_full, 10, None); + insert_reports(&drive_full, &full, 0, 2_000, None); + let (_, fee_full) = open_round(&drive_full, contract_id, payer_full, 20, None); + + let (drive_empty, payer_empty) = setup(); + open_round(&drive_empty, contract_id, payer_empty, 10, None); + let (_, fee_empty) = open_round(&drive_empty, contract_id, payer_empty, 20, None); + + assert_eq!(fee_full.storage_fee, fee_empty.storage_fee); + assert!( + fee_full.processing_fee >= fee_empty.processing_fee, + "reading a populated round cannot be cheaper than reading an empty one" + ); + // One loaded merk node: at most a 32-byte root key, a varint count and their + // framing, priced per loaded byte. + const ONE_ROOT_NODE_READ: Credits = 2_000; + assert!( + fee_full.processing_fee - fee_empty.processing_fee <= ONE_ROOT_NODE_READ, + "full {} vs empty {}: the replacement cost grew with the report count", + fee_full.processing_fee, + fee_empty.processing_fee + ); + + // And the estimate covers both. + let mut layer_info = Some(HashMap::new()); + let (_, operations) = drive_full + .open_readiness_round_operations( + opening(contract_id, payer_full, 30), + FUNDING, + &block_info(1_000_000, 30, 100), + &mut layer_info, + None, + platform_version, + ) + .expect("estimate ops"); + let estimated = estimate( + &drive_full, + layer_info.expect("layer info"), + operations, + platform_version, + ); + assert!( + estimated.processing_fee >= fee_full.processing_fee, + "estimated processing {} < applied {}", + estimated.processing_fee, + fee_full.processing_fee + ); + assert!( + estimated.storage_fee >= fee_full.storage_fee, + "estimated storage {} < applied {}", + estimated.storage_fee, + fee_full.storage_fee + ); + } + + #[test] + fn should_refund_a_replaced_round_to_its_own_payer_when_another_payer_replaces_it() { + let (drive, first_payer) = setup(); + let platform_version = PlatformVersion::latest(); + let second_payer = funded_identity(&drive, 9, [0xACu8; 32], PAYER_BALANCE); + drive + .add_to_system_credits(2 * PAYER_BALANCE, None, platform_version) + .expect("system credits"); + let contract_id = [1u8; 32]; + let (mut first, _) = open_round(&drive, contract_id, first_payer, 10, None); + let (_, operations) = drive + .record_readiness_crossing_operations( + &mut first, + 1_500_000, + MIN_WAIT_MS, + MAX_WAIT_MS, + &mut None, + None, + platform_version, + ) + .expect("crossing"); + apply(&drive, operations, None, platform_version); + assert!(conservation_holds(&drive, None)); + let first_balance_before = payer_balance(&drive, first_payer, None); + let second_balance_before = payer_balance(&drive, second_payer, None); + + let (second, fee) = open_round(&drive, contract_id, second_payer, 20, None); + + // The first payer gets its own remainder back; the second pays the full funding. + assert_eq!( + payer_balance(&drive, first_payer, None), + first_balance_before + INITIAL_FUNDING - CLEANUP_RESERVE + ); + assert_eq!( + payer_balance(&drive, second_payer, None), + second_balance_before - INITIAL_FUNDING + ); + assert_eq!( + drive + .fetch_readiness_fund(second.funding_id().to_buffer(), None, platform_version) + .expect("new fund"), + Some(INITIAL_FUNDING) + ); + assert_eq!( + drive + .fetch_readiness_fund(first.funding_id().to_buffer(), None, platform_version) + .expect("old fund"), + None + ); + assert_eq!(pool_credits(&drive, None), CLEANUP_RESERVE); + assert!(conservation_holds(&drive, None)); + + // The estimate prices the two-payer settlement. + let mut layer_info = Some(HashMap::new()); + let (_, operations) = drive + .open_readiness_round_operations( + opening(contract_id, first_payer, 30), + FUNDING, + &block_info(1_000_000, 30, 100), + &mut layer_info, + None, + platform_version, + ) + .expect("estimate ops"); + let estimated = estimate( + &drive, + layer_info.expect("layer info"), + operations, + platform_version, + ); + assert!( + estimated.processing_fee >= fee.processing_fee, + "estimated processing {} < applied {}", + estimated.processing_fee, + fee.processing_fee + ); + assert!( + estimated.storage_fee >= fee.storage_fee, + "estimated storage {} < applied {}", + estimated.storage_fee, + fee.storage_fee + ); + } + + #[test] + fn should_refuse_a_replacement_the_new_payer_cannot_fund_from_its_own_balance() { + let (drive, first_payer) = setup(); + let platform_version = PlatformVersion::latest(); + // Enough to cover the funding net of the old fund's refund, not the funding itself. + let second_payer = funded_identity(&drive, 9, [0xACu8; 32], 5_000_000); + let contract_id = [1u8; 32]; + let (first, _) = open_round(&drive, contract_id, first_payer, 10, None); + + let result = drive.open_readiness_round_operations( + opening(contract_id, second_payer, 20), + FUNDING, + &block_info(1_000_000, 20, 100), + &mut None, + None, + platform_version, + ); + assert!( + matches!( + result, + Err(Error::Identity( + crate::error::identity::IdentityError::IdentityInsufficientBalance(_) + )) + ), + "{result:?}" + ); + assert_eq!( + drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch"), + Some(first) + ); + } + + #[test] + fn should_cancel_a_round_refund_the_remainder_and_keep_the_votes_tree_shape() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (round, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &round, 0, 3, None); + let balance_before = payer_balance(&drive, payer, None); + + let (cancelled, operations) = drive + .cancel_readiness_round_operations( + contract_id, + CLEANUP_RESERVE, + &block_info(1_100_000, 11, 100), + &mut None, + None, + platform_version, + ) + .expect("cancel"); + assert_eq!(cancelled, Some(round.clone())); + apply(&drive, operations, None, platform_version); + + assert_eq!( + drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch"), + None + ); + assert_eq!( + payer_balance(&drive, payer, None), + balance_before + INITIAL_FUNDING - CLEANUP_RESERVE + ); + assert_eq!(pool_credits(&drive, None), CLEANUP_RESERVE); + assert_eq!( + drive + .fetch_retired_readiness_round(None, platform_version) + .expect("retired"), + Some(RetiredReadinessRound { + round_id: round.round_id(), + contract_id + }) + ); + // The votes tree still has its four children. + let grove_version = &platform_version.drive.grove_version; + for key in [b'd', b'c', b'e', READINESS_TREE_KEY as u8] { + assert!(drive + .grove + .get(&vote_root_path(), &[key], None, grove_version) + .unwrap() + .is_ok()); + } + // Cancelling again is a no-op. + let (cancelled, operations) = drive + .cancel_readiness_round_operations( + contract_id, + CLEANUP_RESERVE, + &block_info(1_100_000, 12, 100), + &mut None, + None, + platform_version, + ) + .expect("cancel again"); + assert_eq!(cancelled, None); + assert!(operations + .iter() + .all(|operation| !matches!(operation, LowLevelDriveOperation::GroveOperation(_)))); + } + + /// Cancels the contract's round through the generic batch path, first as an estimate + /// and then applied, and returns both fees. + fn estimate_then_cancel( + drive: &Drive, + contract_id: [u8; 32], + block_info: &BlockInfo, + ) -> (FeeResult, FeeResult) { + let platform_version = PlatformVersion::latest(); + let cancel = || { + vec![DriveOperation::ReadinessOperation( + ReadinessOperationType::CancelRound { + contract_id, + cleanup_reserve: CLEANUP_RESERVE, + }, + )] + }; + let estimated = drive + .apply_drive_operations(cancel(), false, block_info, None, platform_version, None) + .expect("estimate"); + let applied = drive + .apply_drive_operations(cancel(), true, block_info, None, platform_version, None) + .expect("apply"); + (estimated, applied) + } + + fn assert_estimate_covers(estimated: &FeeResult, applied: &FeeResult) { + assert!(applied.processing_fee > 0 && applied.storage_fee > 0); + assert!( + estimated.processing_fee >= applied.processing_fee, + "estimated processing {} < applied {}", + estimated.processing_fee, + applied.processing_fee + ); + assert!( + estimated.storage_fee >= applied.storage_fee, + "estimated storage {} < applied {}", + estimated.storage_fee, + applied.storage_fee + ); + } + + #[test] + fn should_estimate_cancelling_a_funded_pending_round_at_no_less_than_it_costs() { + let (drive, payer) = setup(); + let contract_id = [1u8; 32]; + let (round, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &round, 0, 3, None); + let balance_before = payer_balance(&drive, payer, None); + + let (estimated, applied) = + estimate_then_cancel(&drive, contract_id, &block_info(1_100_000, 11, 100)); + + // The applied run cancelled the round and settled its fund. + assert_eq!( + drive + .fetch_readiness_round(contract_id, None, PlatformVersion::latest()) + .expect("fetch"), + None + ); + assert_eq!( + payer_balance(&drive, payer, None), + balance_before + INITIAL_FUNDING - CLEANUP_RESERVE + ); + assert_eq!(pool_credits(&drive, None), CLEANUP_RESERVE); + assert_estimate_covers(&estimated, &applied); + } + + #[test] + fn should_estimate_cancelling_a_funded_crossed_round_at_no_less_than_it_costs() { + let (drive, payer) = setup(); + let contract_id = [1u8; 32]; + let (mut round, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &round, 0, 3, None); + let deadline_ms = cross(&drive, &mut round, 1_500_000); + let balance_before = payer_balance(&drive, payer, None); + + let (estimated, applied) = + estimate_then_cancel(&drive, contract_id, &block_info(1_600_000, 12, 100)); + + // The applied run also dropped the deadline entry and its time tree. + assert!(!deadline_bucket_exists(&drive, deadline_ms)); + assert_eq!( + payer_balance(&drive, payer, None), + balance_before + INITIAL_FUNDING - CLEANUP_RESERVE + ); + assert_eq!(pool_credits(&drive, None), CLEANUP_RESERVE); + assert_estimate_covers(&estimated, &applied); + } + + #[test] + fn should_refuse_two_retirements_in_one_batch_and_credit_both_when_batched_apart() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let first = [1u8; 32]; + let second = [2u8; 32]; + open_round(&drive, first, payer, 10, None); + open_round(&drive, second, payer, 10, None); + let block_info = block_info(1_100_000, 11, 100); + let cancel = |contract_id| { + DriveOperation::ReadinessOperation(ReadinessOperationType::CancelRound { + contract_id, + cleanup_reserve: CLEANUP_RESERVE, + }) + }; + + // Both retirements would rewrite the pool from the same read, so one credit would be + // lost; the batch is refused whether estimated, applied or converted. + assert_batch_refused(&drive, || vec![cancel(first), cancel(second)], &block_info); + for contract_id in [first, second] { + assert!(drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch") + .is_some()); + } + + // One retirement per batch credits every reserve. + for contract_id in [first, second] { + drive + .apply_drive_operations( + vec![cancel(contract_id)], + true, + &block_info, + None, + platform_version, + None, + ) + .expect("cancel"); + } + assert_eq!(pool_credits(&drive, None), 2 * CLEANUP_RESERVE); + } + + #[test] + fn should_refuse_a_round_settlement_beside_another_balance_write_of_its_batch() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let open_contract = [1u8; 32]; + let cancel_contract = [2u8; 32]; + open_round(&drive, cancel_contract, payer, 10, None); + let block_info = block_info(1_100_000, 11, 100); + let balance_before = payer_balance(&drive, payer, None); + let open = || { + DriveOperation::ReadinessOperation(ReadinessOperationType::OpenRound { + opening: opening(open_contract, payer, 11), + funding: FUNDING, + }) + }; + let cancel = || { + DriveOperation::ReadinessOperation(ReadinessOperationType::CancelRound { + contract_id: cancel_contract, + cleanup_reserve: CLEANUP_RESERVE, + }) + }; + let debit = || { + DriveOperation::IdentityOperation(IdentityOperationType::RemoveFromIdentityBalance { + identity_id: payer.to_buffer(), + balance_to_remove: 3_000_000, + }) + }; + let credit = || { + DriveOperation::IdentityOperation(IdentityOperationType::AddToIdentityBalance { + identity_id: payer.to_buffer(), + added_balance: 3_000_000, + }) + }; + let fund = || { + DriveOperation::PrefundedSpecializedBalanceOperation( + PrefundedSpecializedBalanceOperationType::CreateNewReadinessFund { + fund_id: Identifier::from([0xF4u8; 32]), + add_balance: 1, + }, + ) + }; + + // A settlement writes the payer's balance as an absolute value computed before the + // batch, so it would erase the debit or the credit batched beside it. + assert_batch_refused(&drive, || vec![open(), debit()], &block_info); + assert_batch_refused(&drive, || vec![debit(), open()], &block_info); + assert_batch_refused(&drive, || vec![cancel(), credit()], &block_info); + assert_batch_refused(&drive, || vec![cancel(), fund()], &block_info); + assert_eq!(payer_balance(&drive, payer, None), balance_before); + + // Batched apart, the debit and the opening both reach the payer. + for operation in [debit(), open()] { + drive + .apply_drive_operations( + vec![operation], + true, + &block_info, + None, + platform_version, + None, + ) + .expect("apply"); + } + assert_eq!( + payer_balance(&drive, payer, None), + balance_before - 3_000_000 - INITIAL_FUNDING + ); + } + + #[test] + fn should_refuse_a_raw_grovedb_write_beside_a_round_settlement_or_fund_write() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let block_info = block_info(1_000_000, 10, 100); + let balance_before = payer_balance(&drive, payer, None); + // The same debit a typed operation would make, handed over as raw GroveDB writes. + let raw_debit = LowLevelDriveOperation::grovedb_operations_consume( + drive + .remove_from_identity_balance_operations( + payer.to_buffer(), + 3_000_000, + &mut None, + None, + platform_version, + ) + .expect("debit operations"), + ); + assert!(!raw_debit.is_empty()); + let open = || { + DriveOperation::ReadinessOperation(ReadinessOperationType::OpenRound { + opening: opening(contract_id, payer, 10), + funding: FUNDING, + }) + }; + let fund = || { + DriveOperation::PrefundedSpecializedBalanceOperation( + PrefundedSpecializedBalanceOperationType::CreateNewReadinessFund { + fund_id: Identifier::from([0xF5u8; 32]), + add_balance: 1, + }, + ) + }; + let raw_operations = || { + raw_debit + .iter() + .cloned() + .map(DriveOperation::GroveDBOperation) + .collect::>() + }; + let raw_batch = + || DriveOperation::GroveDBOpBatch(GroveDbOpBatch::from_operations(raw_debit.clone())); + + let readiness_writes: [&dyn Fn() -> DriveOperation<'static>; 2] = [&open, &fund]; + for readiness_write in readiness_writes { + assert_batch_refused( + &drive, + || { + let mut operations = vec![readiness_write()]; + operations.extend(raw_operations()); + operations + }, + &block_info, + ); + assert_batch_refused( + &drive, + || { + let mut operations = raw_operations(); + operations.push(readiness_write()); + operations + }, + &block_info, + ); + assert_batch_refused(&drive, || vec![readiness_write(), raw_batch()], &block_info); + assert_batch_refused(&drive, || vec![raw_batch(), readiness_write()], &block_info); + } + assert_eq!(payer_balance(&drive, payer, None), balance_before); + } + + #[test] + fn should_route_the_debt_an_activation_refund_repays_to_the_processing_pool() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let payer = funded_identity(&drive, 8, [0xABu8; 32], INITIAL_FUNDING); + drive + .add_to_system_credits(INITIAL_FUNDING, None, platform_version) + .expect("system credits"); + let contract_id = [1u8; 32]; + let (mut round, _) = open_round(&drive, contract_id, payer, 10, None); + let deadline_ms = cross(&drive, &mut round, 1_500_000); + // The payer spent everything on the opening and has since fallen into debt. + const DEBT: Credits = 5_000_000; + let debt_operation = drive + .update_identity_negative_credit_operation(payer.to_buffer(), DEBT, platform_version) + .expect("debt operation"); + apply(&drive, vec![debt_operation], None, platform_version); + let block_info = block_info(deadline_ms, 20, 100); + let activate = || { + DriveOperation::ReadinessOperation(ReadinessOperationType::ActivateRound { + contract_id, + round_id: round.round_id(), + cleanup_reserve: CLEANUP_RESERVE, + }) + }; + + // Applied as plain low level operations, the repaid debt has nowhere to go. + let (_, operations) = drive + .activate_readiness_round_operations( + contract_id, + round.round_id(), + CLEANUP_RESERVE, + &block_info, + &mut None, + None, + platform_version, + ) + .expect("activation operations"); + assert!(LowLevelDriveOperation::holds_repaid_identity_debt( + &operations + )); + // Beside another balance write, the settlement would overwrite it. + assert_batch_refused( + &drive, + || { + vec![ + activate(), + DriveOperation::IdentityOperation( + IdentityOperationType::AddToIdentityBalance { + identity_id: payer.to_buffer(), + added_balance: 1, + }, + ), + ] + }, + &block_info, + ); + + let estimated = drive + .apply_drive_operations( + vec![activate()], + false, + &block_info, + None, + platform_version, + None, + ) + .expect("estimate"); + let applied = drive + .apply_drive_operations( + vec![activate()], + true, + &block_info, + None, + platform_version, + None, + ) + .expect("activate"); + assert_estimate_covers(&estimated, &applied); + + assert_eq!( + drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch"), + None + ); + assert_eq!( + payer_balance(&drive, payer, None), + INITIAL_FUNDING - CLEANUP_RESERVE - DEBT + ); + assert_eq!( + drive + .fetch_identity_negative_balance_operations( + payer.to_buffer(), + true, + None, + &mut vec![], + platform_version, + ) + .expect("debt"), + Some(0) + ); + assert_eq!(pool_credits(&drive, None), CLEANUP_RESERVE + DEBT); + assert!(conservation_holds(&drive, None)); + } + + #[test] + fn should_activate_a_crossed_round_and_refuse_a_pending_early_or_stale_one() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let (mut round, _) = open_round(&drive, contract_id, payer, 10, None); + + let result = drive.activate_readiness_round_operations( + contract_id, + round.round_id(), + CLEANUP_RESERVE, + &block_info(2_000_000, 20, 100), + &mut None, + None, + platform_version, + ); + assert!(matches!( + result, + Err(Error::Drive(DriveError::CorruptedDriveState(_))) + )); + + let (deadline_ms, operations) = drive + .record_readiness_crossing_operations( + &mut round, + 1_500_000, + MIN_WAIT_MS, + MAX_WAIT_MS, + &mut None, + None, + platform_version, + ) + .expect("crossing"); + apply(&drive, operations, None, platform_version); + let balance_before = payer_balance(&drive, payer, None); + + let result = drive.activate_readiness_round_operations( + contract_id, + [9u8; 32], + CLEANUP_RESERVE, + &block_info(deadline_ms, 20, 100), + &mut None, + None, + platform_version, + ); + assert!(matches!( + result, + Err(Error::Drive(DriveError::CorruptedDriveState(_))) + )); + + let result = drive.activate_readiness_round_operations( + contract_id, + round.round_id(), + CLEANUP_RESERVE, + &block_info(deadline_ms - 1, 20, 100), + &mut None, + None, + platform_version, + ); + assert!(matches!( + result, + Err(Error::Drive(DriveError::CorruptedDriveState(_))) + )); + + let (activated, operations) = drive + .activate_readiness_round_operations( + contract_id, + round.round_id(), + CLEANUP_RESERVE, + &block_info(deadline_ms, 20, 100), + &mut None, + None, + platform_version, + ) + .expect("activate"); + assert_eq!(activated, round); + apply(&drive, operations, None, platform_version); + + assert_eq!( + drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch"), + None + ); + assert_eq!( + drive + .fetch_readiness_rounds_due(deadline_ms, 10, None, platform_version) + .expect("due"), + vec![] + ); + assert!(!deadline_bucket_exists(&drive, deadline_ms)); + assert_eq!( + payer_balance(&drive, payer, None), + balance_before + INITIAL_FUNDING - CLEANUP_RESERVE + ); + assert_eq!(pool_credits(&drive, None), CLEANUP_RESERVE); + } + + /// Activates the contract's round, first as an estimate and then applied, and returns + /// both fees and both write counts. + fn estimate_then_activate( + drive: &Drive, + contract_id: [u8; 32], + round_id: [u8; 32], + block_info: &BlockInfo, + ) -> ((FeeResult, usize), (FeeResult, usize)) { + let platform_version = PlatformVersion::latest(); + let writes = |operations: &[LowLevelDriveOperation]| { + operations + .iter() + .filter(|operation| matches!(operation, LowLevelDriveOperation::GroveOperation(_))) + .count() + }; + let mut layer_info = Some(HashMap::new()); + let (_, operations) = drive + .activate_readiness_round_operations( + contract_id, + round_id, + CLEANUP_RESERVE, + block_info, + &mut layer_info, + None, + platform_version, + ) + .expect("estimate"); + let estimated_writes = writes(&operations); + let estimated = estimate( + drive, + layer_info.expect("layer info"), + operations, + platform_version, + ); + let (_, operations) = drive + .activate_readiness_round_operations( + contract_id, + round_id, + CLEANUP_RESERVE, + block_info, + &mut None, + None, + platform_version, + ) + .expect("activate"); + let applied_writes = writes(&operations); + let applied = apply(drive, operations, None, platform_version); + ((estimated, estimated_writes), (applied, applied_writes)) + } + + #[test] + fn should_estimate_an_activation_without_reading_state() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let mut layer_info = Some(HashMap::new()); + let (_, operations) = drive + .activate_readiness_round_operations( + [1u8; 32], + [2u8; 32], + CLEANUP_RESERVE, + &block_info(2_000_000, 20, 100), + &mut layer_info, + None, + platform_version, + ) + .expect("estimate without a stored round"); + let estimated = estimate( + &drive, + layer_info.expect("layer info"), + operations, + platform_version, + ); + assert!(estimated.processing_fee > 0 && estimated.storage_fee > 0); + } + + #[test] + fn should_estimate_activating_a_funded_crossed_round_at_no_less_than_it_costs() { + let (drive, payer) = setup(); + let contract_id = [1u8; 32]; + let (mut round, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &round, 0, 3, None); + let deadline_ms = cross(&drive, &mut round, 1_500_000); + let balance_before = payer_balance(&drive, payer, None); + + let ((estimated, estimated_writes), (applied, applied_writes)) = estimate_then_activate( + &drive, + contract_id, + round.round_id(), + &block_info(deadline_ms, 20, 100), + ); + + // The applied run refunded the payer, so the estimate prices that credit too. + assert!( + estimated_writes >= applied_writes, + "{estimated_writes} estimated writes < {applied_writes} applied" + ); + assert!(!deadline_bucket_exists(&drive, deadline_ms)); + assert_eq!( + payer_balance(&drive, payer, None), + balance_before + INITIAL_FUNDING - CLEANUP_RESERVE + ); + assert_eq!(pool_credits(&drive, None), CLEANUP_RESERVE); + assert_estimate_covers(&estimated, &applied); + } + + #[test] + fn should_leave_no_partial_subtree_when_an_opening_batch_is_rolled_back() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let grove_version = &platform_version.drive.grove_version; + + let transaction = drive.grove.start_transaction(); + open_round(&drive, contract_id, payer, 10, Some(&transaction)); + assert!(drive + .fetch_readiness_round(contract_id, Some(&transaction), platform_version) + .expect("fetch") + .is_some()); + drive + .grove + .rollback_transaction(&transaction) + .expect("rollback"); + + assert_eq!( + drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch"), + None + ); + let contracts_path = crate::drive::votes::paths::readiness_contracts_tree_path(); + assert!(drive + .grove + .get(&contracts_path, &contract_id, None, grove_version) + .unwrap() + .is_err()); + assert_eq!(payer_balance(&drive, payer, None), PAYER_BALANCE); + } +} + +mod cleanup { + use super::*; + + fn cleanup_step( + drive: &Drive, + round_id: [u8; 32], + contract_id: [u8; 32], + max_deletes: u16, + ) -> (ReadinessCleanupOutcome, FeeResult) { + let platform_version = PlatformVersion::latest(); + let (outcome, operations) = drive + .cleanup_retired_readiness_round_operations( + round_id, + contract_id, + max_deletes, + &mut None, + None, + platform_version, + ) + .expect("cleanup"); + let fee = apply(drive, operations, None, platform_version); + (outcome, fee) + } + + #[test] + fn should_drain_a_retired_round_in_bounded_steps_and_remove_its_trees() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let grove_version = &platform_version.drive.grove_version; + let (old, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &old, 0, 2_000, None); + let cursor = ReadinessScanCursor::new(100, 5, platform_version).expect("cursor"); + let operations = drive + .store_readiness_scan_cursor_operations( + contract_id, + old.round_id(), + &cursor, + &mut None, + None, + platform_version, + ) + .expect("store"); + apply(&drive, operations, None, platform_version); + // Cancel so that no live round remains and the contract tree can go too. + let (_, operations) = drive + .cancel_readiness_round_operations( + contract_id, + CLEANUP_RESERVE, + &block_info(1_100_000, 11, 100), + &mut None, + None, + platform_version, + ) + .expect("cancel"); + apply(&drive, operations, None, platform_version); + + // The estimation branch prices `max_deletes` report reads, deletes and path walks + // plus the fixed tail, so it grows with `max_deletes` and bounds every applied step + // of that size, full or final. + let estimate_for = |max_deletes: u16| { + let mut estimated_layer_info = Some(HashMap::new()); + let (outcome, estimated_operations) = drive + .cleanup_retired_readiness_round_operations( + old.round_id(), + contract_id, + max_deletes, + &mut estimated_layer_info, + None, + platform_version, + ) + .expect("estimate"); + assert!(outcome.finished); + assert_eq!(outcome.reports_deleted, max_deletes as u64); + estimate( + &drive, + estimated_layer_info.expect("layer info"), + estimated_operations, + platform_version, + ) + }; + let estimate_small = estimate_for(8); + let estimate_large = estimate_for(512); + assert!(estimate_large.processing_fee > estimate_small.processing_fee); + + let mut steps = 0; + let mut previous_fee: Option = None; + loop { + let (outcome, fee) = cleanup_step(&drive, old.round_id(), contract_id, 512); + steps += 1; + assert!(fee.processing_fee > 0); + assert!( + estimate_large.processing_fee >= fee.processing_fee, + "step {steps}: estimated {} < applied {}", + estimate_large.processing_fee, + fee.processing_fee + ); + assert!(estimate_large.storage_fee >= fee.storage_fee); + if let Some(previous) = previous_fee.as_ref() { + if !outcome.finished { + // Full steps delete the same number of reports and cost about the same. + let (low, high) = if previous.processing_fee <= fee.processing_fee { + (previous.processing_fee, fee.processing_fee) + } else { + (fee.processing_fee, previous.processing_fee) + }; + assert!( + high <= low + low / 4, + "step {steps}: {high} is not within a quarter of the previous step's {low}" + ); + } + } + previous_fee = Some(fee); + if outcome.finished { + assert_eq!(outcome.reports_deleted, 2_000 - 512 * 3); + break; + } + assert_eq!(outcome.reports_deleted, 512); + assert!(steps < 4, "the round should drain in four steps"); + } + assert_eq!(steps, 4); + + assert_eq!( + drive + .fetch_retired_readiness_round(None, platform_version) + .expect("retired"), + None + ); + let old_round_id = old.round_id(); + let round_path = readiness_round_tree_path(&contract_id, &old_round_id); + for key in [ + READINESS_ROUND_RECORD_KEY, + READINESS_ROUND_REPORTS_TREE_KEY, + READINESS_ROUND_SCAN_CURSOR_KEY, + ] { + assert!(drive + .grove + .get(&round_path, &[key], None, grove_version) + .unwrap() + .is_err()); + } + let contract_path = readiness_contract_tree_path(&contract_id); + assert!(drive + .grove + .get(&contract_path, &old_round_id, None, grove_version) + .unwrap() + .is_err()); + assert!(drive + .grove + .get( + &contract_path, + &[READINESS_CURRENT_ROUND_POINTER_KEY as u8], + None, + grove_version + ) + .unwrap() + .is_err()); + let contracts_path = crate::drive::votes::paths::readiness_contracts_tree_path(); + assert!(drive + .grove + .get(&contracts_path, &contract_id, None, grove_version) + .unwrap() + .is_err()); + assert_eq!( + drive + .fetch_readiness_rounds_page(None, 10, None, platform_version) + .expect("page"), + Vec::<[u8; 32]>::new() + ); + } + + #[test] + fn should_keep_the_contract_tree_while_a_live_round_remains_and_queue_in_order() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let contract_id = [1u8; 32]; + let grove_version = &platform_version.drive.grove_version; + let (first, _) = open_round(&drive, contract_id, payer, 10, None); + insert_reports(&drive, &first, 0, 3, None); + let (second, _) = open_round(&drive, contract_id, payer, 20, None); + insert_reports(&drive, &second, 0, 2, None); + let (third, _) = open_round(&drive, contract_id, payer, 30, None); + + // Queue order is by round id (the key), so drain whichever comes first. + let queued = drive + .fetch_retired_readiness_round(None, platform_version) + .expect("retired") + .expect("first entry") + .round_id; + assert!(queued == first.round_id() || queued == second.round_id()); + + let (outcome, _) = cleanup_step(&drive, queued, contract_id, 512); + assert!(outcome.finished); + let queued_next = drive + .fetch_retired_readiness_round(None, platform_version) + .expect("retired") + .expect("second entry") + .round_id; + assert_ne!(queued_next, queued); + let (outcome, _) = cleanup_step(&drive, queued_next, contract_id, 512); + assert!(outcome.finished); + assert_eq!( + drive + .fetch_retired_readiness_round(None, platform_version) + .expect("retired"), + None + ); + + // The live round and its contract tree are untouched. + let current = drive + .fetch_readiness_round(contract_id, None, platform_version) + .expect("fetch") + .expect("round"); + assert_eq!(current, third); + let contract_path = readiness_contract_tree_path(&contract_id); + assert!(drive + .grove + .get(&contract_path, &third.round_id(), None, grove_version) + .unwrap() + .is_ok()); + assert!(drive + .grove + .get(&contract_path, &first.round_id(), None, grove_version) + .unwrap() + .is_err()); + } + + #[test] + fn should_keep_a_shared_deadline_tree_when_only_one_of_its_rounds_retires() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let grove_version = &platform_version.drive.grove_version; + let mut rounds = vec![]; + for contract in 1u8..=2 { + let contract_id = [contract; 32]; + let (mut round, _) = open_round(&drive, contract_id, payer, 10, None); + let (_, operations) = drive + .record_readiness_crossing_operations( + &mut round, + 1_500_000, + MIN_WAIT_MS, + MAX_WAIT_MS, + &mut None, + None, + platform_version, + ) + .expect("crossing"); + apply(&drive, operations, None, platform_version); + rounds.push(round); + } + let deadline_ms = rounds[0].deadline_ms().expect("deadline"); + assert_eq!(rounds[1].deadline_ms(), Some(deadline_ms)); + + let (_, operations) = drive + .cancel_readiness_round_operations( + [1u8; 32], + CLEANUP_RESERVE, + &block_info(1_600_000, 16, 100), + &mut None, + None, + platform_version, + ) + .expect("cancel"); + apply(&drive, operations, None, platform_version); + + let due = drive + .fetch_readiness_rounds_due(deadline_ms, 10, None, platform_version) + .expect("due"); + assert_eq!(due.len(), 1); + assert_eq!(due[0].contract_id, [2u8; 32]); + let deadline_path = readiness_deadline_tree_path_vec(deadline_ms); + assert!(drive + .grove + .get(deadline_path.as_slice(), &[2u8; 32], None, grove_version) + .unwrap() + .is_ok()); + + // The last round at that time takes the time tree with it; the deadlines tree stays. + let (_, operations) = drive + .cancel_readiness_round_operations( + [2u8; 32], + CLEANUP_RESERVE, + &block_info(1_600_000, 17, 100), + &mut None, + None, + platform_version, + ) + .expect("cancel"); + apply(&drive, operations, None, platform_version); + assert!(!deadline_bucket_exists(&drive, deadline_ms)); + let readiness_path = crate::drive::votes::paths::readiness_tree_path_vec(); + assert!(drive + .grove + .get( + readiness_path.as_slice(), + &[crate::drive::votes::paths::READINESS_DEADLINES_TREE_KEY], + None, + grove_version + ) + .unwrap() + .is_ok()); + } + + /// The due query walks the time keys in order under its limit, and an empty time tree + /// spends that limit without yielding an entry. A retired round's emptied time tree + /// ahead of a live deadline must therefore not survive, or a block scanning with a + /// small limit would never reach the live one. + #[test] + fn should_find_a_later_due_round_with_limit_one_after_an_earlier_deadline_retires() { + let (drive, payer) = setup(); + let platform_version = PlatformVersion::latest(); + let (mut early, _) = open_round(&drive, [1u8; 32], payer, 10, None); + let (mut late, _) = open_round(&drive, [2u8; 32], payer, 10, None); + let early_deadline = cross(&drive, &mut early, 1_500_000); + let late_deadline = cross(&drive, &mut late, 1_700_000); + assert!(early_deadline < late_deadline); + + let (_, operations) = drive + .cancel_readiness_round_operations( + [1u8; 32], + CLEANUP_RESERVE, + &block_info(1_800_000, 18, 100), + &mut None, + None, + platform_version, + ) + .expect("cancel"); + apply(&drive, operations, None, platform_version); + assert!(!deadline_bucket_exists(&drive, early_deadline)); + assert!(deadline_bucket_exists(&drive, late_deadline)); + + // Every block asks again and gets the live round, never an empty page. + for _ in 0..3 { + let due = drive + .fetch_readiness_rounds_due(late_deadline, 1, None, platform_version) + .expect("due"); + assert_eq!(due.len(), 1); + assert_eq!(due[0].contract_id, [2u8; 32]); + assert_eq!(due[0].round_id, late.round_id()); + assert_eq!(due[0].deadline_ms, late_deadline); + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/update_readiness_round_evaluation_operations/mod.rs b/packages/rs-drive/src/drive/votes/readiness/update_readiness_round_evaluation_operations/mod.rs new file mode 100644 index 00000000000..8b7bcb0580d --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/update_readiness_round_evaluation_operations/mod.rs @@ -0,0 +1,62 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Rewrites a round's record. The caller has already changed the record in memory (its + /// evaluation mark, its funding flag or its status); this writes it back under its own + /// key. + /// + /// # Parameters + /// + /// * `round` - The record to write. + /// * `estimated_costs_only_with_layer_info` - `Some` to estimate instead of read state. + /// * `transaction` - The current transaction. + /// * `platform_version` - The platform version to use. + /// + /// # Returns + /// + /// * The low level operations that perform the write. + /// * `Err(DriveError::VersionNotActive)` on a platform version without readiness. + pub fn update_readiness_round_evaluation_operations( + &self, + round: &ReadinessRound, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .readiness + .update_round_evaluation + { + Some(0) => self.update_readiness_round_evaluation_operations_v0( + round, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "update_readiness_round_evaluation_operations".to_string(), + known_versions: vec![0], + })), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "update_readiness_round_evaluation_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/readiness/update_readiness_round_evaluation_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/readiness/update_readiness_round_evaluation_operations/v0/mod.rs new file mode 100644 index 00000000000..75ba3af7a62 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/readiness/update_readiness_round_evaluation_operations/v0/mod.rs @@ -0,0 +1,42 @@ +use crate::drive::votes::paths::{readiness_round_tree_path_vec, READINESS_ROUND_RECORD_KEY}; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::fees::op::LowLevelDriveOperation::GroveOperation; +use dpp::serialization::PlatformSerializable; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + pub(super) fn update_readiness_round_evaluation_operations_v0( + &self, + round: &ReadinessRound, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + _transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let contract_id = round.contract_id().to_buffer(); + let round_id = round.round_id(); + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_readiness( + contract_id, + round_id, + estimated_costs_only_with_layer_info, + platform_version, + )?; + } + // The record grows when its evaluation mark or its status is first set, so the + // rewrite is priced as an insert of the new bytes rather than a same-size replace. + let op = QualifiedGroveDbOp::insert_or_replace_op( + readiness_round_tree_path_vec(contract_id, round_id), + vec![READINESS_ROUND_RECORD_KEY], + Element::new_item(round.serialize_to_bytes()?), + ); + Ok(vec![GroveOperation(op)]) + } +} diff --git a/packages/rs-drive/src/drive/votes/setup/setup_initial_vote_tree_main_structure/mod.rs b/packages/rs-drive/src/drive/votes/setup/setup_initial_vote_tree_main_structure/mod.rs index 25ece38374d..dfc9ac3fddf 100644 --- a/packages/rs-drive/src/drive/votes/setup/setup_initial_vote_tree_main_structure/mod.rs +++ b/packages/rs-drive/src/drive/votes/setup/setup_initial_vote_tree_main_structure/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; @@ -43,9 +44,10 @@ impl Drive { .add_initial_vote_tree_main_structure_operations { 0 => Drive::add_initial_vote_tree_main_structure_operations_v0(batch), + 1 => Drive::add_initial_vote_tree_main_structure_operations_v1(batch), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_initial_vote_tree_main_structure_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/votes/setup/setup_initial_vote_tree_main_structure/v1/mod.rs b/packages/rs-drive/src/drive/votes/setup/setup_initial_vote_tree_main_structure/v1/mod.rs new file mode 100644 index 00000000000..d57189dacca --- /dev/null +++ b/packages/rs-drive/src/drive/votes/setup/setup_initial_vote_tree_main_structure/v1/mod.rs @@ -0,0 +1,56 @@ +use crate::drive::prefunded_specialized_balances::{ + prefunded_specialized_balances_path, PREFUNDED_BALANCES_FOR_READINESS, +}; +use crate::drive::votes::paths::{ + readiness_tree_path_vec, vote_root_path_vec, READINESS_CONTRACTS_TREE_KEY, + READINESS_DEADLINES_TREE_KEY, READINESS_RETIRED_ROUNDS_TREE_KEY, READINESS_TREE_KEY, +}; +use crate::drive::Drive; +use crate::error::Error; +use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; +use crate::util::batch::GroveDbOpBatch; + +impl Drive { + /// Generation 1 adds the compilation readiness structures on top of generation 0: the + /// `[Votes] / r` tree with its contracts, deadlines and retired-rounds children, and the + /// readiness fund sum tree `[PreFundedSpecializedBalances] / 129`. + /// + /// The fund tree is created here and not by the prefunded balances helper because that + /// helper is unversioned and genesis builds the lower layers in one batch; this dispatcher + /// has its own version slot, so the shape of genesis at protocol version 14 is unchanged. + /// The upgrade path (`Platform::transition_to_version_17_compilation_readiness`) creates + /// the same elements with insert-if-not-exists through `add_readiness_structure_operations`. + pub(super) fn add_initial_vote_tree_main_structure_operations_v1( + batch: &mut GroveDbOpBatch, + ) -> Result<(), Error> { + Self::add_initial_vote_tree_main_structure_operations_v0(batch)?; + Self::add_readiness_structure_operations(batch); + Ok(()) + } + + /// The operations that create the compilation readiness structures. Shared by genesis + /// (generation 1 of the vote setup) and the upgrade path so both produce byte-identical + /// elements; the caller decides whether the inserts are plain or if-not-exists. + pub fn add_readiness_structure_operations(batch: &mut GroveDbOpBatch) { + batch.add_insert_empty_tree(vote_root_path_vec(), vec![READINESS_TREE_KEY as u8]); + batch.add_insert_empty_tree( + readiness_tree_path_vec(), + vec![READINESS_CONTRACTS_TREE_KEY], + ); + batch.add_insert_empty_tree( + readiness_tree_path_vec(), + vec![READINESS_DEADLINES_TREE_KEY], + ); + batch.add_insert_empty_tree( + readiness_tree_path_vec(), + vec![READINESS_RETIRED_ROUNDS_TREE_KEY], + ); + batch.add_insert_empty_sum_tree( + prefunded_specialized_balances_path() + .iter() + .map(|segment| segment.to_vec()) + .collect(), + vec![PREFUNDED_BALANCES_FOR_READINESS], + ); + } +} diff --git a/packages/rs-drive/src/drive/votes/structure.rs b/packages/rs-drive/src/drive/votes/structure.rs index 9163214b1bb..9d673750a9d 100644 --- a/packages/rs-drive/src/drive/votes/structure.rs +++ b/packages/rs-drive/src/drive/votes/structure.rs @@ -1,9 +1,12 @@ use crate::drive::votes::paths::{ ACTIVE_POLLS_TREE_KEY, CONTESTED_DOCUMENT_INDEXES_TREE_KEY, CONTESTED_DOCUMENT_STORAGE_TREE_KEY, CONTESTED_RESOURCE_TREE_KEY, END_DATE_QUERIES_TREE_KEY, - IDENTITY_VOTES_TREE_KEY, RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32, - RESOURCE_LOCK_VOTE_TREE_KEY_U8_32, RESOURCE_STORED_INFO_KEY_U8_32, VOTE_DECISIONS_TREE_KEY, - VOTING_STORAGE_TREE_KEY, + IDENTITY_VOTES_TREE_KEY, READINESS_CONTRACTS_TREE_KEY, READINESS_CURRENT_ROUND_POINTER_KEY, + READINESS_DEADLINES_TREE_KEY, READINESS_EVALUATION_CURSOR_KEY, + READINESS_RETIRED_ROUNDS_TREE_KEY, READINESS_ROUND_RECORD_KEY, + READINESS_ROUND_REPORTS_TREE_KEY, READINESS_ROUND_SCAN_CURSOR_KEY, READINESS_TREE_KEY, + RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32, RESOURCE_LOCK_VOTE_TREE_KEY_U8_32, + RESOURCE_STORED_INFO_KEY_U8_32, VOTE_DECISIONS_TREE_KEY, VOTING_STORAGE_TREE_KEY, }; use crate::drive::RootTree; use crate::structure::{ElementKind, FlagsKind, KeyEncoding, KeyMatcher, StructureNode}; @@ -131,6 +134,200 @@ pub(crate) fn structure() -> StructureNode { .describe("One poll ending at this time."), ), ), + readiness(), + ]) +} + +/// Compilation readiness: the rounds evonodes report on before a contract's executable +/// bundle activates +fn readiness() -> StructureNode { + StructureNode::fixed( + "readiness", + &[READINESS_TREE_KEY as u8], + "Readiness", + "READINESS_TREE_KEY", + ) + .ascii() + .kind(ElementKind::Tree) + .since(17) + .source(SOURCE) + .book("drive/compilation-readiness.md") + .describe( + "Readiness rounds: a contract's executable bundle activates once \ + enough evonodes report that they have prepared it. The keys are \ + provisional.", + ) + .children(vec![ + StructureNode::fixed( + "contracts", + &[READINESS_CONTRACTS_TREE_KEY], + "Contracts", + "READINESS_CONTRACTS_TREE_KEY", + ) + .kind(ElementKind::Tree) + .describe("The contracts holding a readiness round.") + .child( + StructureNode::identifier("contract", "contract_id", "The data contract id") + .kind(ElementKind::Tree) + .describe( + "Created with the contract's first round. Stays while a \ + retired round still waits for its cleanup.", + ) + .children(vec![ + StructureNode::fixed( + "current_round", + &[READINESS_CURRENT_ROUND_POINTER_KEY as u8], + "CurrentRound", + "READINESS_CURRENT_ROUND_POINTER_KEY", + ) + .ascii() + .kind(ElementKind::Item) + .until_deleted() + .value("round_id (32 bytes)") + .describe( + "The contract's current round. Replacing a round swaps \ + the pointer; cancelling or activating it deletes it.", + ), + readiness_round(), + ]), + ), + StructureNode::fixed( + "deadlines", + &[READINESS_DEADLINES_TREE_KEY], + "Deadlines", + "READINESS_DEADLINES_TREE_KEY", + ) + .kind(ElementKind::Tree) + .describe( + "Crossed rounds by the time they activate, so each block can \ + activate the ones that are due.", + ) + .child( + StructureNode::dynamic( + "deadline", + "deadline_ms", + KeyMatcher::Len(8), + KeyEncoding::U64Be, + "The activation deadline in milliseconds, u64 big endian \ + with the sign bit flipped", + ) + .kind(ElementKind::Tree) + .describe( + "The rounds activating at this time. Removed with its last \ + entry.", + ) + .child( + StructureNode::identifier("entry", "contract_id", "The data contract id") + .kind(ElementKind::Item) + .value("round_id (32 bytes)") + .describe( + "The round the contract activates at this time. Stale, \ + and dropped without activation, once it is no longer the \ + contract's current round.", + ), + ), + ), + StructureNode::fixed( + "evaluation_cursor", + &[READINESS_EVALUATION_CURSOR_KEY], + "EvaluationCursor", + "READINESS_EVALUATION_CURSOR_KEY", + ) + .kind(ElementKind::Item) + .lazy() + .value("contract_id (32 bytes)") + .describe( + "The last contract the block event evaluated, so the next block \ + continues after it.", + ), + StructureNode::fixed( + "retired_rounds", + &[READINESS_RETIRED_ROUNDS_TREE_KEY], + "RetiredRounds", + "READINESS_RETIRED_ROUNDS_TREE_KEY", + ) + .kind(ElementKind::Tree) + .describe( + "Rounds replaced, cancelled or activated, waiting for the bounded \ + cleanup that deletes their trees.", + ) + .child( + StructureNode::dynamic( + "retired_round", + "round_id", + KeyMatcher::Len(32), + KeyEncoding::Hash32, + "The round id, a double sha256 of the network, the contract, \ + the version, the bundle digest and the height it was accepted at", + ) + .kind(ElementKind::Item) + .value("contract_id (32 bytes)") + .describe("One retired round and the contract its tree is under."), + ), + ]) +} + +fn readiness_round() -> StructureNode { + StructureNode::dynamic( + "round", + "round_id", + KeyMatcher::Len(32), + KeyEncoding::Hash32, + "The round id, a double sha256 of the network, the contract, the \ + version, the bundle digest and the height it was accepted at", + ) + .kind(ElementKind::Tree) + .describe( + "One round, normally only the current one. A retired round stays \ + here, unreachable through the pointer, until its cleanup.", + ) + .children(vec![ + StructureNode::fixed( + "record", + &[READINESS_ROUND_RECORD_KEY], + "Record", + "READINESS_ROUND_RECORD_KEY", + ) + .kind(ElementKind::Item) + .value("serialized ReadinessRound") + .describe( + "The bundle, the status, the last evaluation mark and the fund \ + of the round.", + ), + StructureNode::fixed( + "reports", + &[READINESS_ROUND_REPORTS_TREE_KEY], + "Reports", + "READINESS_ROUND_REPORTS_TREE_KEY", + ) + .kind(ElementKind::CountTree) + .describe( + "The accepted reports; the count is the raw number of distinct \ + reporters.", + ) + .child( + StructureNode::identifier( + "report", + "pro_tx_hash", + "The reporting evonode's pro tx hash", + ) + .kind(ElementKind::Item) + .value("serialized ReadinessReportRecord") + .describe("One evonode's report that it prepared the bundle."), + ), + StructureNode::fixed( + "scan_cursor", + &[READINESS_ROUND_SCAN_CURSOR_KEY], + "ScanCursor", + "READINESS_ROUND_SCAN_CURSOR_KEY", + ) + .kind(ElementKind::Item) + .lazy() + .value("serialized ReadinessScanCursor") + .describe( + "Where a walk over the reports that did not finish in one block \ + continues. Absent when no walk is open.", + ), ]) } diff --git a/packages/rs-drive/src/error/drive.rs b/packages/rs-drive/src/error/drive.rs index 3412c9df1aa..fed8473b755 100644 --- a/packages/rs-drive/src/error/drive.rs +++ b/packages/rs-drive/src/error/drive.rs @@ -218,4 +218,8 @@ pub enum DriveError { /// Checkpoint not found for specified block height #[error("checkpoint not found for block height: {0}")] CheckpointNotFound(u64), + + /// A compilation readiness round the state names does not exist + #[error("readiness round missing: {0}")] + ReadinessRoundMissing(String), } diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 5102d8c3e41..82ae1a3a8bd 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -119,7 +119,13 @@ fn should_record_a_contract_layer_with_its_documents_on_top() { // fixture. Documents are read most and sit at the root of the layer; the // contract itself and everything else hang below. let contract = &json["layer_shapes"]["contracts.contract"]; - assert_eq!(contract["origin"], "fixture contracts_with_documents@14"); + assert_eq!( + contract["origin"], + format!( + "fixture contracts_with_documents@{}", + PlatformVersion::latest().protocol_version + ) + ); assert_eq!(contract["tree"]["hex"], "01"); assert_eq!(contract["tree"]["left"]["hex"], "00"); assert_eq!(contract["tree"]["right"]["hex"], "02"); @@ -291,7 +297,9 @@ mod fixtures { DataContractOwnedResolvedInfo, DocumentAndContractInfo, OwnedDocumentInfo, }; use crate::util::storage_flags::StorageFlags; + use crate::drive::votes::readiness::ReadinessRoundFunding; use crate::util::test_helpers::setup::setup_document; + use crate::util::test_helpers::test_utils::identities::create_test_identity_with_rng; use crate::util::test_helpers::setup_contract; use dpp::address_funds::PlatformAddress; use dpp::block::block_info::BlockInfo; @@ -355,6 +363,10 @@ mod fixtures { use dpp::tokens::status::TokenStatus; use dpp::tokens::token_event::TokenEvent; use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; + use dpp::voting::readiness::payer::ReadinessPayer; + use dpp::voting::readiness::report_record::ReadinessReportRecord; + use dpp::voting::readiness::round::{ReadinessRound, ReadinessRoundOpening}; + use dpp::voting::readiness::scan_cursor::ReadinessScanCursor; use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; use rand::rngs::StdRng; use rand::SeedableRng; @@ -1649,6 +1661,148 @@ mod fixtures { conformance_of(&drive, "expiring_documents", run); } + /// Compilation readiness rounds: a replaced round waiting for its cleanup beside the + /// current one with a paged walk open, two rounds crossed with the same deadline, a + /// cancelled round whose contract lost its pointer, the fairness cursor, and one fund per + /// round. + fn readiness_rounds(run: &mut FixtureRun) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let mut rng = StdRng::seed_from_u64(17); + let payer = + create_test_identity_with_rng(&drive, [0x50; 32], &mut rng, None, platform_version) + .expect("expected the payer identity") + .id(); + drive + .add_to_identity_balance( + payer.to_buffer(), + 1_000_000_000, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to fund the payer"); + let funding = ReadinessRoundFunding { + initial_funding: 20_000_000, + cleanup_reserve: 1_000_000, + }; + let block = |height: u64| BlockInfo { + time_ms: 1_000_000, + height, + core_height: 100, + epoch: Epoch::new(0).expect("expected epoch 0"), + }; + let open = |contract_id: [u8; 32], height: u64| { + let (round, operations) = drive + .open_readiness_round_operations( + ReadinessRoundOpening { + contract_id: Identifier::from(contract_id), + version: 1, + bundle_digest: [0xD1; 32], + preparation_profile: 1, + accepted_at_ms: 1_000_000, + accepted_at_height: height, + payer: ReadinessPayer::Identity(payer), + }, + funding, + &block(height), + &mut None, + None, + platform_version, + ) + .expect("expected to open a round"); + apply_operations(&drive, operations); + round + }; + let report = |round: &ReadinessRound, pro_tx_hash: [u8; 32]| { + let record = + ReadinessReportRecord::new(round.accepted_at_height() + 1, 1, platform_version) + .expect("expected a report record"); + let (inserted, operations) = drive + .insert_readiness_report_operations( + round.contract_id().to_buffer(), + round.round_id(), + pro_tx_hash, + &record, + &mut None, + None, + platform_version, + ) + .expect("expected to insert a report"); + assert!(inserted); + apply_operations(&drive, operations); + }; + + // A round replaced by a later bundle, both holding reports; the walk over the + // current round's reports is paged across blocks + let replaced = open([0x51; 32], 1); + report(&replaced, [0x61; 32]); + report(&replaced, [0x62; 32]); + let current = open([0x51; 32], 2); + report(¤t, [0x61; 32]); + report(¤t, [0x62; 32]); + let cursor = + ReadinessScanCursor::new(100, 2, platform_version).expect("expected a scan cursor"); + apply_operations( + &drive, + drive + .store_readiness_scan_cursor_operations( + [0x51; 32], + current.round_id(), + &cursor, + &mut None, + None, + platform_version, + ) + .expect("expected to store the scan cursor"), + ); + + // Two rounds crossing at the same time, so they share a deadline + for (contract_id, height) in [([0x52; 32], 3), ([0x53; 32], 4)] { + let mut round = open(contract_id, height); + let (_, operations) = drive + .record_readiness_crossing_operations( + &mut round, + 2_000_000, + 120_000, + 3_600_000, + &mut None, + None, + platform_version, + ) + .expect("expected to record the crossing"); + apply_operations(&drive, operations); + } + + // A cancelled round: the contract keeps its tree, without a pointer, until cleanup + open([0x54; 32], 5); + let (_, operations) = drive + .cancel_readiness_round_operations( + [0x54; 32], + 1_000_000, + &block(6), + &mut None, + None, + platform_version, + ) + .expect("expected to cancel the round"); + apply_operations(&drive, operations); + + apply_operations( + &drive, + drive + .store_readiness_evaluation_cursor_operations( + Some([0x52; 32]), + &mut None, + None, + platform_version, + ) + .expect("expected to store the evaluation cursor"), + ); + conformance_of(&drive, "readiness_rounds", run); + } + /// Runs every fixture pub(super) fn run_all() -> FixtureRun { let mut run = FixtureRun::default(); @@ -1666,6 +1820,7 @@ mod fixtures { token_distributions(&mut run); contract_groups_and_bound_keys(&mut run); spent_nullifiers(&mut run); + readiness_rounds(&mut run); run } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v0/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v0/mod.rs index b7970c9c8a9..fd0837445cb 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v0/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v0/mod.rs @@ -8,6 +8,8 @@ use dpp::fee::fee_result::FeeResult; use grovedb::{EstimatedLayerInformation, TransactionArg}; +use crate::util::batch::drive_op_batch::readiness::refuse_conflicting_readiness_writes; + use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; @@ -47,6 +49,11 @@ impl Drive { if operations.is_empty() { return Ok(FeeResult::default()); } + // In-place edit of a shipped generation (protocol versions 1 to 13): the guard refuses only + // batches holding a readiness operation or readiness fund write, which nothing builds + // below protocol version 17 and whose own methods are inactive there, so every batch + // these versions replay passes unchanged. + refuse_conflicting_readiness_writes(&operations)?; // With no caller transaction, TTL preparation (direct drainage // writes), conversion reads, and the batch apply would each commit // on their own, so a conversion error after preparation would leave diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs index 6dcf59e1ba5..35cf4555d5e 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/apply_drive_operations/v1/mod.rs @@ -16,6 +16,7 @@ use grovedb_costs::storage_cost::removal::StorageRemovedBytes; use crate::util::batch::drive_op_batch::finalize_task::{ DriveOperationFinalizationTasks, DriveOperationFinalizeTask, }; +use crate::util::batch::drive_op_batch::readiness::refuse_conflicting_readiness_writes; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use std::collections::HashMap; @@ -77,6 +78,10 @@ impl Drive { platform_version: &PlatformVersion, previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, ) -> Result { + // Selected from protocol version 14, which is unreleased; the guard refuses only + // batches holding a readiness operation or readiness fund write, which nothing builds + // below protocol version 17 and whose own methods are inactive there. + refuse_conflicting_readiness_writes(&operations)?; DriveOperation::refuse_repeated_token_balance_writes(&operations)?; let operations = DriveOperation::merge_balance_writes(operations)?; if operations.is_empty() { diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v0/mod.rs b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v0/mod.rs index 9894c9d666f..cda421e62bf 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v0/mod.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/drive_methods/convert_drive_operations_to_grove_operations/v0/mod.rs @@ -2,6 +2,7 @@ use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; +use crate::util::batch::drive_op_batch::readiness::refuse_conflicting_readiness_writes; use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; use crate::util::batch::{DriveOperation, GroveDbOpBatch}; use dpp::block::block_info::BlockInfo; @@ -44,6 +45,11 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { + // In-place edit of a shipped generation (every protocol version): the guard refuses only + // batches holding a readiness operation or readiness fund write, which nothing builds + // below protocol version 17 and whose own methods are inactive there, so every batch + // these versions replay passes unchanged. + refuse_conflicting_readiness_writes(&drive_batch_operations)?; self.prepare_drive_operations_time_range_ttl( &drive_batch_operations, block_info, 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 9e60212adb8..a543b5f2612 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 @@ -9,6 +9,7 @@ pub(crate) mod finalize_task; mod group; mod identity; mod prefunded_specialized_balance; +mod readiness; mod shielded; mod system; mod token; @@ -34,6 +35,7 @@ pub use document::UpdateOperationInfo; pub use group::GroupOperationType; pub use identity::IdentityOperationType; pub use prefunded_specialized_balance::PrefundedSpecializedBalanceOperationType; +pub use readiness::ReadinessOperationType; pub use shielded::ShieldedPoolOperationType; pub use system::SystemOperationType; pub use token::TokenOperationType; @@ -110,6 +112,8 @@ pub enum DriveOperation<'a> { AddressFundsOperation(AddressFundsOperationType), /// A shielded pool operation ShieldedPoolOperation(ShieldedPoolOperationType), + /// A compilation readiness operation + ReadinessOperation(ReadinessOperationType), /// A single low level groveDB operation GroveDBOperation(QualifiedGroveDbOp), /// Multiple low level groveDB operations @@ -190,6 +194,15 @@ impl DriveLowLevelOperationConverter for DriveOperation<'_> { platform_version, ) } + DriveOperation::ReadinessOperation(readiness_operation_type) => { + readiness_operation_type.into_low_level_drive_operations( + drive, + estimated_costs_only_with_layer_info, + block_info, + transaction, + platform_version, + ) + } DriveOperation::GroveDBOperation(op) => Ok(vec![GroveOperation(op)]), DriveOperation::GroveDBOpBatch(operations) => Ok(operations .operations @@ -380,6 +393,9 @@ impl DriveOperation<'_> { /// because no batch repeats their keys: the total system credits /// ([`SystemOperationType`]) and address balances ([`AddressFundsOperationType`]). Their /// docs say what keeps it so; a change that could repeat one must extend this merge first. + /// Readiness fund writes and the settlements of readiness round openings, cancellations + /// and activations are not merged either: a batch that would repeat their keys is refused beforehand + /// (`refuse_conflicting_readiness_writes`). pub fn merge_balance_writes(operations: Vec) -> Result, Error> { // Most batches make at most one such write: nothing to merge, and no map to build. if operations diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/prefunded_specialized_balance.rs b/packages/rs-drive/src/util/batch/drive_op_batch/prefunded_specialized_balance.rs index 35562585e7c..46d3aa23cfa 100644 --- a/packages/rs-drive/src/util/batch/drive_op_batch/prefunded_specialized_balance.rs +++ b/packages/rs-drive/src/util/batch/drive_op_batch/prefunded_specialized_balance.rs @@ -26,6 +26,22 @@ pub enum PrefundedSpecializedBalanceOperationType { /// The removed balance remove_balance: u64, }, + /// Creates a compilation readiness fund or adds to it + CreateNewReadinessFund { + /// The fund id, derived from the round id + fund_id: Identifier, + /// The added balance + add_balance: u64, + }, + /// Deducts from a compilation readiness fund, keeping the cleanup reserve untouchable + DeductFromReadinessFund { + /// The fund id, derived from the round id + fund_id: Identifier, + /// The removed balance + remove_balance: u64, + /// The balance that must remain after the deduction + reserve: u64, + }, } impl DriveLowLevelOperationConverter for PrefundedSpecializedBalanceOperationType { @@ -60,6 +76,28 @@ impl DriveLowLevelOperationConverter for PrefundedSpecializedBalanceOperationTyp transaction, platform_version, ), + PrefundedSpecializedBalanceOperationType::CreateNewReadinessFund { + fund_id, + add_balance, + } => drive.add_readiness_fund_operations( + fund_id, + add_balance, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + PrefundedSpecializedBalanceOperationType::DeductFromReadinessFund { + fund_id, + remove_balance, + reserve, + } => drive.deduct_from_readiness_fund_operations( + fund_id, + remove_balance, + reserve, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), } } } diff --git a/packages/rs-drive/src/util/batch/drive_op_batch/readiness.rs b/packages/rs-drive/src/util/batch/drive_op_batch/readiness.rs new file mode 100644 index 00000000000..0c570c81dc8 --- /dev/null +++ b/packages/rs-drive/src/util/batch/drive_op_batch/readiness.rs @@ -0,0 +1,243 @@ +use crate::drive::votes::readiness::ReadinessRoundFunding; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::batch::drive_op_batch::DriveLowLevelOperationConverter; +use crate::util::batch::drive_op_batch::{ + IdentityOperationType, PrefundedSpecializedBalanceOperationType, +}; +use crate::util::batch::DriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::voting::readiness::report_record::ReadinessReportRecord; +use dpp::voting::readiness::round::ReadinessRoundOpening; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use platform_version::version::PlatformVersion; +use std::collections::{BTreeSet, HashMap}; + +/// Operations on compilation readiness rounds +#[derive(Clone, Debug)] +pub enum ReadinessOperationType { + /// Opens a contract's round, replacing any current one + OpenRound { + /// What identifies the round + opening: ReadinessRoundOpening, + /// The credits the opening moves + funding: ReadinessRoundFunding, + }, + /// Cancels a contract's current round + CancelRound { + /// The contract + contract_id: [u8; 32], + /// The credits charged to the pool for the deferred cleanup + cleanup_reserve: Credits, + }, + /// Activates a contract's crossed round at its deadline + ActivateRound { + /// The contract + contract_id: [u8; 32], + /// The round the deadline entry names + round_id: [u8; 32], + /// The credits charged to the pool for the deferred cleanup + cleanup_reserve: Credits, + }, + /// Inserts one accepted report into the current round + InsertReport { + /// The contract + contract_id: [u8; 32], + /// The round; the caller has checked it is the contract's current round + round_id: [u8; 32], + /// The reporting evonode + pro_tx_hash: [u8; 32], + /// What to store for the report + record: ReadinessReportRecord, + }, + /// Deletes named reports from a round + PruneReports { + /// The contract + contract_id: [u8; 32], + /// The round + round_id: [u8; 32], + /// The reports to delete + pro_tx_hashes: Vec<[u8; 32]>, + }, +} + +impl ReadinessOperationType { + /// Whether the operation can retire a round: an opening retires the contract's previous + /// round when there is one, a cancellation and an activation always do. + fn can_retire_a_round(&self) -> bool { + matches!( + self, + ReadinessOperationType::OpenRound { .. } + | ReadinessOperationType::CancelRound { .. } + | ReadinessOperationType::ActivateRound { .. } + ) + } +} + +/// Refuses a batch whose readiness writes would overwrite each other. +/// +/// Readiness writes compute their new value from the one committed before the batch, and +/// GroveDB keeps only the last write of a key, so a second write of the same key in one batch +/// would replace the first instead of adding to it. Callers schedule one settlement per batch; +/// these checks keep a batch that breaks the rule from losing or minting credits: +/// +/// * at most one operation that can retire a round: each credits its cleanup reserve to the +/// epoch's processing pool as an absolute rewrite of the pool item; +/// * a round opening, cancellation or activation shares its batch with no identity balance +/// write and no readiness fund write: it settles the payers and funds of both rounds with absolute +/// writes, and the payer it refunds is only known once the round is read; +/// * each readiness fund is written at most once, since a deduction's reserve check reads the +/// committed balance too; +/// * a round settlement or readiness fund write shares its batch with no raw GroveDB +/// operation: the guard cannot tell which balances a raw write touches. +pub(crate) fn refuse_conflicting_readiness_writes( + operations: &[DriveOperation], +) -> Result<(), Error> { + let retirements = operations + .iter() + .filter(|operation| { + matches!( + operation, + DriveOperation::ReadinessOperation(readiness_operation) + if readiness_operation.can_retire_a_round() + ) + }) + .count(); + if retirements > 1 { + return Err(Error::Drive(DriveError::NotSupported( + "one readiness round retirement per batch", + ))); + } + let writes_readiness_balances = retirements > 0 + || operations.iter().any(|operation| { + matches!( + operation, + DriveOperation::PrefundedSpecializedBalanceOperation( + PrefundedSpecializedBalanceOperationType::CreateNewReadinessFund { .. } + | PrefundedSpecializedBalanceOperationType::DeductFromReadinessFund { .. }, + ) + ) + }); + let mut funds_written = BTreeSet::new(); + for operation in operations { + match operation { + DriveOperation::PrefundedSpecializedBalanceOperation( + PrefundedSpecializedBalanceOperationType::CreateNewReadinessFund { + fund_id, .. + } + | PrefundedSpecializedBalanceOperationType::DeductFromReadinessFund { + fund_id, .. + }, + ) => { + if retirements > 0 || !funds_written.insert(*fund_id) { + return Err(Error::Drive(DriveError::NotSupported( + "a readiness fund is written once per batch, never beside a round settlement", + ))); + } + } + DriveOperation::IdentityOperation( + IdentityOperationType::AddToIdentityBalance { .. } + | IdentityOperationType::RemoveFromIdentityBalance { .. }, + ) if retirements > 0 => { + return Err(Error::Drive(DriveError::NotSupported( + "a readiness round settlement is the only identity balance write of its batch", + ))); + } + DriveOperation::GroveDBOperation(_) | DriveOperation::GroveDBOpBatch(_) + if writes_readiness_balances => + { + return Err(Error::Drive(DriveError::NotSupported( + "a readiness round settlement or fund write shares its batch with no raw GroveDB operation", + ))); + } + _ => {} + } + } + Ok(()) +} + +impl DriveLowLevelOperationConverter for ReadinessOperationType { + 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 { + ReadinessOperationType::OpenRound { opening, funding } => drive + .open_readiness_round_operations( + opening, + funding, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ) + .map(|(_, operations)| operations), + ReadinessOperationType::CancelRound { + contract_id, + cleanup_reserve, + } => drive + .cancel_readiness_round_operations( + contract_id, + cleanup_reserve, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ) + .map(|(_, operations)| operations), + ReadinessOperationType::ActivateRound { + contract_id, + round_id, + cleanup_reserve, + } => drive + .activate_readiness_round_operations( + contract_id, + round_id, + cleanup_reserve, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ) + .map(|(_, operations)| operations), + ReadinessOperationType::InsertReport { + contract_id, + round_id, + pro_tx_hash, + record, + } => drive + .insert_readiness_report_operations( + contract_id, + round_id, + pro_tx_hash, + &record, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ) + .map(|(_, operations)| operations), + ReadinessOperationType::PruneReports { + contract_id, + round_id, + pro_tx_hashes, + } => drive.prune_readiness_reports_operations( + contract_id, + round_id, + &pro_tx_hashes, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), + } + } +} diff --git a/packages/rs-drive/src/verify/voting/mod.rs b/packages/rs-drive/src/verify/voting/mod.rs index 28169ef1537..6589663d852 100644 --- a/packages/rs-drive/src/verify/voting/mod.rs +++ b/packages/rs-drive/src/verify/voting/mod.rs @@ -3,6 +3,10 @@ mod verify_contests_proof; mod verify_identity_votes_given_proof; mod verify_masternode_vote; +mod verify_readiness_fund; +mod verify_readiness_report; +mod verify_readiness_round; +pub use verify_readiness_round::VerifiedReadinessRound; mod verify_specialized_balance; mod verify_vote_poll_vote_state_proof; mod verify_vote_poll_votes_proof; diff --git a/packages/rs-drive/src/verify/voting/verify_readiness_fund/mod.rs b/packages/rs-drive/src/verify/voting/verify_readiness_fund/mod.rs new file mode 100644 index 00000000000..61534d90021 --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_readiness_fund/mod.rs @@ -0,0 +1,54 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; + +impl Drive { + /// Verifies the balance of a compilation readiness fund. + /// + /// # Parameters + /// + /// - `proof`: A byte slice representing the proof. + /// - `fund_id`: The fund id a round derives from its round id. + /// - `verify_subset_of_proof`: Whether we are verifying a subset of a larger proof. + /// - `platform_version`: The platform version against which to verify. + /// + /// # Returns + /// + /// The root hash and the balance if the fund exists. + /// + /// # Errors + /// + /// Returns an `Error` if the proof is not valid, the proved key value is not for the + /// correct path or key, more than one element is found, or the platform version is + /// unknown. + pub fn verify_readiness_fund( + proof: &[u8], + fund_id: [u8; 32], + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, Option), Error> { + match platform_version + .drive + .methods + .verify + .voting + .verify_readiness_fund + { + 0 => Self::verify_readiness_fund_v0( + proof, + fund_id, + verify_subset_of_proof, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "verify_readiness_fund".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_readiness_fund/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_readiness_fund/v0/mod.rs new file mode 100644 index 00000000000..a805ad3e6cd --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_readiness_fund/v0/mod.rs @@ -0,0 +1,61 @@ +use crate::drive::prefunded_specialized_balances::prefunded_specialized_balances_for_readiness_path_vec; +use crate::drive::votes::readiness::queries::readiness_fund_path_query; +use crate::drive::Drive; +use crate::error::proof::ProofError; +use crate::error::Error; +use crate::verify::RootHash; +use grovedb::GroveDb; +use platform_version::version::PlatformVersion; + +impl Drive { + pub(super) fn verify_readiness_fund_v0( + proof: &[u8], + fund_id: [u8; 32], + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, Option), Error> { + let path_query = readiness_fund_path_query(fund_id); + let (root_hash, mut proved_key_values) = if verify_subset_of_proof { + GroveDb::verify_subset_query_with_absence_proof( + proof, + &path_query, + &platform_version.drive.grove_version, + )? + } else { + GroveDb::verify_query_with_absence_proof( + proof, + &path_query, + &platform_version.drive.grove_version, + )? + }; + if proved_key_values.len() != 1 { + return Err(Error::Proof(ProofError::TooManyElements( + "expected one readiness fund", + ))); + } + let (path, key, maybe_element) = proved_key_values.remove(0); + if path != prefunded_specialized_balances_for_readiness_path_vec() { + return Err(Error::Proof(ProofError::CorruptedProof( + "we did not get back an element for the correct path in readiness funds" + .to_string(), + ))); + } + if key != fund_id { + return Err(Error::Proof(ProofError::CorruptedProof( + "we did not get back an element for the correct key in readiness funds".to_string(), + ))); + } + let balance = maybe_element + .map(|element| { + element + .as_sum_item_value() + .map_err(Error::from)? + .try_into() + .map_err(|_| { + Error::Proof(ProofError::IncorrectValueSize("value size is incorrect")) + }) + }) + .transpose()?; + Ok((root_hash, balance)) + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_readiness_report/mod.rs b/packages/rs-drive/src/verify/voting/verify_readiness_report/mod.rs new file mode 100644 index 00000000000..ee7cacee503 --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_readiness_report/mod.rs @@ -0,0 +1,61 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::report_record::ReadinessReportRecord; + +impl Drive { + /// Verifies one accepted compilation readiness report of a round. + /// + /// # Parameters + /// + /// - `proof`: A byte slice representing the proof. + /// - `contract_id`: The contract. + /// - `round_id`: The round. + /// - `pro_tx_hash`: The reporting evonode. + /// - `verify_subset_of_proof`: Whether we are verifying a subset of a larger proof. + /// - `platform_version`: The platform version against which to verify. + /// + /// # Returns + /// + /// The root hash and the report record if the report exists. + /// + /// # Errors + /// + /// Returns an `Error` if the proof is not valid, the proved key value is not for the + /// correct path or key, more than one element is found, or the platform version is + /// unknown. + pub fn verify_readiness_report( + proof: &[u8], + contract_id: [u8; 32], + round_id: [u8; 32], + pro_tx_hash: [u8; 32], + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, Option), Error> { + match platform_version + .drive + .methods + .verify + .voting + .verify_readiness_report + { + 0 => Self::verify_readiness_report_v0( + proof, + contract_id, + round_id, + pro_tx_hash, + verify_subset_of_proof, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "verify_readiness_report".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_readiness_report/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_readiness_report/v0/mod.rs new file mode 100644 index 00000000000..e94e4eab3dd --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_readiness_report/v0/mod.rs @@ -0,0 +1,66 @@ +use crate::drive::votes::paths::readiness_round_reports_tree_path_vec; +use crate::drive::votes::readiness::queries::readiness_report_path_query; +use crate::drive::Drive; +use crate::error::proof::ProofError; +use crate::error::Error; +use crate::verify::RootHash; +use dpp::serialization::PlatformDeserializableUntrusted; +use dpp::voting::readiness::report_record::ReadinessReportRecord; +use grovedb::{Element, GroveDb}; +use platform_version::version::PlatformVersion; + +impl Drive { + pub(super) fn verify_readiness_report_v0( + proof: &[u8], + contract_id: [u8; 32], + round_id: [u8; 32], + pro_tx_hash: [u8; 32], + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, Option), Error> { + let path_query = readiness_report_path_query(contract_id, round_id, pro_tx_hash); + let (root_hash, mut proved_key_values) = if verify_subset_of_proof { + GroveDb::verify_subset_query_with_absence_proof( + proof, + &path_query, + &platform_version.drive.grove_version, + )? + } else { + GroveDb::verify_query_with_absence_proof( + proof, + &path_query, + &platform_version.drive.grove_version, + )? + }; + if proved_key_values.len() != 1 { + return Err(Error::Proof(ProofError::TooManyElements( + "expected one readiness report", + ))); + } + let (path, key, maybe_element) = proved_key_values.remove(0); + if path != readiness_round_reports_tree_path_vec(contract_id, round_id) { + return Err(Error::Proof(ProofError::CorruptedProof( + "we did not get back an element for the correct path in readiness reports" + .to_string(), + ))); + } + if key != pro_tx_hash { + return Err(Error::Proof(ProofError::CorruptedProof( + "we did not get back an element for the correct key in readiness reports" + .to_string(), + ))); + } + let record = match maybe_element { + None => None, + Some(Element::Item(bytes, _)) => Some( + ReadinessReportRecord::deserialize_from_bytes_untrusted(&bytes)?, + ), + Some(_) => { + return Err(Error::Proof(ProofError::CorruptedProof( + "readiness report was present but was not an item".to_string(), + ))) + } + }; + Ok((root_hash, record)) + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_readiness_round/mod.rs b/packages/rs-drive/src/verify/voting/verify_readiness_round/mod.rs new file mode 100644 index 00000000000..c11f08578a8 --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_readiness_round/mod.rs @@ -0,0 +1,69 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; +use dpp::voting::readiness::round::ReadinessRound; + +/// A contract's compilation readiness round as a proof shows it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedReadinessRound { + /// The round record. + pub round: ReadinessRound, + /// The raw distinct report count: the count of the reports count tree, not an + /// eligibility judgement. + pub raw_count: u64, +} + +impl Drive { + /// Verifies a contract's current compilation readiness round: the pointer, the record it + /// names and the raw distinct report count. + /// + /// The proof is the merge of the pointer query and the round query, which the prover + /// builds from the same helpers in `drive::votes::readiness::queries`. + /// + /// # Parameters + /// + /// - `proof`: A byte slice representing the proof. + /// - `contract_id`: The contract. + /// - `verify_subset_of_proof`: Whether we are verifying a subset of a larger proof. + /// - `platform_version`: The platform version against which to verify. + /// + /// # Returns + /// + /// The root hash and the round if the contract has one. + /// + /// # Errors + /// + /// Returns an `Error` if the proof is not valid, an element is not for the expected path + /// or key, the pointer names a round the proof does not carry, or the platform version is + /// unknown. + pub fn verify_readiness_round( + proof: &[u8], + contract_id: [u8; 32], + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, Option), Error> { + match platform_version + .drive + .methods + .verify + .voting + .verify_readiness_round + { + 0 => Self::verify_readiness_round_v0( + proof, + contract_id, + verify_subset_of_proof, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "verify_readiness_round".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_readiness_round/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_readiness_round/v0/mod.rs new file mode 100644 index 00000000000..197de04ecb0 --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_readiness_round/v0/mod.rs @@ -0,0 +1,111 @@ +use crate::drive::votes::paths::{ + readiness_contract_tree_path_vec, readiness_round_tree_path_vec, + READINESS_CURRENT_ROUND_POINTER_KEY, READINESS_ROUND_RECORD_KEY, + READINESS_ROUND_REPORTS_TREE_KEY, +}; +use crate::drive::votes::readiness::queries::{ + readiness_round_path_query, readiness_round_pointer_path_query, +}; +use crate::drive::Drive; +use crate::error::proof::ProofError; +use crate::error::Error; +use crate::verify::voting::verify_readiness_round::VerifiedReadinessRound; +use crate::verify::RootHash; +use dpp::serialization::PlatformDeserializableUntrusted; +use dpp::voting::readiness::round::ReadinessRound; +use grovedb::{Element, GroveDb}; +use platform_version::version::PlatformVersion; + +impl Drive { + pub(super) fn verify_readiness_round_v0( + proof: &[u8], + contract_id: [u8; 32], + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, Option), Error> { + let grove_version = &platform_version.drive.grove_version; + + // The pointer first; a proof of a round without a round is a proof of absence. The + // prover's query carries no limit (a limited query cannot land at a merged root), so + // the absence check sets the one-key limit on its own copy. Whether or not the + // caller verifies a subset, the pointer is verified as a subset: the full proof also + // carries the round elements when the round exists, and those are pinned below. + let mut pointer_query = readiness_round_pointer_path_query(contract_id); + pointer_query.query.limit = Some(1); + let _ = verify_subset_of_proof; + let (root_hash, mut pointer_values) = + GroveDb::verify_subset_query_with_absence_proof(proof, &pointer_query, grove_version)?; + if pointer_values.len() != 1 { + return Err(Error::Proof(ProofError::TooManyElements( + "expected one readiness round pointer", + ))); + } + let (path, key, maybe_pointer) = pointer_values.remove(0); + if path != readiness_contract_tree_path_vec(contract_id) + || key != [READINESS_CURRENT_ROUND_POINTER_KEY as u8] + { + return Err(Error::Proof(ProofError::CorruptedProof( + "we did not get back the readiness round pointer".to_string(), + ))); + } + let round_id: [u8; 32] = match maybe_pointer { + None => return Ok((root_hash, None)), + Some(Element::Item(bytes, _)) => bytes.try_into().map_err(|_| { + Error::Proof(ProofError::CorruptedProof( + "readiness round pointer is not 32 bytes".to_string(), + )) + })?, + Some(_) => { + return Err(Error::Proof(ProofError::CorruptedProof( + "readiness round pointer was present but was not an item".to_string(), + ))) + } + }; + + let round_query = readiness_round_path_query(contract_id, round_id); + let (round_root_hash, round_values) = + GroveDb::verify_subset_query(proof, &round_query, grove_version)?; + if round_root_hash != root_hash { + return Err(Error::Proof(ProofError::CorruptedProof( + "the readiness round proof does not share the pointer's root".to_string(), + ))); + } + let expected_path = readiness_round_tree_path_vec(contract_id, round_id); + let mut round = None; + let mut raw_count = None; + for (path, key, maybe_element) in round_values { + if path != expected_path { + return Err(Error::Proof(ProofError::CorruptedProof( + "we did not get back an element for the correct readiness round path" + .to_string(), + ))); + } + match (key.as_slice(), maybe_element) { + ([READINESS_ROUND_RECORD_KEY], Some(Element::Item(bytes, _))) => { + round = Some(ReadinessRound::deserialize_from_bytes_untrusted(&bytes)?); + } + ([READINESS_ROUND_REPORTS_TREE_KEY], Some(Element::CountTree(_, count, _))) => { + raw_count = Some(count); + } + _ => { + return Err(Error::Proof(ProofError::CorruptedProof( + "unexpected element in the readiness round proof".to_string(), + ))) + } + } + } + match (round, raw_count) { + (Some(round), Some(raw_count)) => { + if round.round_id() != round_id || round.contract_id().as_bytes() != &contract_id { + return Err(Error::Proof(ProofError::CorruptedProof( + "the readiness round record does not match the pointer".to_string(), + ))); + } + Ok((root_hash, Some(VerifiedReadinessRound { round, raw_count }))) + } + _ => Err(Error::Proof(ProofError::CorruptedProof( + "the readiness round proof lacks the record or the reports tree".to_string(), + ))), + } + } +} diff --git a/packages/rs-drive/tests/readiness_proofs.rs b/packages/rs-drive/tests/readiness_proofs.rs new file mode 100644 index 00000000000..d5a1135efb7 --- /dev/null +++ b/packages/rs-drive/tests/readiness_proofs.rs @@ -0,0 +1,32 @@ +//! Readiness proofs verified from outside the crate, through the public paths only. + +#![cfg(all(feature = "server", feature = "verify"))] + +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::util::test_helpers::setup::setup_drive_with_initial_state_structure; +use drive::verify::voting::VerifiedReadinessRound; + +#[test] +fn should_verify_an_absent_readiness_round_into_its_public_result_type() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let contract_id = [7u8; 32]; + + let proof = drive + .prove_readiness_round(contract_id, None, platform_version) + .expect("expected a proof"); + let (root_hash, round): (_, Option) = + Drive::verify_readiness_round(&proof, contract_id, false, platform_version) + .expect("expected the proof to verify"); + + assert_eq!( + root_hash, + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected a root hash") + ); + assert_eq!(round, None); +} diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs index 900fab5a43c..ee3a3d59431 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs @@ -2,10 +2,21 @@ use versioned_feature_core::FeatureVersion; pub mod v1; pub mod v2; +pub mod v3; #[derive(Clone, Debug, Default)] pub struct DPPVotingVersions { pub default_vote_poll_time_duration_mainnet_ms: u64, pub default_vote_poll_time_duration_test_network_ms: u64, pub contested_document_vote_poll_stored_info_version: FeatureVersion, + /// Structure version `ReadinessRound::new` builds: the compilation readiness round Drive + /// keeps per contract from protocol version 17. Shipped tables carry 0 because the record + /// did not exist before; nothing reads it there. + pub readiness_round_default_structure_version: FeatureVersion, + /// Structure version `ReadinessReportRecord::new` builds: one accepted readiness report + /// under a round's count tree. Shipped tables carry 0 for the same reason. + pub readiness_report_record_default_structure_version: FeatureVersion, + /// Structure version `ReadinessScanCursor::new` builds: the persisted position of a paged + /// eligibility walk bound to one membership view. Shipped tables carry 0 for the same reason. + pub readiness_scan_cursor_default_structure_version: FeatureVersion, } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs index 944a295d1fe..78b2f2027ea 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs @@ -4,4 +4,7 @@ pub const VOTING_VERSION_V1: DPPVotingVersions = DPPVotingVersions { default_vote_poll_time_duration_mainnet_ms: 1_209_600_000, //2 weeks default_vote_poll_time_duration_test_network_ms: 1_209_600_000, //2 weeks contested_document_vote_poll_stored_info_version: 0, + readiness_round_default_structure_version: 0, + readiness_report_record_default_structure_version: 0, + readiness_scan_cursor_default_structure_version: 0, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs index de072e87837..9ba1a722a0d 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs @@ -4,4 +4,7 @@ pub const VOTING_VERSION_V2: DPPVotingVersions = DPPVotingVersions { default_vote_poll_time_duration_mainnet_ms: 1_209_600_000, //2 weeks default_vote_poll_time_duration_test_network_ms: 5_400_000, //90 minutes contested_document_vote_poll_stored_info_version: 0, + readiness_round_default_structure_version: 0, + readiness_report_record_default_structure_version: 0, + readiness_scan_cursor_default_structure_version: 0, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v3.rs new file mode 100644 index 00000000000..d18db4101ea --- /dev/null +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v3.rs @@ -0,0 +1,12 @@ +use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; +use crate::version::dpp_versions::dpp_voting_versions::DPPVotingVersions; + +/// Introduced in protocol version 17 (5.0). Identical to [`VOTING_VERSION_V2`] except that the +/// three compilation readiness structures (the round, the accepted report record and the +/// paged scan cursor) have their default structure version declared. +pub const VOTING_VERSION_V3: DPPVotingVersions = DPPVotingVersions { + readiness_round_default_structure_version: 0, + readiness_report_record_default_structure_version: 0, + readiness_scan_cursor_default_structure_version: 0, + ..VOTING_VERSION_V2 +}; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs index bfa34951428..503fd053bc3 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs @@ -2,6 +2,7 @@ use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v10; +pub mod v11; pub mod v2; pub mod v3; pub mod v4; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs new file mode 100644 index 00000000000..ccda71e3d0c --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs @@ -0,0 +1,23 @@ +use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; +use crate::version::drive_abci_versions::drive_abci_method_versions::{ + DriveAbciMethodVersions, DriveAbciProtocolUpgradeMethodVersions, +}; + +/// Drive ABCI method versions 11. Introduced in protocol version 17 (5.0). +/// +/// One slot changes over `DRIVE_ABCI_METHOD_VERSIONS_V10`: +/// +/// * `protocol_upgrade.perform_events_on_first_block_of_protocol_change` becomes `Some(3)`: +/// the generation that, when the chain crosses protocol version 17, creates the compilation +/// readiness subtree under `[Votes]` and the readiness fund tree under +/// `[PreFundedSpecializedBalances]` on upgraded nodes through the same helper genesis uses, +/// on top of everything generation 2 does. +/// +/// Everything else matches v10. +pub const DRIVE_ABCI_METHOD_VERSIONS_V11: DriveAbciMethodVersions = DriveAbciMethodVersions { + protocol_upgrade: DriveAbciProtocolUpgradeMethodVersions { + perform_events_on_first_block_of_protocol_change: Some(3), // changed in v17: creates the readiness structures on upgrade + ..DRIVE_ABCI_METHOD_VERSIONS_V10.protocol_upgrade + }, + ..DRIVE_ABCI_METHOD_VERSIONS_V10 +}; 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 9a6f1ba4a77..65fd468eb55 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 @@ -182,6 +182,12 @@ pub struct DriveVerifyVoteMethodVersions { pub verify_contests_proof: FeatureVersion, pub verify_vote_polls_by_end_date_proof: FeatureVersion, pub verify_specialized_balance: FeatureVersion, + /// Verifies a contract's compilation readiness round (pointer, record, raw count). + pub verify_readiness_round: FeatureVersion, + /// Verifies one accepted readiness report of a round. + pub verify_readiness_report: FeatureVersion, + /// Verifies a readiness fund balance. + pub verify_readiness_fund: 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 312f4c22275..04bf7b0fc10 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 @@ -126,6 +126,9 @@ pub const DRIVE_VERIFY_METHOD_VERSIONS_V1: DriveVerifyMethodVersions = DriveVeri verify_contests_proof: 0, verify_vote_polls_by_end_date_proof: 0, verify_specialized_balance: 0, + verify_readiness_round: 0, + verify_readiness_report: 0, + verify_readiness_fund: 0, }, address_funds: DriveVerifyAddressFundsMethodVersions { verify_address_info: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs index 3b268472ea2..833482efea6 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs @@ -1,8 +1,9 @@ -use versioned_feature_core::FeatureVersion; +use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; pub mod v3; +pub mod v4; #[derive(Clone, Debug, Default)] pub struct DriveVoteMethodVersions { @@ -12,6 +13,70 @@ pub struct DriveVoteMethodVersions { pub setup: DriveVoteSetupMethodVersions, pub storage_form: DriveVoteStorageFormMethodVersions, pub fetch: DriveVoteFetchMethodVersions, + pub readiness: DriveVoteReadinessMethodVersions, +} + +/// Compilation readiness rounds, reports, cursors, deadlines and funds: the state Drive keeps +/// under `[Votes] / r` and `[PreFundedSpecializedBalances] / 129` from the protocol version +/// that introduces them. Every slot is optional because the subtrees only exist from that +/// version; a shipped table keeps `None` and the dispatchers report `VersionNotActive`. +#[derive(Clone, Debug, Default)] +pub struct DriveVoteReadinessMethodVersions { + /// Opens a contract's round, retiring the previous one and funding the new one. + pub open_round: OptionalFeatureVersion, + /// Cancels a contract's round, retiring it and refunding its fund. + pub cancel_round: OptionalFeatureVersion, + /// Retires a round already unlinked from a contract into the cleanup queue and settles + /// its fund; the primitive `open_round`, `cancel_round` and `activate_round` share. + pub retire_round: OptionalFeatureVersion, + /// Inserts one report into the current round's count tree if absent. + pub insert_report: OptionalFeatureVersion, + /// Reads a contract's current round record through the pointer. + pub fetch_round: OptionalFeatureVersion, + /// Reads the raw distinct report count of a round. + pub fetch_raw_count: OptionalFeatureVersion, + /// Reads a page of a round's reports in key order. + pub fetch_reports_page: OptionalFeatureVersion, + /// Reads a page of contracts holding a round, in contract id order. + pub fetch_rounds_page: OptionalFeatureVersion, + /// Writes the persisted position of a paged eligibility walk. + pub store_scan_cursor: OptionalFeatureVersion, + /// Reads the persisted position of a paged eligibility walk. + pub fetch_scan_cursor: OptionalFeatureVersion, + /// Removes the persisted position of a paged eligibility walk. + pub clear_scan_cursor: OptionalFeatureVersion, + /// Rewrites a round record with its last evaluation mark. + pub update_round_evaluation: OptionalFeatureVersion, + /// Marks a round crossed and queues its activation deadline. + pub record_crossing: OptionalFeatureVersion, + /// Reads the rounds whose activation deadline has passed. + pub fetch_rounds_due: OptionalFeatureVersion, + /// Activates a round at its deadline: unlinks it, retires it, settles the fund. + pub activate_round: OptionalFeatureVersion, + /// Deletes named reports from a round's count tree. + pub prune_reports: OptionalFeatureVersion, + /// Reads the first retired round awaiting cleanup. + pub fetch_retired_round: OptionalFeatureVersion, + /// Runs one bounded cleanup step over a retired round. + pub cleanup_retired_round: OptionalFeatureVersion, + /// Writes the block event's fairness cursor over contracts. + pub store_evaluation_cursor: OptionalFeatureVersion, + /// Reads the block event's fairness cursor over contracts. + pub fetch_evaluation_cursor: OptionalFeatureVersion, + /// Creates or tops up a readiness fund under `[PreFundedSpecializedBalances] / 129`. + pub add_fund: OptionalFeatureVersion, + /// Deducts from a readiness fund. + pub deduct_from_fund: OptionalFeatureVersion, + /// Empties a readiness fund, returning what it held. + pub empty_fund: OptionalFeatureVersion, + /// Reads a readiness fund. + pub fetch_fund: OptionalFeatureVersion, + /// Proves a readiness fund. + pub prove_fund: OptionalFeatureVersion, + /// Credits readiness charges to the block epoch's processing fee pool. + pub credit_pool: OptionalFeatureVersion, + /// Layer estimation for writes under the readiness subtrees. + pub estimation_costs: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs index 17c779d2b9d..dfa777e729c 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs @@ -1,7 +1,8 @@ use crate::version::drive_versions::drive_vote_method_versions::{ DriveVoteCleanupMethodVersions, DriveVoteContestedResourceInsertMethodVersions, DriveVoteFetchMethodVersions, DriveVoteInsertMethodVersions, DriveVoteMethodVersions, - DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, + DriveVoteReadinessMethodVersions, DriveVoteSetupMethodVersions, + DriveVoteStorageFormMethodVersions, }; pub const DRIVE_VOTE_METHOD_VERSIONS_V1: DriveVoteMethodVersions = DriveVoteMethodVersions { @@ -36,4 +37,33 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V1: DriveVoteMethodVersions = DriveVoteMeth fetch_identity_contested_resource_vote: 0, fetch_contested_document_vote_poll_contender_count: 0, }, + readiness: DriveVoteReadinessMethodVersions { + open_round: None, + cancel_round: None, + retire_round: None, + insert_report: None, + fetch_round: None, + fetch_raw_count: None, + fetch_reports_page: None, + fetch_rounds_page: None, + store_scan_cursor: None, + fetch_scan_cursor: None, + clear_scan_cursor: None, + update_round_evaluation: None, + record_crossing: None, + fetch_rounds_due: None, + activate_round: None, + prune_reports: None, + fetch_retired_round: None, + cleanup_retired_round: None, + store_evaluation_cursor: None, + fetch_evaluation_cursor: None, + add_fund: None, + deduct_from_fund: None, + empty_fund: None, + fetch_fund: None, + prove_fund: None, + credit_pool: None, + estimation_costs: None, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs index 4a7c24f091a..5349cb20e39 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs @@ -1,7 +1,8 @@ use crate::version::drive_versions::drive_vote_method_versions::{ DriveVoteCleanupMethodVersions, DriveVoteContestedResourceInsertMethodVersions, DriveVoteFetchMethodVersions, DriveVoteInsertMethodVersions, DriveVoteMethodVersions, - DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, + DriveVoteReadinessMethodVersions, DriveVoteSetupMethodVersions, + DriveVoteStorageFormMethodVersions, }; pub const DRIVE_VOTE_METHOD_VERSIONS_V2: DriveVoteMethodVersions = DriveVoteMethodVersions { @@ -36,4 +37,33 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V2: DriveVoteMethodVersions = DriveVoteMeth fetch_identity_contested_resource_vote: 0, fetch_contested_document_vote_poll_contender_count: 0, }, + readiness: DriveVoteReadinessMethodVersions { + open_round: None, + cancel_round: None, + retire_round: None, + insert_report: None, + fetch_round: None, + fetch_raw_count: None, + fetch_reports_page: None, + fetch_rounds_page: None, + store_scan_cursor: None, + fetch_scan_cursor: None, + clear_scan_cursor: None, + update_round_evaluation: None, + record_crossing: None, + fetch_rounds_due: None, + activate_round: None, + prune_reports: None, + fetch_retired_round: None, + cleanup_retired_round: None, + store_evaluation_cursor: None, + fetch_evaluation_cursor: None, + add_fund: None, + deduct_from_fund: None, + empty_fund: None, + fetch_fund: None, + prove_fund: None, + credit_pool: None, + estimation_costs: None, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs index f25e7a74859..e8bb8080a4b 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs @@ -1,7 +1,8 @@ use crate::version::drive_versions::drive_vote_method_versions::{ DriveVoteCleanupMethodVersions, DriveVoteContestedResourceInsertMethodVersions, DriveVoteFetchMethodVersions, DriveVoteInsertMethodVersions, DriveVoteMethodVersions, - DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, + DriveVoteReadinessMethodVersions, DriveVoteSetupMethodVersions, + DriveVoteStorageFormMethodVersions, }; /// Drive vote methods for protocol v14+. @@ -44,4 +45,33 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V3: DriveVoteMethodVersions = DriveVoteMeth fetch_identity_contested_resource_vote: 0, fetch_contested_document_vote_poll_contender_count: 0, }, + readiness: DriveVoteReadinessMethodVersions { + open_round: None, + cancel_round: None, + retire_round: None, + insert_report: None, + fetch_round: None, + fetch_raw_count: None, + fetch_reports_page: None, + fetch_rounds_page: None, + store_scan_cursor: None, + fetch_scan_cursor: None, + clear_scan_cursor: None, + update_round_evaluation: None, + record_crossing: None, + fetch_rounds_due: None, + activate_round: None, + prune_reports: None, + fetch_retired_round: None, + cleanup_retired_round: None, + store_evaluation_cursor: None, + fetch_evaluation_cursor: None, + add_fund: None, + deduct_from_fund: None, + empty_fund: None, + fetch_fund: None, + prove_fund: None, + credit_pool: None, + estimation_costs: None, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v4.rs new file mode 100644 index 00000000000..b0e12f1c3f9 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v4.rs @@ -0,0 +1,50 @@ +use crate::version::drive_versions::drive_vote_method_versions::v3::DRIVE_VOTE_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_vote_method_versions::{ + DriveVoteMethodVersions, DriveVoteReadinessMethodVersions, DriveVoteSetupMethodVersions, +}; + +/// Introduced in protocol version 17 (5.0) for compilation readiness. +/// +/// Changes over [`DRIVE_VOTE_METHOD_VERSIONS_V3`]: +/// +/// * `setup.add_initial_vote_tree_main_structure_operations` 0 -> 1: genesis creates the +/// readiness subtree `[Votes] / r` with its four children and the readiness fund sum tree +/// `[PreFundedSpecializedBalances] / 129` beside the voting fund tree. The fund tree is +/// created here rather than by the unversioned prefunded helper because genesis builds every +/// lower layer in one batch and this dispatcher has its own slot. +/// * the `readiness` method group turns on at generation 0. +pub const DRIVE_VOTE_METHOD_VERSIONS_V4: DriveVoteMethodVersions = DriveVoteMethodVersions { + setup: DriveVoteSetupMethodVersions { + add_initial_vote_tree_main_structure_operations: 1, // changed in v17: readiness subtree and fund tree at genesis + }, + readiness: DriveVoteReadinessMethodVersions { + open_round: Some(0), + cancel_round: Some(0), + retire_round: Some(0), + insert_report: Some(0), + fetch_round: Some(0), + fetch_raw_count: Some(0), + fetch_reports_page: Some(0), + fetch_rounds_page: Some(0), + store_scan_cursor: Some(0), + fetch_scan_cursor: Some(0), + clear_scan_cursor: Some(0), + update_round_evaluation: Some(0), + record_crossing: Some(0), + fetch_rounds_due: Some(0), + activate_round: Some(0), + prune_reports: Some(0), + fetch_retired_round: Some(0), + cleanup_retired_round: Some(0), + store_evaluation_cursor: Some(0), + fetch_evaluation_cursor: Some(0), + add_fund: Some(0), + deduct_from_fund: Some(0), + empty_fund: Some(0), + fetch_fund: Some(0), + prove_fund: Some(0), + credit_pool: Some(0), + estimation_costs: Some(0), + }, + ..DRIVE_VOTE_METHOD_VERSIONS_V3 +}; diff --git a/packages/rs-platform-version/src/version/drive_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/mod.rs index 1d7c32caed6..889baec9f92 100644 --- a/packages/rs-platform-version/src/version/drive_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/mod.rs @@ -30,6 +30,7 @@ pub mod drive_token_method_versions; pub mod drive_verify_method_versions; pub mod drive_vote_method_versions; pub mod v1; +pub mod v10; pub mod v2; pub mod v3; pub mod v4; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs new file mode 100644 index 00000000000..930a6d795e8 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -0,0 +1,24 @@ +use crate::version::drive_versions::drive_vote_method_versions::v4::DRIVE_VOTE_METHOD_VERSIONS_V4; +use crate::version::drive_versions::v9::DRIVE_VERSION_V9; +use crate::version::drive_versions::{DriveMethodVersions, DriveVersion}; + +/// Drive version 10. +/// Introduced in protocol v17, the 5.0 protocol version, for compilation readiness: the +/// rounds, reports, cursors and deadlines Drive keeps under `[Votes] / r` and the readiness +/// funds under `[PreFundedSpecializedBalances] / 129`. +/// +/// * **Genesis and storage**: `DRIVE_VOTE_METHOD_VERSIONS_V4` bumps +/// `setup.add_initial_vote_tree_main_structure_operations` to 1 so genesis creates both +/// subtrees (the upgrade path creates the same elements with insert-if-not-exists), and +/// turns on the `readiness` method group. +/// * **Proofs**: the three readiness verifiers are generation 0 in every verify table, so the +/// verify table of `DRIVE_VERSION_V9` is kept as is. +/// +/// Everything else matches `DRIVE_VERSION_V9`. +pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { + methods: DriveMethodVersions { + vote: DRIVE_VOTE_METHOD_VERSIONS_V4, // changed in v10: readiness subtrees at genesis and the readiness method group + ..DRIVE_VERSION_V9.methods + }, + ..DRIVE_VERSION_V9 +}; diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 0ce27215cc1..6c232eb0026 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -615,6 +615,8 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_document_expirations_per_block: 0, max_document_expiration_weight_per_block: 0, minimum_grovedb_proof_envelope_version: 0, + readiness_additional_wait_min_ms: None, + readiness_additional_wait_max_ms: None, }, consensus: ConsensusVersions { tenderdash_consensus_version: 0, diff --git a/packages/rs-platform-version/src/version/mod.rs b/packages/rs-platform-version/src/version/mod.rs index 1b1635efb42..18768b37fbe 100644 --- a/packages/rs-platform-version/src/version/mod.rs +++ b/packages/rs-platform-version/src/version/mod.rs @@ -1,6 +1,6 @@ mod protocol_version; -use crate::version::v14::PROTOCOL_VERSION_14; +use crate::version::v17::PROTOCOL_VERSION_17; pub use protocol_version::*; use std::ops::RangeInclusive; @@ -20,6 +20,9 @@ pub mod v11; pub mod v12; pub mod v13; pub mod v14; +pub mod v15; +pub mod v16; +pub mod v17; pub mod v2; pub mod v3; pub mod v4; @@ -33,5 +36,5 @@ pub type ProtocolVersion = u32; pub const ALL_VERSIONS: RangeInclusive = 1..=LATEST_VERSION; -pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_14; +pub const LATEST_VERSION: ProtocolVersion = PROTOCOL_VERSION_17; pub const INITIAL_PROTOCOL_VERSION: ProtocolVersion = 1; diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index 00cc470bbc7..96236d1b0a5 100644 --- a/packages/rs-platform-version/src/version/protocol_version.rs +++ b/packages/rs-platform-version/src/version/protocol_version.rs @@ -22,6 +22,9 @@ use crate::version::v11::PLATFORM_V11; use crate::version::v12::PLATFORM_V12; use crate::version::v13::PLATFORM_V13; use crate::version::v14::PLATFORM_V14; +use crate::version::v15::PLATFORM_V15; +use crate::version::v16::PLATFORM_V16; +use crate::version::v17::PLATFORM_V17; use crate::version::v2::PLATFORM_V2; use crate::version::v3::PLATFORM_V3; use crate::version::v4::PLATFORM_V4; @@ -61,6 +64,9 @@ pub const PLATFORM_VERSIONS: &[PlatformVersion] = &[ PLATFORM_V12, PLATFORM_V13, PLATFORM_V14, + PLATFORM_V15, + PLATFORM_V16, + PLATFORM_V17, ]; #[cfg(feature = "mock-versions")] @@ -69,7 +75,7 @@ pub static PLATFORM_TEST_VERSIONS: OnceLock> = OnceLock::ne #[cfg(feature = "mock-versions")] const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = &[TEST_PLATFORM_V2, TEST_PLATFORM_V3]; -pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V14; +pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V17; pub const DESIRED_PLATFORM_VERSION: &PlatformVersion = LATEST_PLATFORM_VERSION; diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 0567a7c2f6f..bc388e2aac0 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -2,6 +2,7 @@ pub mod v1; pub mod v2; pub mod v3; pub mod v4; +pub mod v5; #[derive(Clone, Debug, Default)] pub struct SystemLimits { @@ -315,6 +316,15 @@ pub struct SystemLimits { /// version 3 (protocol version 13), so every live network already serves /// V1 by the time the floor applies. pub minimum_grovedb_proof_envelope_version: u32, + /// Lower bound in milliseconds of the additional wait between a compilation readiness + /// crossing and the activation of the bundle: `wait = clamp(T, min, max)` where `T` is the + /// time the round took to cross. Confirmed policy (two minutes); only its placement in the + /// tables is a choice. Read by the readiness block event from protocol version 17. `None` + /// on the protocol versions that predate compilation readiness. + pub readiness_additional_wait_min_ms: Option, + /// Upper bound in milliseconds of the same additional wait (one hour). `None` on the + /// protocol versions that predate compilation readiness. + pub readiness_additional_wait_max_ms: Option, } #[cfg(test)] diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index a9b5e7fdbb6..633a3c513f0 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -83,4 +83,6 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_document_expirations_per_block: 0, max_document_expiration_weight_per_block: 0, minimum_grovedb_proof_envelope_version: 0, // V0 envelopes stay accepted until v14 + readiness_additional_wait_min_ms: None, + readiness_additional_wait_max_ms: None, }; diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index bb5d64573be..e7ffb3a336b 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -64,4 +64,6 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_document_expirations_per_block: 0, max_document_expiration_weight_per_block: 0, minimum_grovedb_proof_envelope_version: 0, // V0 envelopes stay accepted until v14 + readiness_additional_wait_min_ms: None, + readiness_additional_wait_max_ms: None, }; diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index b54132c50e2..48883c1b32f 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -66,4 +66,6 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_document_expirations_per_block: 0, max_document_expiration_weight_per_block: 0, minimum_grovedb_proof_envelope_version: 0, // V0 envelopes stay accepted until v14 + readiness_additional_wait_min_ms: None, + readiness_additional_wait_max_ms: None, }; diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index a7b23123841..0cc48f1177f 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -146,4 +146,6 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_document_expirations_per_block: 128, // document ttl (new in v14): expired documents deleted per block max_document_expiration_weight_per_block: 1_024, // document ttl (new in v14): deleted documents plus their index levels per block minimum_grovedb_proof_envelope_version: 1, // clients reject legacy V0 GroveDB proof envelopes from v14 + readiness_additional_wait_min_ms: None, + readiness_additional_wait_max_ms: None, }; diff --git a/packages/rs-platform-version/src/version/system_limits/v5.rs b/packages/rs-platform-version/src/version/system_limits/v5.rs new file mode 100644 index 00000000000..95ba5226593 --- /dev/null +++ b/packages/rs-platform-version/src/version/system_limits/v5.rs @@ -0,0 +1,26 @@ +use crate::version::system_limits::v4::SYSTEM_LIMITS_V4; +use crate::version::system_limits::SystemLimits; + +/// System limits for protocol version 17 (5.0) and above. +/// +/// Identical to [`SYSTEM_LIMITS_V4`] except that the additional wait between a compilation +/// readiness crossing and the activation of the bundle is bounded: `clamp(T, 2 minutes, 1 hour)` +/// where `T` is the time the round took to cross. Both bounds are confirmed policy; the block +/// event that applies them reads these fields. +pub const SYSTEM_LIMITS_V5: SystemLimits = SystemLimits { + readiness_additional_wait_min_ms: Some(120_000), // two minutes, confirmed policy + readiness_additional_wait_max_ms: Some(3_600_000), // one hour, confirmed policy + ..SYSTEM_LIMITS_V4 +}; + +// Whatever the revision of the numbers above is, the wait must stay a non-empty interval. +const _: () = assert!( + match ( + SYSTEM_LIMITS_V5.readiness_additional_wait_min_ms, + SYSTEM_LIMITS_V5.readiness_additional_wait_max_ms, + ) { + (Some(min), Some(max)) => min > 0 && min <= max, + _ => false, + }, + "SYSTEM_LIMITS_V5 must carry a non-empty readiness wait interval" +); diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs new file mode 100644 index 00000000000..d091a5f1aee --- /dev/null +++ b/packages/rs-platform-version/src/version/v15.rs @@ -0,0 +1,21 @@ +use crate::version::protocol_version::PlatformVersion; +use crate::version::v14::PLATFORM_V14; +use crate::version::ProtocolVersion; + +pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; + +/// Placeholder for the 4.3 protocol version. +/// +/// The DashVM allocation register reserves protocol version 15 for the 4.3 release, 16 for 4.4 +/// and 17 for 5.0, and the registry is indexed by number, so the 5.0 version cannot exist on +/// this branch without 15 and 16. Until the 4.3 branch merges its real `v15.rs` forward this +/// version is identical to v14. +/// +/// It is written as a struct update rather than a copy of `v14.rs` on purpose: when the real +/// file arrives the add/add conflict is resolved by taking the incoming file, and because v16 +/// and v17 are struct updates over their predecessor every table the incoming version changes +/// flows into them without a second edit. +pub const PLATFORM_V15: PlatformVersion = PlatformVersion { + protocol_version: PROTOCOL_VERSION_15, + ..PLATFORM_V14 +}; diff --git a/packages/rs-platform-version/src/version/v16.rs b/packages/rs-platform-version/src/version/v16.rs new file mode 100644 index 00000000000..e3d80c4b16e --- /dev/null +++ b/packages/rs-platform-version/src/version/v16.rs @@ -0,0 +1,16 @@ +use crate::version::protocol_version::PlatformVersion; +use crate::version::v15::PLATFORM_V15; +use crate::version::ProtocolVersion; + +pub const PROTOCOL_VERSION_16: ProtocolVersion = 16; + +/// Placeholder for the 4.4 protocol version. +/// +/// Reserved by the DashVM allocation register (15 for 4.3, 16 for 4.4, 17 for 5.0). Identical to +/// v15 until the 4.4 branch merges its real `v16.rs` forward; see `v15.rs` for why it is a +/// struct update and how the forward merge is resolved. Should 4.4 ship no consensus change, the +/// register drops this activation and the 5.0 version becomes 16. +pub const PLATFORM_V16: PlatformVersion = PlatformVersion { + protocol_version: PROTOCOL_VERSION_16, + ..PLATFORM_V15 +}; diff --git a/packages/rs-platform-version/src/version/v17.rs b/packages/rs-platform-version/src/version/v17.rs new file mode 100644 index 00000000000..cccee9f7b2d --- /dev/null +++ b/packages/rs-platform-version/src/version/v17.rs @@ -0,0 +1,50 @@ +use crate::version::dpp_versions::dpp_voting_versions::v3::VOTING_VERSION_V3; +use crate::version::dpp_versions::DPPVersion; +use crate::version::drive_abci_versions::drive_abci_method_versions::v11::DRIVE_ABCI_METHOD_VERSIONS_V11; +use crate::version::drive_abci_versions::DriveAbciVersion; +use crate::version::drive_versions::v10::DRIVE_VERSION_V10; +use crate::version::protocol_version::PlatformVersion; +use crate::version::system_limits::v5::SYSTEM_LIMITS_V5; +use crate::version::v16::PLATFORM_V16; +use crate::version::ProtocolVersion; + +pub const PROTOCOL_VERSION_17: ProtocolVersion = 17; + +/// The 5.0 protocol version, the one that introduces smart contracts. Provisional number from +/// the DashVM allocation register (15 for 4.3, 16 for 4.4, 17 for 5.0). +/// +/// Changes over v16 so far: +/// +/// * `DRIVE_VERSION_V10` adds compilation readiness storage: rounds keyed under +/// `[Votes] / r / 0 / contract_id / round_id` with a current-round pointer, a count tree of +/// accepted reports per round, the paged scan cursor, the activation deadline queue at +/// `[Votes] / r / 1`, the block event's fairness cursor at `[Votes] / r / 2`, the retired +/// round cleanup queue at `[Votes] / r / 3`, and the readiness funds sum tree at +/// `[PreFundedSpecializedBalances] / 129` beside the voting funds. Genesis creates them +/// through `add_initial_vote_tree_main_structure_operations` generation 1. The three +/// verifiers of a round, a report and a fund are generation 0 in every verify table, so the +/// verify table is unchanged. +/// * `DRIVE_ABCI_METHOD_VERSIONS_V11` selects generation 3 of the protocol change hook, which +/// creates the same structures on upgraded nodes. +/// * `VOTING_VERSION_V3` declares the structure versions of the round, report record and +/// scan cursor. +/// * `SYSTEM_LIMITS_V5` bounds the additional wait before activation to two minutes and one +/// hour. +/// +/// The subtree keys, the fund purpose key and the wait placement are provisional (the +/// allocation register leaves new inner tags unallocated) and are revised, if at all, before +/// any network is asked to propose this version. +pub const PLATFORM_V17: PlatformVersion = PlatformVersion { + protocol_version: PROTOCOL_VERSION_17, + drive: DRIVE_VERSION_V10, // changed: compilation readiness storage and genesis setup + drive_abci: DriveAbciVersion { + methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: protocol change hook generation 3 creates the readiness structures + ..PLATFORM_V16.drive_abci + }, + dpp: DPPVersion { + voting_versions: VOTING_VERSION_V3, // changed: readiness round, report record and scan cursor structure versions + ..PLATFORM_V16.dpp + }, + system_limits: SYSTEM_LIMITS_V5, // changed: readiness additional wait bounds + ..PLATFORM_V16 +};